@nearcade/virtual-gamepad 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ import sys
2
+ import json
3
+ import time
4
+ import platform
5
+ import threading
6
+ import os
7
+
8
+ def eprint(*args, **kwargs):
9
+ print(*args, file=sys.stderr, **kwargs)
10
+
11
+ os_type = platform.system()
12
+
13
+ def emit_state(pad_index, state):
14
+ print(json.dumps({"type": "gamepad_state", "index": pad_index, "state": state}), flush=True)
15
+
16
+ def emit_connected(pad_index, name, id_str):
17
+ print(json.dumps({"type": "gamepad_connected", "index": pad_index, "name": name, "id": id_str}), flush=True)
18
+
19
+ def emit_disconnected(pad_index):
20
+ print(json.dumps({"type": "gamepad_disconnected", "index": pad_index}), flush=True)
21
+
22
+ if os_type == "Windows":
23
+ import ctypes
24
+ from ctypes import wintypes
25
+
26
+ class XINPUT_GAMEPAD(ctypes.Structure):
27
+ _fields_ = [
28
+ ("wButtons", wintypes.WORD),
29
+ ("bLeftTrigger", wintypes.BYTE),
30
+ ("bRightTrigger", wintypes.BYTE),
31
+ ("sThumbLX", wintypes.SHORT),
32
+ ("sThumbLY", wintypes.SHORT),
33
+ ("sThumbRX", wintypes.SHORT),
34
+ ("sThumbRY", wintypes.SHORT),
35
+ ]
36
+
37
+ class XINPUT_STATE(ctypes.Structure):
38
+ _fields_ = [
39
+ ("dwPacketNumber", wintypes.DWORD),
40
+ ("Gamepad", XINPUT_GAMEPAD),
41
+ ]
42
+
43
+ class XINPUT_VIBRATION(ctypes.Structure):
44
+ _fields_ = [
45
+ ("wLeftMotorSpeed", wintypes.WORD),
46
+ ("wRightMotorSpeed", wintypes.WORD),
47
+ ]
48
+
49
+ xinput = None
50
+ for dll in ("xinput1_4.dll", "xinput1_3.dll", "xinput9_1_0.dll"):
51
+ try:
52
+ xinput = ctypes.windll.LoadLibrary(dll)
53
+ break
54
+ except Exception:
55
+ pass
56
+
57
+ if not xinput:
58
+ eprint("No XInput found")
59
+ sys.exit(1)
60
+
61
+ XInputGetState = xinput.XInputGetState
62
+ XInputGetState.argtypes = [wintypes.DWORD, ctypes.POINTER(XINPUT_STATE)]
63
+ XInputGetState.restype = wintypes.DWORD
64
+
65
+ try:
66
+ XInputSetState = xinput.XInputSetState
67
+ XInputSetState.argtypes = [wintypes.DWORD, ctypes.POINTER(XINPUT_VIBRATION)]
68
+ XInputSetState.restype = wintypes.DWORD
69
+ except AttributeError:
70
+ XInputSetState = None
71
+
72
+ def windows_loop():
73
+ last_states = {}
74
+ while True:
75
+ for i in range(4):
76
+ state = XINPUT_STATE()
77
+ res = XInputGetState(i, ctypes.byref(state))
78
+ if res == 0:
79
+ gp = state.Gamepad
80
+ btns = [
81
+ (gp.wButtons & 0x1000) != 0, # A
82
+ (gp.wButtons & 0x2000) != 0, # B
83
+ (gp.wButtons & 0x4000) != 0, # X
84
+ (gp.wButtons & 0x8000) != 0, # Y
85
+ (gp.wButtons & 0x0100) != 0, # LB
86
+ (gp.wButtons & 0x0200) != 0, # RB
87
+ gp.bLeftTrigger > 0, # LT
88
+ gp.bRightTrigger > 0, # RT
89
+ (gp.wButtons & 0x0020) != 0, # Back
90
+ (gp.wButtons & 0x0010) != 0, # Start
91
+ (gp.wButtons & 0x0040) != 0, # L3
92
+ (gp.wButtons & 0x0080) != 0, # R3
93
+ (gp.wButtons & 0x0001) != 0, # DUp
94
+ (gp.wButtons & 0x0002) != 0, # DDown
95
+ (gp.wButtons & 0x0004) != 0, # DLeft
96
+ (gp.wButtons & 0x0008) != 0, # DRight
97
+ False
98
+ ]
99
+
100
+ btn_objs = []
101
+ for idx, pressed in enumerate(btns):
102
+ if idx == 6:
103
+ btn_objs.append({"pressed": pressed, "value": gp.bLeftTrigger})
104
+ elif idx == 7:
105
+ btn_objs.append({"pressed": pressed, "value": gp.bRightTrigger})
106
+ else:
107
+ btn_objs.append({"pressed": pressed, "value": 255 if pressed else 0})
108
+
109
+ lx = max(-32767, gp.sThumbLX)
110
+ ly = max(-32767, -gp.sThumbLY) if gp.sThumbLY != -32768 else 32767
111
+ rx = max(-32767, gp.sThumbRX)
112
+ ry = max(-32767, -gp.sThumbRY) if gp.sThumbRY != -32768 else 32767
113
+
114
+ new_s = {"axes": [lx, ly, rx, ry], "buttons": btn_objs}
115
+
116
+ if i not in last_states:
117
+ emit_connected(i, "XInput Controller", f"xinput_{i}")
118
+
119
+ str_s = json.dumps(new_s)
120
+ if last_states.get(i) != str_s:
121
+ last_states[i] = str_s
122
+ emit_state(i, new_s)
123
+ else:
124
+ if i in last_states:
125
+ del last_states[i]
126
+ emit_disconnected(i)
127
+ time.sleep(0.008)
128
+
129
+ threading.Thread(target=windows_loop, daemon=True).start()
130
+
131
+ elif os_type == "Linux":
132
+ try:
133
+ import evdev
134
+ except ImportError:
135
+ eprint("evdev not installed")
136
+ sys.exit(1)
137
+
138
+ devices = {}
139
+
140
+ def linux_loop():
141
+ while True:
142
+ for path in evdev.list_devices():
143
+ if path not in devices:
144
+ try:
145
+ dev = evdev.InputDevice(path)
146
+ caps = dev.capabilities()
147
+ if evdev.ecodes.EV_ABS in caps and evdev.ecodes.EV_KEY in caps:
148
+ has_btn = any(btn in caps[evdev.ecodes.EV_KEY] for btn in [
149
+ getattr(evdev.ecodes, 'BTN_GAMEPAD', 315),
150
+ getattr(evdev.ecodes, 'BTN_SOUTH', 304),
151
+ getattr(evdev.ecodes, 'BTN_A', 304),
152
+ getattr(evdev.ecodes, 'BTN_1', 288),
153
+ getattr(evdev.ecodes, 'BTN_TRIGGER', 288)
154
+ ])
155
+ if has_btn:
156
+ devices[path] = dev
157
+ idx = len(devices) - 1
158
+ dev.my_idx = idx
159
+ emit_connected(idx, dev.name, f"evdev_{path.replace('/', '_')}")
160
+
161
+ def read_dev(d, i):
162
+ state = {"axes": [0,0,0,0], "buttons": [{"pressed": False, "value": 0} for _ in range(17)]}
163
+ btn_map = {
164
+ getattr(evdev.ecodes, 'BTN_SOUTH', 304): 0, getattr(evdev.ecodes, 'BTN_A', 304): 0, getattr(evdev.ecodes, 'BTN_1', 288): 0,
165
+ getattr(evdev.ecodes, 'BTN_EAST', 305): 1, getattr(evdev.ecodes, 'BTN_B', 305): 1, getattr(evdev.ecodes, 'BTN_2', 289): 1,
166
+ getattr(evdev.ecodes, 'BTN_NORTH', 307): 3, getattr(evdev.ecodes, 'BTN_X', 307): 3, getattr(evdev.ecodes, 'BTN_4', 291): 3,
167
+ getattr(evdev.ecodes, 'BTN_WEST', 306): 2, getattr(evdev.ecodes, 'BTN_Y', 306): 2, getattr(evdev.ecodes, 'BTN_3', 290): 2,
168
+ evdev.ecodes.BTN_TL: 4, evdev.ecodes.BTN_TR: 5,
169
+ evdev.ecodes.BTN_TL2: 6, evdev.ecodes.BTN_TR2: 7,
170
+ evdev.ecodes.BTN_SELECT: 8, evdev.ecodes.BTN_START: 9,
171
+ evdev.ecodes.BTN_THUMBL: 10, evdev.ecodes.BTN_THUMBR: 11,
172
+ evdev.ecodes.BTN_DPAD_UP: 12, evdev.ecodes.BTN_DPAD_DOWN: 13,
173
+ evdev.ecodes.BTN_DPAD_LEFT: 14, evdev.ecodes.BTN_DPAD_RIGHT: 15,
174
+ evdev.ecodes.BTN_MODE: 16,
175
+ }
176
+
177
+ axis_map = {}
178
+ absinfo = d.capabilities().get(evdev.ecodes.EV_ABS, [])
179
+ for code, info in absinfo:
180
+ axis_map[code] = {"min": info.min, "max": info.max}
181
+
182
+ def normalize_axis(val, amin, amax):
183
+ if amax == amin: return 0
184
+ v = ((val - amin) / (amax - amin)) * 2.0 - 1.0
185
+ return int(v * 32767)
186
+
187
+ try:
188
+ for event in d.read_loop():
189
+ changed = False
190
+ if event.type == evdev.ecodes.EV_KEY:
191
+ if event.code in btn_map:
192
+ b_idx = btn_map[event.code]
193
+ state["buttons"][b_idx] = {"pressed": event.value > 0, "value": 255 if event.value > 0 else 0}
194
+ changed = True
195
+ elif event.type == evdev.ecodes.EV_ABS:
196
+ info = axis_map.get(event.code)
197
+ if not info: continue
198
+ if event.code == evdev.ecodes.ABS_X:
199
+ state["axes"][0] = normalize_axis(event.value, info["min"], info["max"])
200
+ changed = True
201
+ elif event.code == evdev.ecodes.ABS_Y:
202
+ state["axes"][1] = normalize_axis(event.value, info["min"], info["max"])
203
+ changed = True
204
+ elif event.code == evdev.ecodes.ABS_RX or event.code == evdev.ecodes.ABS_Z:
205
+ state["axes"][2] = normalize_axis(event.value, info["min"], info["max"])
206
+ changed = True
207
+ elif event.code == evdev.ecodes.ABS_RY or event.code == evdev.ecodes.ABS_RZ:
208
+ state["axes"][3] = normalize_axis(event.value, info["min"], info["max"])
209
+ changed = True
210
+ elif event.code == evdev.ecodes.ABS_HAT0X:
211
+ state["buttons"][14]["pressed"] = event.value < 0; state["buttons"][14]["value"] = 255 if event.value < 0 else 0
212
+ state["buttons"][15]["pressed"] = event.value > 0; state["buttons"][15]["value"] = 255 if event.value > 0 else 0
213
+ changed = True
214
+ elif event.code == evdev.ecodes.ABS_HAT0Y:
215
+ state["buttons"][12]["pressed"] = event.value < 0; state["buttons"][12]["value"] = 255 if event.value < 0 else 0
216
+ state["buttons"][13]["pressed"] = event.value > 0; state["buttons"][13]["value"] = 255 if event.value > 0 else 0
217
+ changed = True
218
+ if changed:
219
+ emit_state(i, state)
220
+ except Exception:
221
+ emit_disconnected(i)
222
+ if path in devices: del devices[path]
223
+
224
+ threading.Thread(target=read_dev, args=(dev, idx), daemon=True).start()
225
+ except Exception:
226
+ pass
227
+ time.sleep(2)
228
+
229
+ threading.Thread(target=linux_loop, daemon=True).start()
230
+ else:
231
+ eprint("Native gamepads not supported on this OS via Python")
232
+
233
+ def stdin_loop():
234
+ for line in sys.stdin:
235
+ if not line.strip(): continue
236
+ try:
237
+ msg = json.loads(line)
238
+ if msg.get("type") == "rumble":
239
+ idx = msg.get("padIndex", 0)
240
+ strong = msg.get("strong", 0.0)
241
+ weak = msg.get("weak", 0.0)
242
+ duration = msg.get("duration", 200)
243
+
244
+ if os_type == "Windows" and xinput and XInputSetState:
245
+ vib = XINPUT_VIBRATION()
246
+ vib.wLeftMotorSpeed = int(strong * 65535)
247
+ vib.wRightMotorSpeed = int(weak * 65535)
248
+ XInputSetState(idx, ctypes.byref(vib))
249
+ def stop_vib():
250
+ time.sleep(duration / 1000.0)
251
+ vib_stop = XINPUT_VIBRATION(0, 0)
252
+ XInputSetState(idx, ctypes.byref(vib_stop))
253
+ threading.Thread(target=stop_vib, daemon=True).start()
254
+
255
+ elif os_type == "Linux":
256
+ dev_to_rumble = None
257
+ for path, dev in devices.items():
258
+ if getattr(dev, 'my_idx', -1) == idx:
259
+ dev_to_rumble = dev
260
+ break
261
+ if dev_to_rumble and evdev.ecodes.EV_FF in dev_to_rumble.capabilities():
262
+ try:
263
+ # evdev rumble
264
+ r = evdev.ff.Rumble(strong_magnitude=int(strong * 65535), weak_magnitude=int(weak * 65535))
265
+ effect_type = evdev.ff.EffectType(ff_rumble_effect=r)
266
+ effect = evdev.ff.Effect(
267
+ evdev.ecodes.FF_RUMBLE, -1, 0,
268
+ evdev.ff.Trigger(0, 0),
269
+ evdev.ff.Replay(int(duration), 0),
270
+ effect_type
271
+ )
272
+ eid = dev_to_rumble.upload_effect(effect)
273
+ dev_to_rumble.write(evdev.ecodes.EV_FF, eid, 1)
274
+
275
+ # Erase effect after it finishes to prevent slot leak
276
+ def erase_effect(dev, effect_id, delay_ms):
277
+ time.sleep((delay_ms + 50) / 1000.0)
278
+ try:
279
+ dev.erase_effect(effect_id)
280
+ except Exception:
281
+ pass
282
+ threading.Thread(target=erase_effect, args=(dev_to_rumble, eid, duration), daemon=True).start()
283
+
284
+ except Exception as e:
285
+ eprint("Linux rumble failed:", e)
286
+ except Exception as e:
287
+ pass
288
+
289
+ # Start stdin loop on main thread to keep script alive and responsive
290
+ stdin_loop()
291
+
@@ -0,0 +1,394 @@
1
+ """
2
+ Windows HIDMaestro backend
3
+ Virtual controller via HIDMaestro SDK (.NET bridge) + KBM injection via pyautogui.
4
+
5
+ Emits JSON to stdout for InputOrchestrator.js:
6
+ {"type": "error", "message": "...", "code": "..."}
7
+ {"type": "ready", "message": "..."}
8
+ {"type": "log", "message": "..."}
9
+ {"type": "rumble", "pad_id": "...", "strong": 0.5, "weak": 0.3, "duration": 250}
10
+ """
11
+
12
+ import sys
13
+ import json
14
+ import gc
15
+ import os
16
+ import subprocess
17
+ import threading
18
+ import queue
19
+
20
+
21
+ # ── JSON protocol helpers ─────────────────────────────────────────────
22
+
23
+ def _emit(payload: dict):
24
+ print(json.dumps(payload), flush=True)
25
+
26
+ def _log(msg: str):
27
+ _emit({"type": "log", "message": msg})
28
+
29
+ def _error(msg: str, code: str = "HIDMAESTRO_ERROR"):
30
+ _emit({"type": "error", "message": msg, "code": code})
31
+
32
+
33
+ # ── PyAutoGUI for KBM ─────────────────────────────────────────────────
34
+
35
+ try:
36
+ import pyautogui
37
+ pyautogui.FAILSAFE = False
38
+ pyautogui.PAUSE = 0
39
+ KBM_ENABLED = True
40
+ _log("pyautogui loaded — KBM passthrough enabled")
41
+ except ImportError:
42
+ _log("WARNING: pyautogui not installed — KBM passthrough disabled")
43
+ KBM_ENABLED = False
44
+
45
+ PYAUTOGUI_KEY_MAP = {
46
+ "KEY_A": "a", "KEY_B": "b", "KEY_C": "c", "KEY_D": "d",
47
+ "KEY_E": "e", "KEY_F": "f", "KEY_G": "g", "KEY_H": "h",
48
+ "KEY_I": "i", "KEY_J": "j", "KEY_K": "k", "KEY_L": "l",
49
+ "KEY_M": "m", "KEY_N": "n", "KEY_O": "o", "KEY_P": "p",
50
+ "KEY_Q": "q", "KEY_R": "r", "KEY_S": "s", "KEY_T": "t",
51
+ "KEY_U": "u", "KEY_V": "v", "KEY_W": "w", "KEY_X": "x",
52
+ "KEY_Y": "y", "KEY_Z": "z",
53
+ "KEY_0": "0", "KEY_1": "1", "KEY_2": "2", "KEY_3": "3",
54
+ "KEY_4": "4", "KEY_5": "5", "KEY_6": "6", "KEY_7": "7",
55
+ "KEY_8": "8", "KEY_9": "9",
56
+ "KEY_UP": "up", "KEY_DOWN": "down", "KEY_LEFT": "left", "KEY_RIGHT": "right",
57
+ "KEY_SPACE": "space", "KEY_ENTER": "enter", "KEY_ESC": "escape",
58
+ "KEY_LEFTSHIFT": "shift", "KEY_RIGHTSHIFT": "shift",
59
+ "KEY_LEFTCTRL": "ctrl", "KEY_RIGHTCTRL": "ctrl",
60
+ "KEY_LEFTALT": "alt", "KEY_RIGHTALT": "alt",
61
+ "KEY_TAB": "tab", "KEY_BACKSPACE": "backspace",
62
+ "KEY_CAPSLOCK": "capslock",
63
+ "KEY_F1": "f1", "KEY_F2": "f2", "KEY_F3": "f3", "KEY_F4": "f4",
64
+ "KEY_F5": "f5", "KEY_F6": "f6", "KEY_F7": "f7", "KEY_F8": "f8",
65
+ "KEY_F9": "f9", "KEY_F10": "f10", "KEY_F11": "f11", "KEY_F12": "f12",
66
+ "BTN_LEFT": "left", "BTN_MIDDLE": "middle", "BTN_RIGHT": "right",
67
+ }
68
+
69
+
70
+ # ── Nearcade → HIDMaestro conversion ─────────────────────────────────
71
+
72
+ def _nearcade_to_hm_buttons(btns: int) -> int:
73
+ hm = 0
74
+ if btns & 0x0001: hm |= 1 << 0 # A
75
+ if btns & 0x0002: hm |= 1 << 1 # B
76
+ if btns & 0x0004: hm |= 1 << 2 # X
77
+ if btns & 0x0008: hm |= 1 << 3 # Y
78
+ if btns & 0x0100: hm |= 1 << 4 # LB
79
+ if btns & 0x0200: hm |= 1 << 5 # RB
80
+ if btns & 0x2000: hm |= 1 << 6 # Back/Select
81
+ if btns & 0x1000: hm |= 1 << 7 # Start
82
+ if btns & 0x0400: hm |= 1 << 8 # L3
83
+ if btns & 0x0800: hm |= 1 << 9 # R3
84
+ if btns & 0x4000: hm |= 1 << 10 # Guide
85
+ return hm
86
+
87
+ HM_HAT_MAP = {
88
+ (0, 0, 0, 0): 0, # None
89
+ (1, 0, 0, 0): 1, # North
90
+ (1, 0, 1, 0): 2, # NorthEast
91
+ (0, 0, 1, 0): 3, # East
92
+ (0, 1, 1, 0): 4, # SouthEast
93
+ (0, 1, 0, 0): 5, # South
94
+ (0, 1, 0, 1): 6, # SouthWest
95
+ (0, 0, 0, 1): 7, # West
96
+ (1, 0, 0, 1): 8, # NorthWest
97
+ }
98
+
99
+ def _dpad_to_hmhat(btns: int) -> int:
100
+ return HM_HAT_MAP.get(((btns >> 4) & 1, (btns >> 5) & 1, (btns >> 6) & 1, (btns >> 7) & 1), 0)
101
+
102
+ def _axis_to_hm(val) -> float:
103
+ try:
104
+ f = float(val) / 32767.0
105
+ return max(0.0, min(1.0, (f + 1.0) / 2.0))
106
+ except (TypeError, ValueError):
107
+ return 0.5
108
+
109
+ def _trig_to_hm(val) -> float:
110
+ try:
111
+ return max(0.0, min(1.0, float(val)))
112
+ except (TypeError, ValueError):
113
+ return 0.0
114
+
115
+ HM_PROFILE_MAP = {
116
+ 'xbox360': 'xbox-360-wired',
117
+ 'xbox': 'xbox-360-wired',
118
+ 'xboxone': 'xbox-one-s',
119
+ 'ds4': 'dualshock-4-v1-full',
120
+ 'ps4': 'dualshock-4-v1-full',
121
+ 'playstation':'dualsense',
122
+ 'dualshock4':'dualshock-4-v1-full',
123
+ 'dualsense': 'dualsense',
124
+ 'switchpro': 'switch-pro',
125
+ 'switch': 'switch-pro',
126
+ 'nintendo': 'switch-pro',
127
+ }
128
+
129
+
130
+ # ── HmBridge subprocess management ────────────────────────────────────
131
+
132
+ _hm_bridge: subprocess.Popen = None
133
+ _hm_stdin_lock = threading.Lock()
134
+ _hm_stdout_queue: queue.Queue = queue.Queue()
135
+ _event_queue: queue.Queue = queue.Queue()
136
+ _viewer_modes: dict = {}
137
+ _viewer_pads: dict = {}
138
+
139
+
140
+ def _find_hm_bridge() -> str | None:
141
+ script_dir = os.path.dirname(os.path.abspath(__file__))
142
+ for p in [
143
+ os.path.join(script_dir, 'HmBridge', 'HmBridge.exe'),
144
+ os.path.join(script_dir, 'HmBridge', 'bin', 'Release',
145
+ 'net10.0-windows10.0.26100.0', 'win-x64', 'HmBridge.exe'),
146
+ os.path.join(os.path.dirname(script_dir), 'windows', 'HmBridge', 'HmBridge.exe'),
147
+ ]:
148
+ if os.path.isfile(p):
149
+ return p
150
+ return None
151
+
152
+
153
+ def _hm_write(obj: dict):
154
+ global _hm_bridge
155
+ if _hm_bridge is None or _hm_bridge.stdin is None:
156
+ return
157
+ with _hm_stdin_lock:
158
+ try:
159
+ _hm_bridge.stdin.write(json.dumps(obj) + '\n')
160
+ _hm_bridge.stdin.flush()
161
+ except (BrokenPipeError, OSError):
162
+ pass
163
+
164
+
165
+ def _hm_stdout_reader():
166
+ global _hm_bridge
167
+ if _hm_bridge is None or _hm_bridge.stdout is None:
168
+ return
169
+ for raw in _hm_bridge.stdout:
170
+ line = raw.strip()
171
+ if line:
172
+ try:
173
+ _hm_stdout_queue.put(json.loads(line))
174
+ except json.JSONDecodeError:
175
+ pass
176
+
177
+
178
+ def _hm_stderr_reader():
179
+ global _hm_bridge
180
+ if _hm_bridge is None or _hm_bridge.stderr is None:
181
+ return
182
+ for raw in _hm_bridge.stderr:
183
+ line = raw.strip()
184
+ if line:
185
+ _emit({"type": "log", "message": f"[HmBridge] {line}"})
186
+
187
+
188
+ # ── Stdin reader thread ───────────────────────────────────────────────
189
+
190
+ def _stdin_thread():
191
+ stdin_raw = open(sys.stdin.fileno(), 'rb', buffering=0)
192
+ for raw_line in stdin_raw:
193
+ line = raw_line.decode('utf-8', errors='replace').strip()
194
+ if line:
195
+ _event_queue.put(('json', line))
196
+
197
+
198
+ # ── KBM handler ───────────────────────────────────────────────────────
199
+
200
+ def _handle_kbm(msg: dict):
201
+ if not KBM_ENABLED:
202
+ return
203
+ vid = str(msg.get("viewer_id", msg.get("viewerId", "")))
204
+ mode = _viewer_modes.get(vid, "gamepad")
205
+ if mode not in ("kbm", "hybrid", "kbm_emulated"):
206
+ return
207
+
208
+ event_type = msg.get("event", "")
209
+ if event_type == "mousemove":
210
+ dx = msg.get("dx", 0)
211
+ dy = msg.get("dy", 0)
212
+ if dx != 0 or dy != 0:
213
+ try: pyautogui.move(int(dx), int(dy))
214
+ except Exception: pass
215
+ return
216
+
217
+ if event_type in ("keydown", "keyup"):
218
+ key_name = msg.get("key", "")
219
+ py_key = PYAUTOGUI_KEY_MAP.get(key_name) or key_name.lower().replace("key_", "")
220
+ try:
221
+ is_mouse = "btn_" in key_name.lower()
222
+ if is_mouse:
223
+ if event_type == "keydown": pyautogui.mouseDown(button=py_key)
224
+ else: pyautogui.mouseUp(button=py_key)
225
+ else:
226
+ if event_type == "keydown": pyautogui.keyDown(py_key)
227
+ else: pyautogui.keyUp(py_key)
228
+ except Exception: pass
229
+
230
+
231
+ # ── Message dispatcher ────────────────────────────────────────────────
232
+
233
+ def _process(msg: dict):
234
+ msg_type = msg.get("type", "")
235
+ vid = str(msg.get("viewer_id", msg.get("viewerId", "")))
236
+
237
+ if msg_type == "set-input-mode":
238
+ _viewer_modes[vid] = msg.get("mode", "gamepad")
239
+ return
240
+
241
+ if msg_type == "allocate_slot":
242
+ pad_id = str(msg.get("pad_id", ""))
243
+ profile_key = str(msg.get("profile", "xbox360"))
244
+ hm_profile = HM_PROFILE_MAP.get(profile_key, 'xbox-360-wired')
245
+ _hm_write({"type": "create", "pad_id": pad_id, "profile": hm_profile})
246
+ _viewer_pads.setdefault(vid, set()).add(pad_id)
247
+ return
248
+
249
+ if msg_type == "free_slot":
250
+ pad_id = str(msg.get("pad_id", ""))
251
+ _hm_write({"type": "free", "pad_id": pad_id})
252
+ _viewer_pads.get(vid, set()).discard(pad_id)
253
+ return
254
+
255
+ if msg_type in ("flush_neutral", "disconnect_viewer", "destroy_all"):
256
+ if vid in _viewer_pads:
257
+ for pid in list(_viewer_pads[vid]):
258
+ _hm_write({"type": "free", "pad_id": pid})
259
+ _viewer_pads[vid].clear()
260
+ if msg_type == "destroy_all":
261
+ _hm_write({"type": "destroy_all"})
262
+ gc.collect()
263
+ return
264
+
265
+ if msg_type == "gamepad":
266
+ mode = _viewer_modes.get(vid, "gamepad")
267
+ if mode not in ("gamepad", "hybrid"):
268
+ return
269
+
270
+ pad_id = str(msg.get("pad_id", msg.get("viewer_id", "default")))
271
+ btns_raw = msg.get("buttons", 0)
272
+
273
+ if isinstance(btns_raw, int):
274
+ buttons_val = btns_raw
275
+ elif isinstance(btns_raw, list):
276
+ buttons_val = 0
277
+ masks = [0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0020,
278
+ 0x0040, 0x0080, 0x0100, 0x0200, 0x0400, 0x0800,
279
+ 0x1000, 0x2000, 0x4000]
280
+ for i, b in enumerate(btns_raw):
281
+ if isinstance(b, dict): b = b.get("pressed", False)
282
+ if b and i < len(masks): buttons_val |= masks[i]
283
+ else:
284
+ buttons_val = 0
285
+
286
+ hm_buttons = _nearcade_to_hm_buttons(buttons_val)
287
+ hm_hat = _dpad_to_hmhat(buttons_val)
288
+ axes = msg.get("axes", [])
289
+
290
+ if "lx" in msg:
291
+ lx = _axis_to_hm(msg["lx"]); ly = _axis_to_hm(msg["ly"])
292
+ rx = _axis_to_hm(msg["rx"]); ry = _axis_to_hm(msg["ry"])
293
+ lt = _trig_to_hm(msg.get("lt", 0.0))
294
+ rt = _trig_to_hm(msg.get("rt", 0.0))
295
+ elif len(axes) >= 6:
296
+ lx = _axis_to_hm(axes[0]); ly = _axis_to_hm(axes[1])
297
+ rx = _axis_to_hm(axes[2]); ry = _axis_to_hm(axes[3])
298
+ lt = _trig_to_hm(axes[4]); rt = _trig_to_hm(axes[5])
299
+ elif len(axes) >= 4:
300
+ lx = _axis_to_hm(axes[0]); ly = _axis_to_hm(axes[1])
301
+ rx = _axis_to_hm(axes[2]); ry = _axis_to_hm(axes[3])
302
+ lt = 0.0; rt = 0.0
303
+ else:
304
+ lx = 0.5; ly = 0.5; rx = 0.5; ry = 0.5; lt = 0.0; rt = 0.0
305
+
306
+ _hm_write({
307
+ "type": "state", "pad_id": pad_id,
308
+ "buttons": hm_buttons, "hat": hm_hat,
309
+ "lx": lx, "ly": ly, "rx": rx, "ry": ry, "lt": lt, "rt": rt,
310
+ })
311
+ return
312
+
313
+ if msg_type in ("kbm", "keyboard"):
314
+ _handle_kbm(msg)
315
+ return
316
+
317
+
318
+ # ── Run loop ──────────────────────────────────────────────────────────
319
+
320
+ def run():
321
+ global _hm_bridge
322
+
323
+ bridge_path = _find_hm_bridge()
324
+ if bridge_path is None:
325
+ _error(
326
+ "HmBridge.exe not found. Build it from the HmBridge.csproj in the windows/ directory or download from the package releases.",
327
+ "HM_BRIDGE_NOT_FOUND"
328
+ )
329
+ _emit({"type": "ready", "message": "HmBridge unavailable — KBM-only mode"})
330
+ else:
331
+ try:
332
+ _hm_bridge = subprocess.Popen(
333
+ [bridge_path],
334
+ stdin=subprocess.PIPE,
335
+ stdout=subprocess.PIPE,
336
+ stderr=subprocess.PIPE,
337
+ )
338
+ _hm_write({"type": "init"})
339
+ threading.Thread(target=_hm_stdout_reader, daemon=True).start()
340
+ threading.Thread(target=_hm_stderr_reader, daemon=True).start()
341
+ _log(f"HmBridge spawned: {bridge_path}")
342
+ _emit({"type": "ready", "message": "Windows HIDMaestro backend initialized"})
343
+ except Exception as e:
344
+ _error(f"Failed to spawn HmBridge: {e}", "HM_BRIDGE_SPAWN_FAILED")
345
+ _hm_bridge = None
346
+ _emit({"type": "ready", "message": "HmBridge unavailable — KBM-only mode"})
347
+
348
+ threading.Thread(target=_stdin_thread, daemon=True).start()
349
+
350
+ # Main loop: drain HmBridge output queue, then block on stdin events
351
+ try:
352
+ while True:
353
+ while True:
354
+ try:
355
+ ev = _hm_stdout_queue.get_nowait()
356
+ except queue.Empty:
357
+ break
358
+ ev_type = ev.get("type")
359
+ if ev_type == "rumble":
360
+ pad_id = str(ev.get("pad_id", ""))
361
+ vid = pad_id.split('_')[0] if '_' in pad_id else pad_id
362
+ _emit({
363
+ "type": "rumble",
364
+ "viewerId": vid,
365
+ "strong": ev.get("strong", 0),
366
+ "weak": ev.get("weak", 0),
367
+ "duration": ev.get("duration", 200),
368
+ })
369
+ elif ev_type in ("error", "log", "ready"):
370
+ _emit(ev)
371
+
372
+ ev = _event_queue.get()
373
+ if ev[0] == 'json':
374
+ msg = json.loads(ev[1])
375
+ try:
376
+ _process(msg)
377
+ except Exception as e:
378
+ _error(f"Unexpected error: {e}", "PROCESS_ERROR")
379
+ except (EOFError, KeyboardInterrupt):
380
+ pass
381
+
382
+ if _hm_bridge:
383
+ _hm_write({"type": "destroy_all"})
384
+ try:
385
+ _hm_bridge.stdin.close()
386
+ except Exception: pass
387
+ try:
388
+ _hm_bridge.wait(timeout=5)
389
+ except Exception:
390
+ _hm_bridge.kill()
391
+
392
+
393
+ if __name__ == "__main__":
394
+ run()