Add per-game Sol-R1 Linux input mapping system with reusable template

Restructures around one folder per game, each with its own joyful
config, launch wrapper, and (if needed) a stateful daemon for physical
switches that need game-state feedback. template/ generalizes that
pattern (bare passthrough config, template wrapper, template daemon)
for mapping new games. space-engineers/ is the first instance, built
from the original mapping brief; find_codes.py is shared across games.
All device codes/keybinds/HUD calibration are still TODO pending
hardware access.
main
Amir Alexander Abdelbaki 2026-07-13 10:19:27 +02:00
commit 79c9ed94c4
13 changed files with 1214 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# sol-r1-linux-game-mappings
Custom input mappings for the Thrustmaster Sol-R1 HOTAS (grip + base)
on Linux (Arch/Hyprland), without Windows/T.A.R.G.E.T. Built on
[joyful](https://aur.archlinux.org/packages/joyful), a Go-based Linux
joystick remapper that turns physical Sol-R1 input into a virtual
gamepad via YAML rules.
## Layout
- `find_codes.py` - shared diagnostic tool. Run against the physical
hardware first, for any game, to get real evdev `BTN_*`/`ABS_*`
codes to fill into that game's config.
- `template/` - starting point for mapping a new game: a bare
passthrough joyful config (stick behaves normally, nothing remapped
yet), a template Steam launch-options wrapper, and a template
stateful input-converter daemon for controls that need game-state
feedback (real on/off switches mapped to a game's stateless toggle
key). See `template/README.md`.
- `<game>/` - one folder per game, each self-contained (its own
joyful config, launch wrapper, and daemon + config if it needs one).
Currently:
- `space-engineers/`
## Adding a new game
Copy `template/` to `<game-name>/` and follow `template/README.md`.
## Why a daemon at all
joyful is stateless - it has no idea what's happening in the game, so
it can only pass a control through or remap it. That's fine for
buttons, triggers, and continuous axes. It breaks down for a real
physical on/off switch mapped to a game action that's only exposed as
a stateless "press to toggle" key: the switch position can silently
drift out of sync with the game's actual state. Where that happens, a
small per-game daemon polls both the physical switch and the game's
actual state (usually by sampling a HUD pixel via `grim`, absent a
better signal) and only sends a correction keypress when they
disagree. See `template/input_converter_daemon.py` and
`space-engineers/switch_sync.py` for a worked example.

122
find_codes.py Executable file
View File

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
find_codes.py - print real evdev BTN_*/ABS_* codes for the Sol-R1
grip and base, so they can replace the TODO placeholders in any
game's joyful-config.yml / daemon config.
Requirements:
pip install evdev --break-system-packages
Permissions:
sudo usermod -aG input $USER # for reading the joystick
(re-login after this)
Usage:
# Step 1: find the exact device name(s) - grip and base usually
# show up as two separate evdev devices.
python3 find_codes.py --list
# Step 2: watch a device and exercise every physical control on it
# (move every axis through its full range, press every button,
# flip every switch both ways, run every rotary knob both ways).
python3 find_codes.py --device "Sol-R"
"""
import argparse
import selectors
import sys
try:
from evdev import InputDevice, ecodes, list_devices
except ImportError:
sys.exit("Missing deps. Run:\n pip install evdev --break-system-packages")
def list_all_devices():
devices = [InputDevice(path) for path in list_devices()]
if not devices:
print("No input devices found (check permissions: groups $USER).")
return
for dev in devices:
print(f"{dev.path}\t{dev.name}")
def find_devices(name_substr: str):
matches = [
dev for dev in (InputDevice(path) for path in list_devices())
if name_substr.lower() in dev.name.lower()
]
if not matches:
sys.exit(
f"No input device matching '{name_substr}' found. "
f"Run --list to see available device names."
)
if len(matches) > 1:
print(f"Multiple devices match '{name_substr}', watching all:")
for d in matches:
print(f" {d.path}\t{d.name}")
print()
return matches
def code_name(ev_type: int, code: int) -> str:
"""Resolve a numeric code back to its BTN_*/ABS_*/KEY_* name(s)."""
names = ecodes.bytype.get(ev_type, {}).get(code)
if names is None:
return str(code)
return names if isinstance(names, str) else "/".join(names)
def watch(devices):
print("Exercise every physical control now (axes through full "
"range, buttons pressed, switches flipped both ways, knobs "
"turned both ways). Ctrl+C to stop.\n")
sel = selectors.DefaultSelector()
for dev in devices:
sel.register(dev, selectors.EVENT_READ, data=dev)
try:
while True:
for key, _ in sel.select():
dev = key.data
for event in dev.read():
if event.type == ecodes.EV_KEY:
name = code_name(ecodes.EV_KEY, event.code)
state = {0: "UP", 1: "DOWN", 2: "HOLD"}.get(
event.value, f"value={event.value}"
)
print(f"[{dev.name}] {name} ({event.code}) {state}")
elif event.type == ecodes.EV_ABS:
name = code_name(ecodes.EV_ABS, event.code)
print(f"[{dev.name}] {name} ({event.code}) = {event.value}")
except KeyboardInterrupt:
print("\nStopped.")
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--list", action="store_true", help="list all evdev input devices and exit"
)
parser.add_argument(
"--device", metavar="NAME",
help="substring match against evdev device name(s) to watch",
)
args = parser.parse_args()
if args.list:
list_all_devices()
return
if args.device:
watch(find_devices(args.device))
return
parser.print_help()
if __name__ == "__main__":
main()

73
space-engineers/README.md Normal file
View File

@ -0,0 +1,73 @@
# Space Engineers
Custom input scheme for the Thrustmaster Sol-R1 (grip + base) in Space
Engineers, on Arch/Hyprland, without Windows/T.A.R.G.E.T. Fixes the
"throttle behaves like a trigger" bug and adds a full custom mapping
via joyful plus a stateful daemon (`switch_sync.py`) for the 4 tilt
switches, which need game-state feedback that joyful can't provide.
**Status: draft.** No real evdev codes, SE keybinds, or HUD
calibration have been captured yet - every config file here is full of
`TODO_*` placeholders. See `mapping-brief.md` for the full design
rationale, the complete mapping table, and known caveats/open items.
## Files
- `mapping-brief.md` - design notes: stack decisions, full
physical-control -> SE-function mapping table, caveats, next steps.
- `joyful-config.yml` - joyful rule config (**draft, unverified field
names** - check against the installed version's
`examples/ruletypes.yml` first).
- `switch_sync.py` - the daemon: reconciles physical switch position
against actual in-game HUD state via `grim` screen-color sampling,
and only sends a toggle keypress when they disagree.
- `switch_sync-config.yaml` - config for the 4 tilt switches.
- `launch-wrapper.sh` - Steam launch-options wrapper (`%command%`)
that starts/stops joyful and `switch_sync.py` around the game
session, cleaning up on exit.
(`find_codes.py`, the diagnostic tool used in step 2 below, lives at
the repo root - it's shared across all games.)
## Setup
1. Install dependencies:
```
yay -S joyful
sudo pacman -S grim
pip install evdev pillow pyyaml --break-system-packages
sudo usermod -aG input $USER # for reading the joystick + uinput
# re-login after this
```
2. Find real evdev codes:
```
python3 ../find_codes.py --list
python3 ../find_codes.py --device "<grip or base device name>"
```
Exercise every physical control (axes through full range, buttons,
both positions of every switch, both directions of every knob), and
replace every `TODO_*` code in `joyful-config.yml` and
`switch_sync-config.yaml` with what you observe.
3. In Space Engineers (Options -> Controls -> Controllers), confirm
the actual keybinds for the jetpack/helmet/flashlight/parking
toggles - don't assume defaults - and fill in `toggle_key` for each
switch in `switch_sync-config.yaml`.
4. Calibrate the HUD regions/colors:
```
python3 switch_sync.py --sample X,Y
```
for each indicator in-game, and fill in `region`/`on_color` in
`switch_sync-config.yaml`.
5. In Steam, set this game's launch options to:
```
/path/to/sol-r1-linux-game-mappings/space-engineers/launch-wrapper.sh %command%
```
Launch options are per-machine and not synced by Steam - repeat
this step on every machine. Keeping this repo checked out at the
same path on every machine keeps the launch-options string itself
identical too.
See `mapping-brief.md` for the full mapping table and caveats:
SE-version-dependent joystick support, the dampener 3-state
limitation, HUD-detection fragility (breaks on Tab/resolution/UI-scale
changes), and the unverified joyful rule schema.

View File

@ -0,0 +1,121 @@
# joyful-config.yml - joyful config for Thrustmaster Sol-R1
# (grip + base) -> Space Engineers.
#
# DRAFT / UNVERIFIED: joyful's exact per-rule-type field names below
# were not checked against `examples/ruletypes.yml` in the installed
# version - do that before trusting this file. See mapping-brief.md
# "Known caveats".
#
# Every TODO_* below is a placeholder. Run find_codes.py (repo root)
# against the actual hardware and replace every one before use.
devices:
base:
match: "TODO: Sol-R Base" # evdev device name substring, see find_codes.py --list
grip:
match: "TODO: Sol-R Grip"
modes:
space_engineers:
devices: [base, grip]
rules:
# Throttle (base) -> Move Forward/Backward.
# Original bug: SE read the raw HID throttle axis like a trigger.
# Straight axis passthrough fixes it.
- device: base
type: axis
input: TODO_ABS_THROTTLE
output: ABS_Y
# Main stick (grip) X/Y -> View/look/rotate
- device: grip
type: axis
input: TODO_ABS_STICK_X
output: ABS_RX
- device: grip
type: axis
input: TODO_ABS_STICK_Y
output: ABS_RY
# Ministick (grip) -> Strafe horizontal/vertical
- device: grip
type: axis
input: TODO_ABS_MINISTICK_X
output: ABS_Z
- device: grip
type: axis
input: TODO_ABS_MINISTICK_Y
output: ABS_RZ
# Dual-position trigger stage 1/2 -> Primary/secondary click (use/fire, alt-fire)
- device: grip
type: button
input: TODO_BTN_TRIGGER_STAGE1
output: BTN_TRIGGER
- device: grip
type: button
input: TODO_BTN_TRIGGER_STAGE2
output: BTN_THUMB
# 2 orange buttons (top of stick) -> Dampeners on/off (Z) + Relative
# dampers (Ctrl+Z). Key emulation, not gamepad buttons - Relative
# Dampers isn't confirmed as a standalone bindable action, it's a
# modifier on the toggle key.
- device: grip
type: key
input: TODO_BTN_ORANGE_1
output: [KEY_Z]
- device: grip
type: key
input: TODO_BTN_ORANGE_2
output: [KEY_LEFTCTRL, KEY_Z]
# Rotary knob 1 (base) -> Switch toolbar (next/prev, default `,`/`.`).
# ASSUMES the knob reports as BTN detent pulses, not an ABS axis -
# confirm with find_codes.py. If it's an ABS axis instead, this
# needs joyful's segmented axis-to-button rule type instead.
- device: base
type: key
input: TODO_BTN_KNOB1_CW
output: [KEY_DOT]
- device: base
type: key
input: TODO_BTN_KNOB1_CCW
output: [KEY_COMMA]
# Rotary knob 2 (base) -> Hotbar slot select, same pulse assumption
# as knob 1 above.
- device: base
type: key
input: TODO_BTN_KNOB2_CW
output: [TODO_KEY_HOTBAR_NEXT]
- device: base
type: key
input: TODO_BTN_KNOB2_CCW
output: [TODO_KEY_HOTBAR_PREV]
# 4-button group (base, left side) -> Jetpack/Helmet/Flashlight/
# Parking toggles, bound in SE's own control menu (gamepad button
# passthrough, not key emulation).
- device: base
type: button
input: TODO_BTN_GROUP_JETPACK
output: BTN_TRIGGER_HAPPY1
- device: base
type: button
input: TODO_BTN_GROUP_HELMET
output: BTN_TRIGGER_HAPPY2
- device: base
type: button
input: TODO_BTN_GROUP_FLASHLIGHT
output: BTN_TRIGGER_HAPPY3
- device: base
type: button
input: TODO_BTN_GROUP_PARKING
output: BTN_TRIGGER_HAPPY4
# NOTE: the 4 tilt switches (base, top) are intentionally NOT mapped
# here - they're handled by switch_sync.py instead of joyful,
# because they need game-state feedback to avoid desync. See
# switch_sync-config.yaml.

View File

@ -0,0 +1,25 @@
#!/bin/bash
# launch-wrapper.sh - starts joyful + switch_sync, runs the game,
# cleans up when it exits (however it exits).
#
# Steam launch options: /path/to/space-engineers/launch-wrapper.sh %command%
# (per-machine, not synced by Steam - re-paste after cloning on a new
# machine; keeping the repo checked out at the same path everywhere
# keeps the launch-options string itself identical too.)
REPO_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
JOYFUL_CONFIG="$REPO_DIR/joyful-config.yml"
SYNC_CONFIG="$REPO_DIR/switch_sync-config.yaml"
joyful --config "$JOYFUL_CONFIG" &
JOYFUL_PID=$!
python3 "$REPO_DIR/switch_sync.py" --config "$SYNC_CONFIG" &
SYNC_PID=$!
# clean up even if this script gets killed/terminated, not just on normal exit
trap 'kill "$JOYFUL_PID" "$SYNC_PID" 2>/dev/null' EXIT
# "$@" is the substituted real launch command (Proton + the game)
"$@"

View File

@ -0,0 +1,79 @@
# Thrustmaster Sol-R 1 -> Space Engineers input mapping (Linux/Arch/Hyprland)
## Goal
Fix "throttle behaves like a trigger" bug and build a full custom input
scheme for the Sol-R1 (grip + base) in Space Engineers, on Arch/Hyprland,
without Windows/T.A.R.G.E.T.
## Stack decided on
- **joyful** (AUR: `yay -S joyful`) - Go-based Linux joystick remapper,
creates a virtual gamepad device from physical Sol-R inputs via YAML
rules in `~/.config/joyful/`. Chosen over input-remapper because it
natively supports split-axis and segmented-axis-to-button mappings,
which input-remapper doesn't (see upstream issue #900).
- **switch_sync.py** (custom, in this repo/outputs) - a separate stateful
daemon for switches that need to reconcile physical position against
actual in-game state (bypasses joyful, which can't do this since it
has no game-state feedback).
- **Steam launch options wrapper script** - starts/stops joyful and
switch_sync around the actual game session via `%command%`, instead of
a Hyprland-IPC window-watcher (Proton WM_CLASS is unreliable for this).
NOTE: Steam launch options are per-machine (stored in
`userdata/<id>/config/localconfig.vdf`, not synced) - the wrapper
script path should be identical across machines (keep it in dotfiles),
but the launch-options string itself must be re-pasted into Steam on
each machine.
## Diagnostic tools (already written, in outputs/)
- `find_codes.py` - run this first on every physical control to get real
evdev BTN_*/ABS_* codes. Nothing below has real codes yet - they're
all `TODO:` placeholders until this is run.
- `switch_sync.py` - screen-reads HUD state via `grim` region capture +
color sampling, compares against physical switch position (evdev), and
taps a virtual key only when they disagree. Has `--sample X,Y` for
pixel-color calibration and `--dump-example-config`.
## Current mapping plan
| Physical control | SE function | Mechanism |
|---|---|---|
| Throttle (base) | Move Forward/Backward | joyful axis passthrough (was original bug: SE read raw HID axis like a trigger) |
| Main stick (grip) X/Y | View/look/rotate | joyful axis passthrough |
| Ministick (grip) | Strafe horizontal/vertical | joyful axis passthrough |
| Dual-position trigger, stage 1/2 | Primary/secondary click (use/fire, alt-fire) | joyful button passthrough |
| 2 orange buttons (top of stick) | Dampeners on/off (Z) + Relative dampers (Ctrl+Z) | joyful, KEY_Z / [KEY_LEFTCTRL, KEY_Z] key emulation (not gamepad buttons - Relative Dampers isn't confirmed as a standalone bindable action, it's a modifier on the toggle key) |
| Rotary knob 1 (base) | Switch toolbar (next/prev, default `,`/`.`) | joyful, detent pulses -> KEY_COMMA/KEY_DOT - ASSUMES knob reports as BTN pulses, not an ABS axis; confirm with find_codes.py |
| Rotary knob 2 (base) | Hotbar slot select | joyful, same pulse assumption as above; if it's an ABS axis instead, needs joyful's segmented axis-to-button feature |
| 4-button group (base, left side) | Jetpack / Helmet / Flashlight / Parking toggles | joyful button passthrough to virtual gamepad buttons, bound in SE's own control menu |
| 4 tilt switches (base, top), 2 buttons each | Same 4 toggles as above, but as REAL on/off switches | switch_sync.py - NOT joyful, because this needs game-state feedback to avoid desync |
## Known caveats / open items
- SE's native joystick support only got real per-binding customization in
update 1.209; before that (and possibly still, depending on version) it
treated joysticks through an Xbox-controller-shaped abstraction. Check
Options -> Controls -> Controllers on the current version before
assuming full flexibility.
- Dampeners are a stateless toggle (Z) + contextual modifier (Ctrl+Z,
only works if dampers are off AND you're looking at a moving target in
range). No true 3-state action exists in SE.
- `switch_sync.py`'s HUD detection is pixel-color based and fragile:
breaks on HUD detail mode changes (Tab), resolution/UI scale changes,
or camera view changes. Needs recalibration after any of those.
- joyful's exact per-rule-type YAML field names (beyond the top-level
devices/modes/rules structure and evdev keycode naming) were not fully
verified against `examples/ruletypes.yml` in the actual installed
version - check that file before trusting rule blocks verbatim.
- None of the joyful config blocks have real BTN_*/ABS_* codes yet - all
placeholders pending find_codes.py output on the actual hardware.
## Next steps
1. Run find_codes.py against every physical control, replace all TODOs.
2. Confirm rotary knobs are pulse (BTN) vs continuous (ABS) - determines
which joyful rule type to use.
3. Verify SE's actual keybinds for helmet/flashlight/parking in Options
before finalizing switch_sync.py's toggle_key values.
4. Calibrate switch_sync.py's HUD regions/colors for jetpack, helmet,
light, parking indicators.
5. Write the Steam launch-options wrapper script, add to dotfiles repo.
6. Repeat launch-options paste on each machine (desktop, Framework 12,
ThinkPad T440p) - not synced by Steam.

View File

@ -0,0 +1,47 @@
# switch_sync-config.yaml - switch_sync.py config for the Sol-R1's 4
# tilt switches (jetpack/helmet/flashlight/parking), kept in sync with
# actual in-game state instead of desyncing like a stateless toggle key.
#
# Every TODO placeholder here needs a real value before this is usable
# (see README "Setup" and mapping-brief.md "Next steps"):
# - input_on/input_off: run find_codes.py against the base to get
# real BTN_* codes for each switch's two held positions.
# - toggle_key: confirm the actual SE keybind in Options -> Controls
# -> Controllers first - do not assume a default.
# - region/on_color: run `switch_sync.py --sample X,Y` against the
# live HUD indicator to calibrate.
device_name: "TODO: Sol-R Base" # evdev device name substring, see find_codes.py --list
switches:
jetpack:
input_on: "TODO_BTN_JETPACK_UP"
input_off: "TODO_BTN_JETPACK_DOWN"
toggle_key: "TODO_KEY_JETPACK_TOGGLE"
region: [0, 0, 20, 20] # TODO: calibrate with --sample
on_color: [0, 0, 0] # TODO: calibrate with --sample
tolerance: 30
helmet:
input_on: "TODO_BTN_HELMET_UP"
input_off: "TODO_BTN_HELMET_DOWN"
toggle_key: "TODO_KEY_HELMET_TOGGLE"
region: [0, 0, 20, 20]
on_color: [0, 0, 0]
tolerance: 30
flashlight:
input_on: "TODO_BTN_FLASHLIGHT_UP"
input_off: "TODO_BTN_FLASHLIGHT_DOWN"
toggle_key: "TODO_KEY_FLASHLIGHT_TOGGLE"
region: [0, 0, 20, 20]
on_color: [0, 0, 0]
tolerance: 30
parking:
input_on: "TODO_BTN_PARKING_UP"
input_off: "TODO_BTN_PARKING_DOWN"
toggle_key: "TODO_KEY_PARKING_TOGGLE"
region: [0, 0, 20, 20]
on_color: [0, 0, 0]
tolerance: 30

245
space-engineers/switch_sync.py Executable file
View File

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
switch_sync.py - make a physical toggle switch behave like a REAL switch
in Space Engineers, instead of a stateless "press to toggle" key.
Problem: SE only exposes toggles (jetpack/helmet/light/parking) as a
single press-to-flip key. A physical on/off switch has no way to know
which state the game is currently in, so flipping it can desync (e.g.
switch says ON, game is actually OFF, because someone toggled it some
other way in between).
Fix: this script polls two things continuously -
1. The physical switch position (via evdev, reading held button state)
2. The ACTUAL in-game state (by sampling a pixel/region on screen)
and sends the toggle keypress ONLY when they disagree, via a virtual
uinput keyboard. Net effect: switch position always matches game state.
Requirements:
sudo pacman -S grim # Hyprland/wlroots screen capture
pip install evdev pillow pyyaml --break-system-packages
Permissions:
sudo usermod -aG input $USER # for reading the joystick + uinput
(re-login after this)
Usage:
# Step 1: calibrate - find the pixel/region + colors for each toggle
python3 switch_sync.py --sample 200,850
# Step 2: fill in config.yaml (see example below), then run:
python3 switch_sync.py --config config.yaml
"""
import argparse
import subprocess
import sys
import tempfile
import time
from pathlib import Path
try:
import yaml
from PIL import Image
from evdev import InputDevice, UInput, ecodes, list_devices
except ImportError:
sys.exit(
"Missing deps. Run:\n"
" pip install evdev pillow pyyaml --break-system-packages"
)
# --------------------------------------------------------------------
# Screen capture (Hyprland/wlroots via grim)
# --------------------------------------------------------------------
def grab_region(x: int, y: int, w: int, h: int) -> Image.Image:
"""Capture a screen region using grim and return a PIL Image."""
with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
geometry = f"{x},{y} {w}x{h}"
subprocess.run(
["grim", "-g", geometry, tmp.name],
check=True,
stderr=subprocess.DEVNULL,
)
return Image.open(tmp.name).convert("RGB").copy()
def sample_pixel(x: int, y: int):
"""Grab a 1x1 region and return its RGB - used for calibration."""
img = grab_region(x, y, 1, 1)
return img.getpixel((0, 0))
def region_matches_color(x, y, w, h, target_rgb, tolerance):
"""Average the region's color and compare to target within tolerance."""
img = grab_region(x, y, w, h)
pixels = list(img.getdata())
n = len(pixels)
avg = tuple(sum(p[i] for p in pixels) / n for i in range(3))
diff = sum(abs(avg[i] - target_rgb[i]) for i in range(3))
return diff <= tolerance
# --------------------------------------------------------------------
# Physical switch state (evdev)
# --------------------------------------------------------------------
class SwitchReader:
"""Tracks held button state for one or more physical devices."""
def __init__(self, device_name_substr: str):
self.dev = self._find_device(device_name_substr)
self.state = {} # code -> 0/1, current held state
self.dev.grab = False # don't steal input from the game
@staticmethod
def _find_device(name_substr):
for path in list_devices():
d = InputDevice(path)
if name_substr.lower() in d.name.lower():
return d
sys.exit(f"No input device matching '{name_substr}' found.")
def poll(self):
"""Non-blocking drain of pending events, updating held state."""
try:
for event in self.dev.read():
if event.type == ecodes.EV_KEY:
self.state[event.code] = event.value
except BlockingIOError:
pass
def code_for(self, name: str) -> int:
return getattr(ecodes, name)
def is_held(self, code_name: str) -> bool:
return self.state.get(self.code_for(code_name), 0) == 1
# --------------------------------------------------------------------
# Virtual keyboard output (uinput)
# --------------------------------------------------------------------
def make_virtual_keyboard():
return UInput(name="switch-sync-vkbd")
def tap_key(vkbd, key_name: str):
code = getattr(ecodes, key_name)
vkbd.write(ecodes.EV_KEY, code, 1)
vkbd.syn()
time.sleep(0.05)
vkbd.write(ecodes.EV_KEY, code, 0)
vkbd.syn()
# --------------------------------------------------------------------
# Main loop
# --------------------------------------------------------------------
def run(config_path: str, poll_interval: float):
cfg = yaml.safe_load(Path(config_path).read_text())
reader = SwitchReader(cfg["device_name"])
vkbd = make_virtual_keyboard()
print("switch_sync running. Ctrl+C to stop.\n")
try:
while True:
reader.poll()
for name, sw in cfg["switches"].items():
desired_on = reader.is_held(sw["input_on"])
# if neither position held (mid-flip / debounce), skip
if not desired_on and not reader.is_held(sw["input_off"]):
continue
region = sw["region"] # [x, y, w, h]
actual_on = region_matches_color(
*region,
target_rgb=sw["on_color"],
tolerance=sw.get("tolerance", 30),
)
if desired_on != actual_on:
print(f"[{name}] switch={'ON' if desired_on else 'OFF'} "
f"game={'ON' if actual_on else 'OFF'} -> correcting")
tap_key(vkbd, sw["toggle_key"])
time.sleep(0.3) # let the game UI update before re-checking
time.sleep(poll_interval)
except KeyboardInterrupt:
print("\nStopped.")
finally:
vkbd.close()
# --------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------
EXAMPLE_CONFIG = """\
# config.yaml - example, fill in with your real values
device_name: "Sol-R Base" # substring match against evdev device name
switches:
jetpack:
input_on: "BTN_TRIGGER_HAPPY9" # held while switch is flipped up
input_off: "BTN_TRIGGER_HAPPY10" # held while switch is flipped down
toggle_key: "KEY_SPACE" # SE's actual jetpack toggle bind
region: [1830, 980, 20, 20] # x, y, w, h of the HUD indicator
on_color: [255, 200, 0] # sampled color when jetpack is ON
tolerance: 30
helmet:
input_on: "BTN_TRIGGER_HAPPY11"
input_off: "BTN_TRIGGER_HAPPY12"
toggle_key: "KEY_H" # example, use your real bind
region: [1830, 1010, 20, 20]
on_color: [255, 200, 0]
tolerance: 30
# ... light / parking follow the same pattern
"""
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--sample", metavar="X,Y",
help="calibration helper: print the RGB color at screen pixel X,Y"
)
parser.add_argument(
"--dump-example-config", action="store_true",
help="print an example config.yaml to stdout"
)
parser.add_argument("--config", help="path to config.yaml")
parser.add_argument(
"--interval", type=float, default=0.25,
help="poll interval in seconds (default 0.25)"
)
args = parser.parse_args()
if args.dump_example_config:
print(EXAMPLE_CONFIG)
return
if args.sample:
x, y = map(int, args.sample.split(","))
rgb = sample_pixel(x, y)
print(f"Pixel ({x},{y}) = RGB{rgb}")
return
if args.config:
run(args.config, args.interval)
return
parser.print_help()
if __name__ == "__main__":
main()

