@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,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Windows ViGEmBus backend
|
|
3
|
+
Virtual controller + KBM injection via vgamepad and pyautogui.
|
|
4
|
+
|
|
5
|
+
All errors are emitted as JSON to stdout so the Node.js orchestrator
|
|
6
|
+
(InputOrchestrator.js) can surface them to the Electron frontend:
|
|
7
|
+
{"type": "error", "message": "...", "code": "..."}
|
|
8
|
+
{"type": "ready", "message": "..."}
|
|
9
|
+
{"type": "log", "message": "..."}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import json
|
|
14
|
+
import gc
|
|
15
|
+
import socket
|
|
16
|
+
import struct
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── JSON protocol helpers ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def _emit(payload: dict):
|
|
24
|
+
"""Write a JSON line to stdout, flushed immediately."""
|
|
25
|
+
print(json.dumps(payload), flush=True)
|
|
26
|
+
|
|
27
|
+
def _log(msg: str):
|
|
28
|
+
_emit({"type": "log", "message": msg})
|
|
29
|
+
|
|
30
|
+
def _error(msg: str, code: str = "VIGEM_ERROR"):
|
|
31
|
+
_emit({"type": "error", "message": msg, "code": code})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Dependency checks — emit structured JSON on failure ──────────────────────
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import vgamepad as vg
|
|
38
|
+
except ImportError:
|
|
39
|
+
_error(
|
|
40
|
+
"vgamepad not installed. Install with: pip install vgamepad "
|
|
41
|
+
"ViGEmBus driver also required: https://github.com/nefarius/ViGEmBus/releases",
|
|
42
|
+
"VIGEMBUS_MISSING"
|
|
43
|
+
)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
# vgamepad imports successfully but ViGEmBus service is not running/installed
|
|
47
|
+
_error(
|
|
48
|
+
f"vgamepad loaded but ViGEmBus driver error: {e} "
|
|
49
|
+
"Install ViGEmBus: https://github.com/nefarius/ViGEmBus/releases",
|
|
50
|
+
"VIGEMBUS_MISSING"
|
|
51
|
+
)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import pyautogui
|
|
56
|
+
pyautogui.FAILSAFE = False
|
|
57
|
+
pyautogui.PAUSE = 0 # Eliminate artificial delays during KBM injection
|
|
58
|
+
KBM_ENABLED = True
|
|
59
|
+
_log("pyautogui loaded — KBM passthrough enabled")
|
|
60
|
+
except ImportError:
|
|
61
|
+
_log("WARNING: pyautogui not installed — KBM passthrough disabled. Install: pip install pyautogui")
|
|
62
|
+
KBM_ENABLED = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Key map: KEY_* tokens → pyautogui key names ──────────────────────────────
|
|
66
|
+
|
|
67
|
+
PYAUTOGUI_KEY_MAP = {
|
|
68
|
+
"KEY_A": "a", "KEY_B": "b", "KEY_C": "c", "KEY_D": "d",
|
|
69
|
+
"KEY_E": "e", "KEY_F": "f", "KEY_G": "g", "KEY_H": "h",
|
|
70
|
+
"KEY_I": "i", "KEY_J": "j", "KEY_K": "k", "KEY_L": "l",
|
|
71
|
+
"KEY_M": "m", "KEY_N": "n", "KEY_O": "o", "KEY_P": "p",
|
|
72
|
+
"KEY_Q": "q", "KEY_R": "r", "KEY_S": "s", "KEY_T": "t",
|
|
73
|
+
"KEY_U": "u", "KEY_V": "v", "KEY_W": "w", "KEY_X": "x",
|
|
74
|
+
"KEY_Y": "y", "KEY_Z": "z",
|
|
75
|
+
"KEY_0": "0", "KEY_1": "1", "KEY_2": "2", "KEY_3": "3",
|
|
76
|
+
"KEY_4": "4", "KEY_5": "5", "KEY_6": "6", "KEY_7": "7",
|
|
77
|
+
"KEY_8": "8", "KEY_9": "9",
|
|
78
|
+
"KEY_UP": "up", "KEY_DOWN": "down", "KEY_LEFT": "left", "KEY_RIGHT": "right",
|
|
79
|
+
"KEY_SPACE": "space", "KEY_ENTER": "enter", "KEY_ESC": "escape",
|
|
80
|
+
"KEY_LEFTSHIFT": "shift", "KEY_RIGHTSHIFT": "shift",
|
|
81
|
+
"KEY_LEFTCTRL": "ctrl", "KEY_RIGHTCTRL": "ctrl",
|
|
82
|
+
"KEY_LEFTALT": "alt", "KEY_RIGHTALT": "alt",
|
|
83
|
+
"KEY_TAB": "tab", "KEY_BACKSPACE": "backspace",
|
|
84
|
+
"KEY_CAPSLOCK": "capslock",
|
|
85
|
+
"KEY_F1": "f1", "KEY_F2": "f2", "KEY_F3": "f3", "KEY_F4": "f4",
|
|
86
|
+
"KEY_F5": "f5", "KEY_F6": "f6", "KEY_F7": "f7", "KEY_F8": "f8",
|
|
87
|
+
"KEY_F9": "f9", "KEY_F10": "f10", "KEY_F11": "f11", "KEY_F12": "f12",
|
|
88
|
+
"BTN_LEFT": "left",
|
|
89
|
+
"BTN_MIDDLE": "middle",
|
|
90
|
+
"BTN_RIGHT": "right",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# ── Module state ──────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
# devices: pad_id → VX360Gamepad instance
|
|
96
|
+
devices = {}
|
|
97
|
+
# viewer_modes: viewer_id → 'gamepad' | 'kbm' | 'hybrid' | 'kbm_emulated'
|
|
98
|
+
viewer_modes = {}
|
|
99
|
+
devices_by_slot = {}
|
|
100
|
+
event_queue = queue.Queue()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── Axis conversion helpers ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
106
|
+
"""Clamp a float to [lo, hi]."""
|
|
107
|
+
return max(lo, min(hi, value))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _axis_to_float(raw) -> float:
|
|
111
|
+
"""
|
|
112
|
+
Convert the raw axis value from Node.js payload to vgamepad float range [-1.0, +1.0].
|
|
113
|
+
|
|
114
|
+
Node sends axes as integers in the range -32767..+32767 (matching the
|
|
115
|
+
W3C Gamepad API specification). vgamepad expects -1.0..+1.0.
|
|
116
|
+
|
|
117
|
+
Division by 32767.0 (not 32768) avoids overflow at the negative extreme:
|
|
118
|
+
-32767 / 32767.0 = -1.0 exactly
|
|
119
|
+
+32767 / 32767.0 = +1.0 exactly
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
return _clamp(float(raw) / 32767.0, -1.0, 1.0)
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return 0.0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _trigger_to_float(raw) -> float:
|
|
128
|
+
"""
|
|
129
|
+
Convert raw trigger value to vgamepad float range [0.0, +1.0].
|
|
130
|
+
|
|
131
|
+
Node sends trigger axes as integers in 0..255.
|
|
132
|
+
vgamepad expects 0.0..1.0.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
return _clamp(float(raw) / 255.0, 0.0, 1.0)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return 0.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Button application helper ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def _apply_btn(gp, btns: list, idx: int, const):
|
|
143
|
+
"""Press or release a vgamepad button based on the W3C buttons array."""
|
|
144
|
+
if idx >= len(btns):
|
|
145
|
+
return
|
|
146
|
+
entry = btns[idx]
|
|
147
|
+
# W3C GamepadButton is { pressed: bool, value: float }
|
|
148
|
+
# Node may send it as a dict or as a bare bool/number depending on the path.
|
|
149
|
+
pressed = False
|
|
150
|
+
if isinstance(entry, dict):
|
|
151
|
+
pressed = bool(entry.get("pressed", False))
|
|
152
|
+
else:
|
|
153
|
+
pressed = bool(entry)
|
|
154
|
+
|
|
155
|
+
if pressed:
|
|
156
|
+
gp.press_button(button=const)
|
|
157
|
+
else:
|
|
158
|
+
gp.release_button(button=const)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── Main run loop ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def stdin_thread():
|
|
164
|
+
stdin_raw = open(sys.stdin.fileno(), 'rb', buffering=0)
|
|
165
|
+
for raw_line in stdin_raw:
|
|
166
|
+
line = raw_line.decode('utf-8', errors='replace').strip()
|
|
167
|
+
if line:
|
|
168
|
+
event_queue.put(('json', line))
|
|
169
|
+
|
|
170
|
+
def udp_thread(sock):
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
data, _ = sock.recvfrom(1024)
|
|
174
|
+
if data and len(data) == 16 and data[0] == 0x01:
|
|
175
|
+
slot = data[15]
|
|
176
|
+
event_queue.put(('binary', slot, data))
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def _emit_gp_binary(slot, payload):
|
|
181
|
+
gp = devices_by_slot.get(slot)
|
|
182
|
+
if not gp: return
|
|
183
|
+
|
|
184
|
+
magic, lx, ly, rx, ry, lt, rt, cppBtns, hx, hy, slot_check = struct.unpack('<BhhhhBBHbbB', payload)
|
|
185
|
+
|
|
186
|
+
def _bit(mask): return bool(cppBtns & mask)
|
|
187
|
+
def _press(const, state):
|
|
188
|
+
if state: gp.press_button(button=const)
|
|
189
|
+
else: gp.release_button(button=const)
|
|
190
|
+
|
|
191
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_A, _bit(1 << 0))
|
|
192
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_B, _bit(1 << 1))
|
|
193
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_X, _bit(1 << 2))
|
|
194
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_Y, _bit(1 << 3))
|
|
195
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER, _bit(1 << 4))
|
|
196
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER, _bit(1 << 5))
|
|
197
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK, _bit(1 << 8))
|
|
198
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_START, _bit(1 << 9))
|
|
199
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB, _bit(1 << 10))
|
|
200
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB, _bit(1 << 11))
|
|
201
|
+
|
|
202
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP, hy == -1)
|
|
203
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN, hy == 1)
|
|
204
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT, hx == -1)
|
|
205
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT, hx == 1)
|
|
206
|
+
|
|
207
|
+
lx_f = _clamp(lx / 32767.0, -1.0, 1.0)
|
|
208
|
+
ly_f = -_clamp(ly / 32767.0, -1.0, 1.0)
|
|
209
|
+
gp.left_joystick_float(x_value_float=lx_f, y_value_float=ly_f)
|
|
210
|
+
|
|
211
|
+
rx_f = _clamp(rx / 32767.0, -1.0, 1.0)
|
|
212
|
+
ry_f = -_clamp(ry / 32767.0, -1.0, 1.0)
|
|
213
|
+
gp.right_joystick_float(x_value_float=rx_f, y_value_float=ry_f)
|
|
214
|
+
|
|
215
|
+
lt_f = _clamp(lt / 255.0, 0.0, 1.0)
|
|
216
|
+
rt_f = _clamp(rt / 255.0, 0.0, 1.0)
|
|
217
|
+
gp.left_trigger_float(value_float=lt_f)
|
|
218
|
+
gp.right_trigger_float(value_float=rt_f)
|
|
219
|
+
|
|
220
|
+
gp.update()
|
|
221
|
+
|
|
222
|
+
def run():
|
|
223
|
+
_emit({"type": "ready", "message": "Windows vgamepad + pyautogui backend initialized"})
|
|
224
|
+
|
|
225
|
+
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
226
|
+
udp_sock.bind(('127.0.0.1', 0))
|
|
227
|
+
udp_port = udp_sock.getsockname()[1]
|
|
228
|
+
_emit({"type": "udp_ready", "udp_port": udp_port})
|
|
229
|
+
|
|
230
|
+
threading.Thread(target=stdin_thread, daemon=True).start()
|
|
231
|
+
threading.Thread(target=udp_thread, args=(udp_sock,), daemon=True).start()
|
|
232
|
+
|
|
233
|
+
while True:
|
|
234
|
+
ev = event_queue.get()
|
|
235
|
+
if ev[0] == 'binary':
|
|
236
|
+
_emit_gp_binary(ev[1], ev[2])
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
line = ev[1]
|
|
240
|
+
try:
|
|
241
|
+
msg = json.loads(line)
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
_process(msg)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
_error(f"Unexpected error processing message: {e}", "PROCESS_ERROR")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _process(msg: dict):
|
|
252
|
+
msg_type = msg.get("type", "")
|
|
253
|
+
vid = str(msg.get("viewer_id", msg.get("viewerId", "")))
|
|
254
|
+
|
|
255
|
+
# ── Mode updates ──────────────────────────────────────────────────────────
|
|
256
|
+
if msg_type == "set-input-mode":
|
|
257
|
+
viewer_modes[str(msg.get("viewerId", vid))] = msg.get("mode", "gamepad")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if msg_type == "allocate_slot":
|
|
261
|
+
pad_id = str(msg.get("pad_id", ""))
|
|
262
|
+
slot = msg.get("slot")
|
|
263
|
+
if pad_id not in devices:
|
|
264
|
+
# We don't have the gamepad instance yet, _handle_gamepad will lazy-create it.
|
|
265
|
+
# We can lazy-create it right here instead.
|
|
266
|
+
_handle_gamepad({"pad_id": pad_id})
|
|
267
|
+
|
|
268
|
+
if pad_id in devices:
|
|
269
|
+
devices_by_slot[slot] = devices[pad_id]
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
if msg_type == "free_slot":
|
|
273
|
+
slot = msg.get("slot")
|
|
274
|
+
if slot in devices_by_slot:
|
|
275
|
+
del devices_by_slot[slot]
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# ── Viewer cleanup ────────────────────────────────────────────────────────
|
|
279
|
+
if msg_type in ("flush_neutral", "disconnect_viewer", "destroy_all"):
|
|
280
|
+
# Find all pad slots belonging to this viewer (pad IDs are "viewerId_N")
|
|
281
|
+
keys_to_remove = [
|
|
282
|
+
k for k in list(devices.keys())
|
|
283
|
+
if str(k).startswith(vid + "_") or str(k) == vid
|
|
284
|
+
]
|
|
285
|
+
for k in keys_to_remove:
|
|
286
|
+
gp = devices.pop(k, None)
|
|
287
|
+
if gp is not None:
|
|
288
|
+
try:
|
|
289
|
+
# Zero out the controller state before destroying
|
|
290
|
+
gp.left_joystick(x_value=0, y_value=0)
|
|
291
|
+
gp.right_joystick(x_value=0, y_value=0)
|
|
292
|
+
gp.left_trigger(value=0)
|
|
293
|
+
gp.right_trigger(value=0)
|
|
294
|
+
gp.update()
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
del gp
|
|
298
|
+
if msg_type == "destroy_all":
|
|
299
|
+
devices.clear()
|
|
300
|
+
gc.collect()
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# ── Current mode for this viewer ─────────────────────────────────────────
|
|
304
|
+
current_mode = viewer_modes.get(vid, "gamepad")
|
|
305
|
+
|
|
306
|
+
# ── KBM handling ─────────────────────────────────────────────────────────
|
|
307
|
+
if msg_type in ("kbm", "keyboard"):
|
|
308
|
+
if not KBM_ENABLED:
|
|
309
|
+
return
|
|
310
|
+
if current_mode not in ("kbm", "hybrid", "kbm_emulated"):
|
|
311
|
+
return
|
|
312
|
+
_handle_kbm(msg)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# ── Gamepad handling ─────────────────────────────────────────────────────
|
|
316
|
+
if msg_type == "gamepad":
|
|
317
|
+
if current_mode not in ("gamepad", "hybrid"):
|
|
318
|
+
return
|
|
319
|
+
_handle_gamepad(msg)
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _handle_kbm(msg: dict):
|
|
324
|
+
"""Inject keyboard/mouse events via pyautogui."""
|
|
325
|
+
event_type = msg.get("event", "")
|
|
326
|
+
|
|
327
|
+
if event_type == "mousemove":
|
|
328
|
+
dx = msg.get("dx", 0)
|
|
329
|
+
dy = msg.get("dy", 0)
|
|
330
|
+
if dx != 0 or dy != 0:
|
|
331
|
+
try:
|
|
332
|
+
pyautogui.move(int(dx), int(dy))
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if event_type in ("keydown", "keyup"):
|
|
338
|
+
key_name = msg.get("key", "")
|
|
339
|
+
py_key = PYAUTOGUI_KEY_MAP.get(key_name)
|
|
340
|
+
if py_key is None:
|
|
341
|
+
# Fall back: strip KEY_ prefix and lower-case
|
|
342
|
+
py_key = key_name.lower().replace("key_", "")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
is_mouse = "btn_" in key_name.lower()
|
|
346
|
+
if is_mouse:
|
|
347
|
+
if event_type == "keydown":
|
|
348
|
+
pyautogui.mouseDown(button=py_key)
|
|
349
|
+
else:
|
|
350
|
+
pyautogui.mouseUp(button=py_key)
|
|
351
|
+
else:
|
|
352
|
+
if event_type == "keydown":
|
|
353
|
+
pyautogui.keyDown(py_key)
|
|
354
|
+
else:
|
|
355
|
+
pyautogui.keyUp(py_key)
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _handle_gamepad(msg: dict):
|
|
361
|
+
"""Inject gamepad state via vgamepad VX360Gamepad."""
|
|
362
|
+
# pad_id is set by InputOrchestrator: 'viewerId_0', 'viewerId_1', etc.
|
|
363
|
+
pad_id = str(msg.get("pad_id", msg.get("viewer_id", "default")))
|
|
364
|
+
|
|
365
|
+
# Lazy-create the virtual gamepad for this slot
|
|
366
|
+
if pad_id not in devices:
|
|
367
|
+
try:
|
|
368
|
+
gp = vg.VX360Gamepad()
|
|
369
|
+
|
|
370
|
+
# Capture pad_id in a closure — vgamepad 0.1.0's register_notification()
|
|
371
|
+
# does not accept a user_data kwarg, but still passes user_data=None to
|
|
372
|
+
# the callback, so we ignore the callback's user_data and use our own.
|
|
373
|
+
_captured_pad_id = pad_id
|
|
374
|
+
def _on_vibration(client, target, large_motor, small_motor, led_number, user_data):
|
|
375
|
+
vid = str(_captured_pad_id).split('_')[0] if "_" in str(_captured_pad_id) else str(_captured_pad_id)
|
|
376
|
+
_emit({
|
|
377
|
+
"type": "rumble",
|
|
378
|
+
"viewerId": vid,
|
|
379
|
+
"strong": large_motor / 255.0,
|
|
380
|
+
"weak": small_motor / 255.0,
|
|
381
|
+
"duration": 250
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
gp.register_notification(callback_function=_on_vibration)
|
|
385
|
+
|
|
386
|
+
gp.update() # Send an initial neutral state to register with ViGEmBus
|
|
387
|
+
devices[pad_id] = gp
|
|
388
|
+
_log(f"Created virtual gamepad for slot: {pad_id}")
|
|
389
|
+
except Exception as e:
|
|
390
|
+
_error(
|
|
391
|
+
f"Failed to create VX360Gamepad for {pad_id}: {e} "
|
|
392
|
+
"Ensure ViGEmBus is installed: https://github.com/nefarius/ViGEmBus/releases",
|
|
393
|
+
"VIGEMBUS_CREATE_FAILED"
|
|
394
|
+
)
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
gp = devices[pad_id]
|
|
398
|
+
|
|
399
|
+
# Node sends:
|
|
400
|
+
# buttons: integer bitmask (from InputOrchestrator's _gpBuf)
|
|
401
|
+
# axes: [lx, ly, rx, ry, lt, rt] where lx/ly/rx/ry are -32767..+32767
|
|
402
|
+
# and lt/rt are 0..255
|
|
403
|
+
# OR (legacy W3C array path):
|
|
404
|
+
# buttons: array of { pressed, value }
|
|
405
|
+
# axes: array of raw floats
|
|
406
|
+
|
|
407
|
+
btns_raw = msg.get("buttons", 0)
|
|
408
|
+
axes_raw = msg.get("axes", [])
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
if isinstance(btns_raw, int) or "lx" in msg:
|
|
412
|
+
# Bitmask or flat schema path — from InputOrchestrator's binary protocol
|
|
413
|
+
_apply_bitmask(gp, btns_raw, axes_raw, msg)
|
|
414
|
+
else:
|
|
415
|
+
# Legacy W3C array path — direct from viewer.js (pre-orchestrator)
|
|
416
|
+
_apply_w3c_array(gp, btns_raw, axes_raw)
|
|
417
|
+
|
|
418
|
+
gp.update()
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
_error(f"Error updating gamepad {pad_id}: {e}", "GAMEPAD_UPDATE_ERROR")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _apply_bitmask(gp, buttons: int, axes: list, msg: dict = None):
|
|
425
|
+
"""
|
|
426
|
+
Apply gamepad state from InputOrchestrator's compact bitmask + axes format.
|
|
427
|
+
|
|
428
|
+
Bitmask layout (matches InputOrchestrator KBM_BTN_MAP):
|
|
429
|
+
bit 0 (0x0001): A
|
|
430
|
+
bit 1 (0x0002): B
|
|
431
|
+
bit 2 (0x0004): X
|
|
432
|
+
bit 3 (0x0008): Y
|
|
433
|
+
bit 4 (0x0010): D-Up
|
|
434
|
+
bit 5 (0x0020): D-Down
|
|
435
|
+
bit 6 (0x0040): D-Left
|
|
436
|
+
bit 7 (0x0080): D-Right
|
|
437
|
+
bit 8 (0x0100): LB
|
|
438
|
+
bit 9 (0x0200): RB
|
|
439
|
+
bit 10 (0x0400): L3
|
|
440
|
+
bit 11 (0x0800): R3
|
|
441
|
+
bit 12 (0x1000): Start
|
|
442
|
+
bit 13 (0x2000): Select/Back
|
|
443
|
+
bit 14 (0x4000): Guide
|
|
444
|
+
"""
|
|
445
|
+
if isinstance(buttons, int):
|
|
446
|
+
def _bit(mask): return bool(buttons & mask)
|
|
447
|
+
def _press(const, state):
|
|
448
|
+
if state: gp.press_button(button=const)
|
|
449
|
+
else: gp.release_button(button=const)
|
|
450
|
+
|
|
451
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_A, _bit(0x0001))
|
|
452
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_B, _bit(0x0002))
|
|
453
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_X, _bit(0x0004))
|
|
454
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_Y, _bit(0x0008))
|
|
455
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP, _bit(0x0010))
|
|
456
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN, _bit(0x0020))
|
|
457
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT, _bit(0x0040))
|
|
458
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT, _bit(0x0080))
|
|
459
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER, _bit(0x0100))
|
|
460
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER, _bit(0x0200))
|
|
461
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB, _bit(0x0400))
|
|
462
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB, _bit(0x0800))
|
|
463
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_START, _bit(0x1000))
|
|
464
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK, _bit(0x2000))
|
|
465
|
+
_press(vg.XUSB_BUTTON.XUSB_GAMEPAD_GUIDE, _bit(0x4000))
|
|
466
|
+
|
|
467
|
+
if msg is not None and "lx" in msg:
|
|
468
|
+
lx = _axis_to_float(msg.get("lx", 0))
|
|
469
|
+
ly = -_axis_to_float(msg.get("ly", 0))
|
|
470
|
+
gp.left_joystick_float(x_value_float=lx, y_value_float=ly)
|
|
471
|
+
|
|
472
|
+
rx = _axis_to_float(msg.get("rx", 0))
|
|
473
|
+
ry = -_axis_to_float(msg.get("ry", 0))
|
|
474
|
+
gp.right_joystick_float(x_value_float=rx, y_value_float=ry)
|
|
475
|
+
|
|
476
|
+
lt = float(msg.get("lt", 0.0))
|
|
477
|
+
rt = float(msg.get("rt", 0.0))
|
|
478
|
+
gp.left_trigger_float(value_float=lt)
|
|
479
|
+
gp.right_trigger_float(value_float=rt)
|
|
480
|
+
else:
|
|
481
|
+
# Fallback to reading from legacy axes array if present
|
|
482
|
+
if len(axes) >= 2:
|
|
483
|
+
lx = _axis_to_float(axes[0])
|
|
484
|
+
ly = -_axis_to_float(axes[1])
|
|
485
|
+
gp.left_joystick_float(x_value_float=lx, y_value_float=ly)
|
|
486
|
+
|
|
487
|
+
if len(axes) >= 4:
|
|
488
|
+
rx = _axis_to_float(axes[2])
|
|
489
|
+
ry = -_axis_to_float(axes[3])
|
|
490
|
+
gp.right_joystick_float(x_value_float=rx, y_value_float=ry)
|
|
491
|
+
|
|
492
|
+
if len(axes) >= 6:
|
|
493
|
+
lt = _trigger_to_float(axes[4])
|
|
494
|
+
rt = _trigger_to_float(axes[5])
|
|
495
|
+
gp.left_trigger_float(value_float=lt)
|
|
496
|
+
gp.right_trigger_float(value_float=rt)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _apply_w3c_array(gp, btns: list, axes: list):
|
|
500
|
+
"""
|
|
501
|
+
Apply gamepad state from the legacy W3C Gamepad API array format.
|
|
502
|
+
Used when the packet arrives directly from viewer.js (not via orchestrator bitmask).
|
|
503
|
+
|
|
504
|
+
btns: list of { pressed: bool, value: float } or bare booleans
|
|
505
|
+
axes: list of raw floats -1.0..+1.0 from Gamepad.axes
|
|
506
|
+
"""
|
|
507
|
+
_apply_btn(gp, btns, 0, vg.XUSB_BUTTON.XUSB_GAMEPAD_A)
|
|
508
|
+
_apply_btn(gp, btns, 1, vg.XUSB_BUTTON.XUSB_GAMEPAD_B)
|
|
509
|
+
_apply_btn(gp, btns, 2, vg.XUSB_BUTTON.XUSB_GAMEPAD_X)
|
|
510
|
+
_apply_btn(gp, btns, 3, vg.XUSB_BUTTON.XUSB_GAMEPAD_Y)
|
|
511
|
+
_apply_btn(gp, btns, 4, vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER)
|
|
512
|
+
_apply_btn(gp, btns, 5, vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER)
|
|
513
|
+
_apply_btn(gp, btns, 8, vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK)
|
|
514
|
+
_apply_btn(gp, btns, 9, vg.XUSB_BUTTON.XUSB_GAMEPAD_START)
|
|
515
|
+
_apply_btn(gp, btns, 10, vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB)
|
|
516
|
+
_apply_btn(gp, btns, 11, vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB)
|
|
517
|
+
_apply_btn(gp, btns, 12, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP)
|
|
518
|
+
_apply_btn(gp, btns, 13, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN)
|
|
519
|
+
_apply_btn(gp, btns, 14, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT)
|
|
520
|
+
_apply_btn(gp, btns, 15, vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT)
|
|
521
|
+
_apply_btn(gp, btns, 16, vg.XUSB_BUTTON.XUSB_GAMEPAD_GUIDE)
|
|
522
|
+
|
|
523
|
+
# W3C axes are already in -1.0..+1.0 range; clamp defensively.
|
|
524
|
+
# Negate Y axes: W3C Y+ = down, ViGEm Y+ = up.
|
|
525
|
+
if len(axes) >= 2:
|
|
526
|
+
lx = _clamp(float(axes[0]), -1.0, 1.0)
|
|
527
|
+
ly = -_clamp(float(axes[1]), -1.0, 1.0)
|
|
528
|
+
gp.left_joystick_float(x_value_float=lx, y_value_float=ly)
|
|
529
|
+
|
|
530
|
+
if len(axes) >= 4:
|
|
531
|
+
rx = _clamp(float(axes[2]), -1.0, 1.0)
|
|
532
|
+
ry = -_clamp(float(axes[3]), -1.0, 1.0)
|
|
533
|
+
gp.right_joystick_float(x_value_float=rx, y_value_float=ry)
|
|
534
|
+
|
|
535
|
+
# W3C triggers are axes[4]/axes[5] in 0.0..1.0 (some browsers put them in -1..+1)
|
|
536
|
+
if len(axes) >= 6:
|
|
537
|
+
lt = _clamp((float(axes[4]) + 1.0) / 2.0, 0.0, 1.0)
|
|
538
|
+
rt = _clamp((float(axes[5]) + 1.0) / 2.0, 0.0, 1.0)
|
|
539
|
+
gp.left_trigger_float(value_float=lt)
|
|
540
|
+
gp.right_trigger_float(value_float=rt)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
if __name__ == "__main__":
|
|
544
|
+
run()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<OutputType>Exe</OutputType>
|
|
4
|
+
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
|
5
|
+
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
|
6
|
+
<PlatformTarget>x64</PlatformTarget>
|
|
7
|
+
<Nullable>enable</Nullable>
|
|
8
|
+
<RootNamespace>HmBridge</RootNamespace>
|
|
9
|
+
<AssemblyName>HmBridge</AssemblyName>
|
|
10
|
+
</PropertyGroup>
|
|
11
|
+
<ItemGroup>
|
|
12
|
+
<ProjectReference Include="..\..\hidmaestro\sdk\HIDMaestro.Core\HIDMaestro.Core.csproj" />
|
|
13
|
+
</ItemGroup>
|
|
14
|
+
</Project>
|
|
Binary file
|