From 79c9ed94c42d7cf83abe40935b0d44575d00da1b Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 13 Jul 2026 10:19:27 +0200 Subject: [PATCH] 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. --- .gitignore | 2 + README.md | 41 ++++ find_codes.py | 122 ++++++++++++ space-engineers/README.md | 73 +++++++ space-engineers/joyful-config.yml | 121 ++++++++++++ space-engineers/launch-wrapper.sh | 25 +++ space-engineers/mapping-brief.md | 79 ++++++++ space-engineers/switch_sync-config.yaml | 47 +++++ space-engineers/switch_sync.py | 245 +++++++++++++++++++++++ template/README.md | 33 ++++ template/input_converter_daemon.py | 251 ++++++++++++++++++++++++ template/joyful-config.yml | 142 ++++++++++++++ template/launch-wrapper.sh | 33 ++++ 13 files changed, 1214 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 find_codes.py create mode 100644 space-engineers/README.md create mode 100644 space-engineers/joyful-config.yml create mode 100755 space-engineers/launch-wrapper.sh create mode 100644 space-engineers/mapping-brief.md create mode 100644 space-engineers/switch_sync-config.yaml create mode 100755 space-engineers/switch_sync.py create mode 100644 template/README.md create mode 100755 template/input_converter_daemon.py create mode 100644 template/joyful-config.yml create mode 100755 template/launch-wrapper.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..5813e3d --- /dev/null +++ b/README.md @@ -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`. +- `/` - 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 `/` 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. diff --git a/find_codes.py b/find_codes.py new file mode 100755 index 0000000..64ffd16 --- /dev/null +++ b/find_codes.py @@ -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() diff --git a/space-engineers/README.md b/space-engineers/README.md new file mode 100644 index 0000000..08fbcdb --- /dev/null +++ b/space-engineers/README.md @@ -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 "" + ``` + 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. diff --git a/space-engineers/joyful-config.yml b/space-engineers/joyful-config.yml new file mode 100644 index 0000000..e44bc4f --- /dev/null +++ b/space-engineers/joyful-config.yml @@ -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. diff --git a/space-engineers/launch-wrapper.sh b/space-engineers/launch-wrapper.sh new file mode 100755 index 0000000..21418fb --- /dev/null +++ b/space-engineers/launch-wrapper.sh @@ -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) +"$@" diff --git a/space-engineers/mapping-brief.md b/space-engineers/mapping-brief.md new file mode 100644 index 0000000..b8dc45c --- /dev/null +++ b/space-engineers/mapping-brief.md @@ -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//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. diff --git a/space-engineers/switch_sync-config.yaml b/space-engineers/switch_sync-config.yaml new file mode 100644 index 0000000..8bd5417 --- /dev/null +++ b/space-engineers/switch_sync-config.yaml @@ -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 diff --git a/space-engineers/switch_sync.py b/space-engineers/switch_sync.py new file mode 100755 index 0000000..d8d58ef --- /dev/null +++ b/space-engineers/switch_sync.py @@ -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() diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..cb4d000 --- /dev/null +++ b/template/README.md @@ -0,0 +1,33 @@ +# Template - adding a new game + +Copy this whole folder to `..//` 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 > -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//launch-wrapper.sh %command%`. Per-machine, not + synced by Steam - repeat on every machine. diff --git a/template/input_converter_daemon.py b/template/input_converter_daemon.py new file mode 100755 index 0000000..3249329 --- /dev/null +++ b/template/input_converter_daemon.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +input_converter_daemon.py - TEMPLATE stateful input-converter daemon. + +Copy into a new / 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() diff --git a/template/joyful-config.yml b/template/joyful-config.yml new file mode 100644 index 0000000..68e77b1 --- /dev/null +++ b/template/joyful-config.yml @@ -0,0 +1,142 @@ +# joyful-config.yml - TEMPLATE: bare passthrough config. +# +# Copy this into a new / 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 diff --git a/template/launch-wrapper.sh b/template/launch-wrapper.sh new file mode 100755 index 0000000..7a4fcf3 --- /dev/null +++ b/template/launch-wrapper.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# launch-wrapper.sh - TEMPLATE Steam launch-options wrapper. +# +# Copy into a new / 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//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) +"$@"