33
template/README.md Normal file
View File

@ -0,0 +1,33 @@
# Template - adding a new game
Copy this whole folder to `../<game-name>/` and work through it top to
bottom.
1. **`joyful-config.yml`** - bare 1:1 passthrough (every physical
control mapped straight through, no remapping, no key emulation).
Run `find_codes.py` (repo root) against the hardware and fill in
every `TODO_*`. Get the game running with this first, so you have a
known-good baseline before customizing anything.
2. Customize the mapping for what the game actually needs: axis
remaps, key emulation for toggles, buttons bound in the game's own
control menu, etc. See `../space-engineers/` for a worked example
(including the "throttle read as a trigger" class of bug this
exists to fix).
3. **Do you need `input_converter_daemon.py` at all?** Only if some
physical control is a real on/off switch (not a momentary button)
*and* the game only exposes the equivalent action as a stateless
toggle key. If every control you're mapping is a stateless
passthrough, delete `input_converter_daemon.py`,
`TODO-daemon-config.yaml` (once created), and the daemon block in
`launch-wrapper.sh` - joyful alone is enough, and you're done after
step 2.
4. If you do need it: rename the example config
(`input_converter_daemon.py --dump-example-config > <game>-daemon-config.yaml`),
fill in real evdev codes (`find_codes.py`), confirm the game's
actual toggle keybinds in its own settings menu (never assume
defaults), and calibrate HUD regions/colors with
`input_converter_daemon.py --sample X,Y`. Point
`launch-wrapper.sh`'s `DAEMON_CONFIG` at it.
5. **`launch-wrapper.sh`** - set as the Steam launch options:
`/path/to/<game>/launch-wrapper.sh %command%`. Per-machine, not
synced by Steam - repeat on every machine.

