@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.
- package/README.md +128 -0
- package/config/game_profiles.csv +339 -0
- package/config/kbm_bindings.json +50 -0
- package/index.js +10 -0
- package/lib/InputOrchestrator.js +829 -0
- package/native/binding.gyp +18 -0
- package/native/build/Release/uinputBridge.node +0 -0
- package/native/uinputBridge.cpp +613 -0
- package/package.json +56 -0
- package/python/__init__.py +1 -0
- package/python/linux_uinput.py +697 -0
- package/python/mac_gamepad_bridge.py +532 -0
- package/python/read_gamepads.py +291 -0
- package/python/windows_hidmaestro.py +394 -0
- package/python/windows_vigem.py +544 -0
- package/windows/HmBridge/HmBridge.csproj +14 -0
- package/windows/HmBridge/HmBridge.exe +0 -0
- package/windows/HmBridge/Program.cs +164 -0
|
@@ -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()
|