View File

@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
input_converter_daemon.py - TEMPLATE stateful input-converter daemon.
Copy into a new <game>/ folder and customize when a game exposes a
control only as a stateless "press to toggle" key, but the physical
Sol-R1 control involved is a REAL on/off switch (or otherwise has a
fixed physical position that can drift out of sync with in-game
state). joyful alone can't do this - it has no game-state feedback,
so it can't tell a stateless key apart from a real switch.
This only needs to exist for controls that need that feedback loop.
If every physical control on a game's mapping is a stateless
passthrough (buttons, triggers, continuous axes), delete this file and
the daemon block in launch-wrapper.sh - joyful by itself is enough.
How it works: polls two things continuously -
1. The physical control's position (via evdev, reading held button
state for a two-position switch)
2. The ACTUAL in-game state, however it's readable - by default this
template samples a screen pixel/region via `grim` (HUD color),
since most games have no other externally-readable state. Swap
out `read_external_state()` for a different method if the game
exposes state some other way (e.g. a log file, a memory read, an
API).
and sends a correction keypress ONLY when they disagree, via a virtual
uinput keyboard. Net effect: physical switch position always matches
game state.
Requirements:
sudo pacman -S grim # Hyprland/wlroots screen capture -
# only needed if using the default
# pixel-sampling read_external_state()
pip install evdev pillow pyyaml --break-system-packages
Permissions:
sudo usermod -aG input $USER # for reading the joystick + uinput
(re-login after this)
Usage:
# Step 1: calibrate - find the pixel/region + colors for each control
python3 input_converter_daemon.py --sample 200,850
# Step 2: fill in a config (see --dump-example-config), then run:
python3 input_converter_daemon.py --config config.yaml
"""
import argparse
import subprocess
import sys
import tempfile
import time
from pathlib import Path
try:
import yaml
from PIL import Image
from evdev import InputDevice, UInput, ecodes, list_devices
except ImportError:
sys.exit(
"Missing deps. Run:\n"
" pip install evdev pillow pyyaml --break-system-packages"
)
# --------------------------------------------------------------------
# External (in-game) state - default implementation reads a screen
# region via grim. Replace this section if the game offers a better
# signal than pixel color.
# --------------------------------------------------------------------
def grab_region(x: int, y: int, w: int, h: int) -> Image.Image:
"""Capture a screen region using grim and return a PIL Image."""
with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
geometry = f"{x},{y} {w}x{h}"
subprocess.run(
["grim", "-g", geometry, tmp.name],
check=True,
stderr=subprocess.DEVNULL,
)
return Image.open(tmp.name).convert("RGB").copy()
def sample_pixel(x: int, y: int):
"""Grab a 1x1 region and return its RGB - used for calibration."""
img = grab_region(x, y, 1, 1)
return img.getpixel((0, 0))
def read_external_state(control_cfg: dict) -> bool:
"""True if the game's on-screen indicator reads as 'on'."""
x, y, w, h = control_cfg["region"]
img = grab_region(x, y, w, h)
pixels = list(img.getdata())
n = len(pixels)
avg = tuple(sum(p[i] for p in pixels) / n for i in range(3))
target_rgb = control_cfg["on_color"]
tolerance = control_cfg.get("tolerance", 30)
diff = sum(abs(avg[i] - target_rgb[i]) for i in range(3))
return diff <= tolerance
# --------------------------------------------------------------------
# Physical control state (evdev)
# --------------------------------------------------------------------
class PhysicalStateReader:
"""Tracks held button state for one physical device."""
def __init__(self, device_name_substr: str):
self.dev = self._find_device(device_name_substr)
self.state = {} # code -> 0/1, current held state
self.dev.grab = False # don't steal input from the game
@staticmethod
def _find_device(name_substr):
for path in list_devices():
d = InputDevice(path)
if name_substr.lower() in d.name.lower():
return d
sys.exit(f"No input device matching '{name_substr}' found.")
def poll(self):
"""Non-blocking drain of pending events, updating held state."""
try:
for event in self.dev.read():
if event.type == ecodes.EV_KEY:
self.state[event.code] = event.value
except BlockingIOError:
pass
def code_for(self, name: str) -> int:
return getattr(ecodes, name)
def is_held(self, code_name: str) -> bool:
return self.state.get(self.code_for(code_name), 0) == 1
# --------------------------------------------------------------------
# Virtual keyboard output (uinput)
# --------------------------------------------------------------------
def make_virtual_keyboard():
return UInput(name="input-converter-daemon-vkbd")
def tap_key(vkbd, key_name: str):
code = getattr(ecodes, key_name)
vkbd.write(ecodes.EV_KEY, code, 1)
vkbd.syn()
time.sleep(0.05)
vkbd.write(ecodes.EV_KEY, code, 0)
vkbd.syn()
# --------------------------------------------------------------------
# Main loop
# --------------------------------------------------------------------
def run(config_path: str, poll_interval: float):
cfg = yaml.safe_load(Path(config_path).read_text())
reader = PhysicalStateReader(cfg["device_name"])
vkbd = make_virtual_keyboard()
print("input_converter_daemon running. Ctrl+C to stop.\n")
try:
while True:
reader.poll()
for name, control in cfg["controls"].items():
desired_on = reader.is_held(control["input_on"])
# if neither position held (mid-flip / debounce), skip
if not desired_on and not reader.is_held(control["input_off"]):
continue
actual_on = read_external_state(control)
if desired_on != actual_on:
print(f"[{name}] physical={'ON' if desired_on else 'OFF'} "
f"game={'ON' if actual_on else 'OFF'} -> correcting")
tap_key(vkbd, control["toggle_key"])
time.sleep(0.3) # let the game UI update before re-checking
time.sleep(poll_interval)
except KeyboardInterrupt:
print("\nStopped.")
finally:
vkbd.close()
# --------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------
EXAMPLE_CONFIG = """\
# config.yaml - example, fill in with your real values
device_name: "TODO: Sol-R Base" # substring match against evdev device name
controls:
example_control:
input_on: "TODO_BTN_EXAMPLE_UP" # held while switch is flipped up
input_off: "TODO_BTN_EXAMPLE_DOWN" # held while switch is flipped down
toggle_key: "TODO_KEY_EXAMPLE" # the game's actual toggle keybind
region: [0, 0, 20, 20] # x, y, w, h of the HUD indicator
on_color: [0, 0, 0] # sampled color when the control is ON
tolerance: 30
# ... one block per physical switch that needs state reconciliation
"""
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--sample", metavar="X,Y",
help="calibration helper: print the RGB color at screen pixel X,Y"
)
parser.add_argument(
"--dump-example-config", action="store_true",
help="print an example config.yaml to stdout"
)
parser.add_argument("--config", help="path to config.yaml")
parser.add_argument(
"--interval", type=float, default=0.25,
help="poll interval in seconds (default 0.25)"
)
args = parser.parse_args()
if args.dump_example_config:
print(EXAMPLE_CONFIG)
return
if args.sample:
x, y = map(int, args.sample.split(","))
rgb = sample_pixel(x, y)
print(f"Pixel ({x},{y}) = RGB{rgb}")
return
if args.config:
run(args.config, args.interval)
return
parser.print_help()
if __name__ == "__main__":
main()

142
template/joyful-config.yml Normal file
View File

@ -0,0 +1,142 @@
# joyful-config.yml - TEMPLATE: bare passthrough config.
#
# Copy this into a new <game>/ folder and customize. As-is, every
# physical control is mapped straight through 1:1 (axis->axis,
# button->button), no remapping and no key emulation - i.e. the
# Sol-R1 behaves like a normal, unmodified joystick/gamepad. Start
# here, confirm the game plays sanely with stock behavior, then only
# add `key`-type rules or axis remaps for the specific controls that
# particular game actually needs changed (e.g. a throttle axis a game
# misreads as a trigger - see space-engineers/ for a worked example).
#
# UNVERIFIED: joyful's exact per-rule-type field names below were not
# checked against `examples/ruletypes.yml` in the installed version -
# check that file before trusting this.
#
# Every TODO_* is a placeholder. Run find_codes.py (repo root) against
# the actual hardware and replace every one before use.
devices:
base:
match: "TODO: Sol-R Base" # evdev device name substring, see find_codes.py --list
grip:
match: "TODO: Sol-R Grip"
modes:
default:
devices: [base, grip]
rules:
# --- base: throttle axis ---
- device: base
type: axis
input: TODO_ABS_THROTTLE
output: ABS_Y
# --- grip: main stick + ministick axes ---
- device: grip
type: axis
input: TODO_ABS_STICK_X
output: ABS_X
- device: grip
type: axis
input: TODO_ABS_STICK_Y
output: ABS_Y
- device: grip
type: axis
input: TODO_ABS_MINISTICK_X
output: ABS_Z
- device: grip
type: axis
input: TODO_ABS_MINISTICK_Y
output: ABS_RZ
# --- grip: trigger + orange buttons ---
- device: grip
type: button
input: TODO_BTN_TRIGGER_STAGE1
output: BTN_TRIGGER
- device: grip
type: button
input: TODO_BTN_TRIGGER_STAGE2
output: BTN_THUMB
- device: grip
type: button
input: TODO_BTN_ORANGE_1
output: BTN_THUMB2
- device: grip
type: button
input: TODO_BTN_ORANGE_2
output: BTN_TOP
# --- base: rotary knobs (assumed BTN detent pulses - confirm with
# find_codes.py; if either is an ABS axis instead, use joyful's
# segmented axis-to-button rule type there instead) ---
- device: base
type: button
input: TODO_BTN_KNOB1_CW
output: BTN_TRIGGER_HAPPY1
- device: base
type: button
input: TODO_BTN_KNOB1_CCW
output: BTN_TRIGGER_HAPPY2
- device: base
type: button
input: TODO_BTN_KNOB2_CW
output: BTN_TRIGGER_HAPPY3
- device: base
type: button
input: TODO_BTN_KNOB2_CCW
output: BTN_TRIGGER_HAPPY4
# --- base: 4-button group ---
- device: base
type: button
input: TODO_BTN_GROUP_1
output: BTN_TRIGGER_HAPPY5
- device: base
type: button
input: TODO_BTN_GROUP_2
output: BTN_TRIGGER_HAPPY6
- device: base
type: button
input: TODO_BTN_GROUP_3
output: BTN_TRIGGER_HAPPY7
- device: base
type: button
input: TODO_BTN_GROUP_4
output: BTN_TRIGGER_HAPPY8
# --- base: 4 tilt switches, 2 held-position buttons each (8 codes) ---
- device: base
type: button
input: TODO_BTN_SWITCH1_UP
output: BTN_TRIGGER_HAPPY9
- device: base
type: button
input: TODO_BTN_SWITCH1_DOWN
output: BTN_TRIGGER_HAPPY10
- device: base
type: button
input: TODO_BTN_SWITCH2_UP
output: BTN_TRIGGER_HAPPY11
- device: base
type: button
input: TODO_BTN_SWITCH2_DOWN
output: BTN_TRIGGER_HAPPY12
- device: base
type: button
input: TODO_BTN_SWITCH3_UP
output: BTN_TRIGGER_HAPPY13
- device: base
type: button
input: TODO_BTN_SWITCH3_DOWN
output: BTN_TRIGGER_HAPPY14
- device: base
type: button
input: TODO_BTN_SWITCH4_UP
output: BTN_TRIGGER_HAPPY15
- device: base
type: button
input: TODO_BTN_SWITCH4_DOWN
output: BTN_TRIGGER_HAPPY16

33
template/launch-wrapper.sh Executable file
View File

@ -0,0 +1,33 @@
#!/bin/bash
# launch-wrapper.sh - TEMPLATE Steam launch-options wrapper.
#
# Copy into a new <game>/ folder and customize. Always starts joyful;
# optionally starts a companion input-converter daemon if this game
# needs one (see input_converter_daemon.py and its README section on
# when a daemon is actually required vs. joyful alone being enough).
# Runs the game, cleans up on exit however it happens.
#
# Steam launch options: /path/to/<game>/launch-wrapper.sh %command%
# (per-machine, not synced by Steam - re-paste after cloning on a new
# machine; keeping the repo checked out at the same path everywhere
# keeps the launch-options string itself identical too.)
REPO_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
JOYFUL_CONFIG="$REPO_DIR/joyful-config.yml"
joyful --config "$JOYFUL_CONFIG" &
JOYFUL_PID=$!
# --- daemon: delete this whole block (and DAEMON_PID below) if this
# game doesn't need game-state feedback and joyful alone is enough ---
DAEMON_CONFIG="$REPO_DIR/TODO-daemon-config.yaml"
python3 "$REPO_DIR/input_converter_daemon.py" --config "$DAEMON_CONFIG" &
DAEMON_PID=$!
# ----------------------------------------------------------------------
# clean up even if this script gets killed/terminated, not just on normal exit
trap 'kill "$JOYFUL_PID" "$DAEMON_PID" 2>/dev/null' EXIT
# "$@" is the substituted real launch command (Proton + the game)
"$@"