@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,532 @@
1
+ """
2
+ macOS KBM-Gamepad Bridge: Translates gamepad input to synthetic keyboard/mouse events
3
+
4
+ Since macOS lacks a stable gamepad injection API (unlike Linux uinput or Windows ViGEmBus),
5
+ this bridge converts incoming gamepad packets to keyboard and mouse events using pynput.
6
+
7
+ This allows Steam Input and other applications to work with remapped gamepad inputs.
8
+
9
+ DEPENDENCIES: pip install pynput
10
+ PERMISSIONS: Requires Accessibility permission (System Settings > Security & Privacy > Accessibility)
11
+
12
+ BUTTON MAPPING:
13
+ A → Space (action/jump)
14
+ B → Escape (menu/back)
15
+ X → R (reload/interact)
16
+ Y → E (equipment/ability)
17
+ LB → Left Mouse Click
18
+ RB → Right Mouse Click
19
+ LT/RT → Shift + mouse buttons for modified actions
20
+
21
+ STICK MAPPING:
22
+ Left Stick → W/A/S/D (movement with deadzone)
23
+ Right Stick → Mouse movement (relative)
24
+ Triggers → Mouse scroll / modifier keys
25
+
26
+ FEATURES:
27
+ [OK] Cross-architecture (Intel & Apple Silicon M1/M2/M3)
28
+ [OK] Deadzone handling (0.1) to prevent stick drift
29
+ [OK] Smooth mouse acceleration
30
+ [OK] Graceful shutdown on stream disconnect
31
+ [OK] Permission warnings if Accessibility not granted
32
+ """
33
+
34
+ import sys
35
+ import json
36
+ import threading
37
+ import time
38
+ import socket
39
+ import struct
40
+ import queue
41
+ from typing import Dict, Set, Tuple, Optional
42
+
43
+ try:
44
+ from pynput.keyboard import Controller as KeyboardController, Key
45
+ from pynput.mouse import Controller as MouseController, Button
46
+ except ImportError:
47
+ print(
48
+ "[gamepad-bridge] ERROR: pynput not installed. Install with: pip install pynput",
49
+ flush=True,
50
+ )
51
+ sys.exit(1)
52
+
53
+ # ── Configuration ────────────────────────────────────────────────────────────
54
+ DEADZONE = 0.1 # Stick values below this are treated as neutral
55
+ MOUSE_SPEED_MULTIPLIER = 3.0 # Right stick to mouse speed
56
+ MOUSE_SENSITIVITY = 2000 # Pixels per second
57
+ UPDATE_RATE = 0.016 # ~60 FPS update loop
58
+
59
+ # ── Button Mapping (W3C Gamepad standard → macOS keycodes) ──────────────────
60
+ BUTTON_MAP = {
61
+ 0: "space", # A button → Space (action/jump)
62
+ 1: "esc", # B button → Escape (menu)
63
+ 2: "r", # X button → R (reload)
64
+ 3: "e", # Y button → E (equipment)
65
+ 4: "shift", # LB (Left Bumper) → Shift (modifier for LMB)
66
+ 5: "cmd", # RB (Right Bumper) → Cmd (modifier for RMB)
67
+ 8: "tab", # Back → Tab (scoreboard)
68
+ 9: "enter", # Start → Enter (confirm)
69
+ 10: "z", # Left Stick Click → Z (crouch/alt action)
70
+ 11: "c", # Right Stick Click → C (emote/action)
71
+ }
72
+
73
+ # ── Mouse Button Mapping ─────────────────────────────────────────────────────
74
+ MOUSE_BUTTON_MAP = {
75
+ "left": Button.left,
76
+ "right": Button.right,
77
+ "middle": Button.middle,
78
+ "scroll_up": "scroll_up",
79
+ "scroll_down": "scroll_down",
80
+ }
81
+
82
+ # ── State Tracking ───────────────────────────────────────────────────────────
83
+ class GamepadState:
84
+ def __init__(self):
85
+ self.buttons_pressed: Set[int] = set() # Currently held button indices
86
+ self.key_held: Dict[str, bool] = {} # Track which keys are actively pressed
87
+ self.mouse_held: Dict[str, bool] = {} # Track mouse buttons
88
+ self.left_stick = (0.0, 0.0) # (x, y) normalized to -1.0..1.0
89
+ self.right_stick = (0.0, 0.0)
90
+ self.left_trigger = 0.0 # 0.0..1.0
91
+ self.right_trigger = 0.0
92
+ self.last_mouse_pos = (0, 0)
93
+ self.last_update_time = time.time()
94
+ self.lock = threading.Lock()
95
+
96
+ # ── Global state ─────────────────────────────────────────────────────────────
97
+ gamepad_state = GamepadState()
98
+ keyboard = KeyboardController()
99
+ mouse = MouseController()
100
+ running = True
101
+ viewer_ids: Set[str] = set() # Track connected viewer IDs
102
+ event_queue = queue.Queue()
103
+
104
+
105
+ def clamp(val: float, min_val: float, max_val: float) -> float:
106
+ """Clamp value to range."""
107
+ return max(min_val, min(max_val, val))
108
+
109
+
110
+ def apply_deadzone(value: float) -> float:
111
+ """Apply deadzone to prevent stick drift."""
112
+ if abs(value) < DEADZONE:
113
+ return 0.0
114
+ return value
115
+
116
+
117
+ def normalize_stick(x: float, y: float) -> Tuple[float, float]:
118
+ """Normalize stick values and apply deadzone."""
119
+ # Clamp to -1..1 range
120
+ x = clamp(x, -1.0, 1.0)
121
+ y = clamp(y, -1.0, 1.0)
122
+ # Apply deadzone
123
+ x = apply_deadzone(x)
124
+ y = apply_deadzone(y)
125
+ return (x, y)
126
+
127
+
128
+ def handle_button_event(button_idx: int, pressed: bool):
129
+ """
130
+ Handle gamepad button press/release and emit corresponding keyboard events.
131
+ """
132
+ with gamepad_state.lock:
133
+ if pressed:
134
+ gamepad_state.buttons_pressed.add(button_idx)
135
+ else:
136
+ gamepad_state.buttons_pressed.discard(button_idx)
137
+
138
+ key_name = BUTTON_MAP.get(button_idx)
139
+ if not key_name:
140
+ return # Unmapped button, ignore
141
+
142
+ try:
143
+ if key_name == "shift":
144
+ if pressed:
145
+ keyboard.press(Key.shift)
146
+ gamepad_state.key_held[key_name] = True
147
+ else:
148
+ keyboard.release(Key.shift)
149
+ gamepad_state.key_held[key_name] = False
150
+
151
+ elif key_name == "cmd":
152
+ if pressed:
153
+ keyboard.press(Key.cmd)
154
+ gamepad_state.key_held[key_name] = True
155
+ else:
156
+ keyboard.release(Key.cmd)
157
+ gamepad_state.key_held[key_name] = False
158
+
159
+ elif key_name == "esc":
160
+ if pressed:
161
+ keyboard.press(Key.esc)
162
+ gamepad_state.key_held[key_name] = True
163
+ else:
164
+ keyboard.release(Key.esc)
165
+ gamepad_state.key_held[key_name] = False
166
+
167
+ elif key_name == "tab":
168
+ if pressed:
169
+ keyboard.press(Key.tab)
170
+ gamepad_state.key_held[key_name] = True
171
+ else:
172
+ keyboard.release(Key.tab)
173
+ gamepad_state.key_held[key_name] = False
174
+
175
+ elif key_name == "enter":
176
+ if pressed:
177
+ keyboard.press(Key.enter)
178
+ gamepad_state.key_held[key_name] = True
179
+ else:
180
+ keyboard.release(Key.enter)
181
+ gamepad_state.key_held[key_name] = False
182
+
183
+ else:
184
+ # Regular letter key
185
+ if pressed:
186
+ keyboard.press(key_name)
187
+ gamepad_state.key_held[key_name] = True
188
+ else:
189
+ keyboard.release(key_name)
190
+ gamepad_state.key_held[key_name] = False
191
+
192
+ except Exception as e:
193
+ print(f"[gamepad-bridge] Error handling button {button_idx}: {e}", flush=True)
194
+
195
+
196
+ def handle_stick_movement(stick_name: str, x: float, y: float):
197
+ """
198
+ Handle left/right stick movement.
199
+ Left stick → W/A/S/D
200
+ Right stick → mouse movement
201
+ """
202
+ x, y = normalize_stick(x, y)
203
+
204
+ if stick_name == "left":
205
+ # Map to WASD movement
206
+ # -Y = up (W), +Y = down (S), -X = left (A), +X = right (D)
207
+ with gamepad_state.lock:
208
+ gamepad_state.left_stick = (x, y)
209
+
210
+ # Handle W/S (up/down)
211
+ if y < -DEADZONE: # Forward
212
+ if "w" not in gamepad_state.key_held or not gamepad_state.key_held["w"]:
213
+ keyboard.press("w")
214
+ gamepad_state.key_held["w"] = True
215
+ else:
216
+ if gamepad_state.key_held.get("w", False):
217
+ keyboard.release("w")
218
+ gamepad_state.key_held["w"] = False
219
+
220
+ if y > DEADZONE: # Backward
221
+ if "s" not in gamepad_state.key_held or not gamepad_state.key_held["s"]:
222
+ keyboard.press("s")
223
+ gamepad_state.key_held["s"] = True
224
+ else:
225
+ if gamepad_state.key_held.get("s", False):
226
+ keyboard.release("s")
227
+ gamepad_state.key_held["s"] = False
228
+
229
+ # Handle A/D (left/right)
230
+ if x < -DEADZONE: # Left
231
+ if "a" not in gamepad_state.key_held or not gamepad_state.key_held["a"]:
232
+ keyboard.press("a")
233
+ gamepad_state.key_held["a"] = True
234
+ else:
235
+ if gamepad_state.key_held.get("a", False):
236
+ keyboard.release("a")
237
+ gamepad_state.key_held["a"] = False
238
+
239
+ if x > DEADZONE: # Right
240
+ if "d" not in gamepad_state.key_held or not gamepad_state.key_held["d"]:
241
+ keyboard.press("d")
242
+ gamepad_state.key_held["d"] = True
243
+ else:
244
+ if gamepad_state.key_held.get("d", False):
245
+ keyboard.release("d")
246
+ gamepad_state.key_held["d"] = False
247
+
248
+ elif stick_name == "right":
249
+ # Map to mouse movement
250
+ with gamepad_state.lock:
251
+ gamepad_state.right_stick = (x, y)
252
+ current_pos = mouse.position
253
+ gamepad_state.last_mouse_pos = current_pos
254
+
255
+ # Calculate mouse delta
256
+ now = time.time()
257
+ dt = now - gamepad_state.last_update_time
258
+ if dt > 0:
259
+ mouse_x = int(x * MOUSE_SENSITIVITY * dt * MOUSE_SPEED_MULTIPLIER)
260
+ mouse_y = int(y * MOUSE_SENSITIVITY * dt * MOUSE_SPEED_MULTIPLIER)
261
+
262
+ if mouse_x != 0 or mouse_y != 0:
263
+ try:
264
+ new_x = current_pos[0] + mouse_x
265
+ new_y = current_pos[1] + mouse_y
266
+ mouse.position = (new_x, new_y)
267
+ except Exception as e:
268
+ print(f"[gamepad-bridge] Mouse movement error: {e}", flush=True)
269
+
270
+
271
+ def handle_trigger(trigger_name: str, value: float):
272
+ """
273
+ Handle trigger input (0.0 to 1.0).
274
+ LT → Left click (or scroll)
275
+ RT → Right click (or scroll)
276
+ """
277
+ value = clamp(value, 0.0, 1.0)
278
+
279
+ if trigger_name == "left":
280
+ with gamepad_state.lock:
281
+ gamepad_state.left_trigger = value
282
+
283
+ # If trigger > 0.5, hold left mouse button
284
+ if value > 0.5:
285
+ if not gamepad_state.mouse_held.get("left", False):
286
+ try:
287
+ mouse.press(Button.left)
288
+ gamepad_state.mouse_held["left"] = True
289
+ except Exception as e:
290
+ print(f"[gamepad-bridge] LMB press error: {e}", flush=True)
291
+ else:
292
+ if gamepad_state.mouse_held.get("left", False):
293
+ try:
294
+ mouse.release(Button.left)
295
+ gamepad_state.mouse_held["left"] = False
296
+ except Exception as e:
297
+ print(f"[gamepad-bridge] LMB release error: {e}", flush=True)
298
+
299
+ elif trigger_name == "right":
300
+ with gamepad_state.lock:
301
+ gamepad_state.right_trigger = value
302
+
303
+ # If trigger > 0.5, hold right mouse button
304
+ if value > 0.5:
305
+ if not gamepad_state.mouse_held.get("right", False):
306
+ try:
307
+ mouse.press(Button.right)
308
+ gamepad_state.mouse_held["right"] = True
309
+ except Exception as e:
310
+ print(f"[gamepad-bridge] RMB press error: {e}", flush=True)
311
+ else:
312
+ if gamepad_state.mouse_held.get("right", False):
313
+ try:
314
+ mouse.release(Button.right)
315
+ gamepad_state.mouse_held["right"] = False
316
+ except Exception as e:
317
+ print(f"[gamepad-bridge] RMB release error: {e}", flush=True)
318
+
319
+
320
+ def reset_all_keys():
321
+ """
322
+ Emergency panic key: Release all held keys and mouse buttons.
323
+ Called on disconnect or shutdown.
324
+ """
325
+ print("[gamepad-bridge] Releasing all held keys/buttons", flush=True)
326
+ with gamepad_state.lock:
327
+ # Release all keyboard keys
328
+ for key_name in list(gamepad_state.key_held.keys()):
329
+ try:
330
+ if key_name == "shift":
331
+ keyboard.release(Key.shift)
332
+ elif key_name == "cmd":
333
+ keyboard.release(Key.cmd)
334
+ elif key_name == "esc":
335
+ keyboard.release(Key.esc)
336
+ elif key_name == "tab":
337
+ keyboard.release(Key.tab)
338
+ elif key_name == "enter":
339
+ keyboard.release(Key.enter)
340
+ else:
341
+ keyboard.release(key_name)
342
+ except Exception:
343
+ pass
344
+ gamepad_state.key_held[key_name] = False
345
+
346
+ # Release all mouse buttons
347
+ for btn_name in list(gamepad_state.mouse_held.keys()):
348
+ try:
349
+ if btn_name == "left":
350
+ mouse.release(Button.left)
351
+ elif btn_name == "right":
352
+ mouse.release(Button.right)
353
+ elif btn_name == "middle":
354
+ mouse.release(Button.middle)
355
+ except Exception:
356
+ pass
357
+ gamepad_state.mouse_held[btn_name] = False
358
+
359
+ gamepad_state.buttons_pressed.clear()
360
+
361
+
362
+ def process_gamepad_event(msg: dict):
363
+ """
364
+ Process a gamepad event from the network packet.
365
+ Expected format:
366
+ {"type": "gamepad", "viewer_id": "viewer_1", "button": 0, "pressed": true}
367
+ {"type": "gamepad", "viewer_id": "viewer_1", "axis": 0, "value": 0.5}
368
+ {"type": "gamepad", "viewer_id": "viewer_1", "stick": "left", "x": 0.2, "y": 0.3}
369
+ {"type": "gamepad", "viewer_id": "viewer_1", "trigger": "left", "value": 0.8}
370
+ """
371
+ viewer_id = str(msg.get("viewer_id", "unknown"))
372
+ with gamepad_state.lock:
373
+ if viewer_id not in viewer_ids:
374
+ viewer_ids.add(viewer_id)
375
+ print(f"[gamepad-bridge] Viewer {viewer_id} connected", flush=True)
376
+
377
+ # Button event
378
+ if "button" in msg:
379
+ button_idx = msg["button"]
380
+ pressed = bool(msg.get("pressed", False))
381
+ handle_button_event(button_idx, pressed)
382
+
383
+ # Stick event (combined X/Y)
384
+ elif "stick" in msg:
385
+ stick_name = msg["stick"] # "left" or "right"
386
+ x = float(msg.get("x", 0.0))
387
+ y = float(msg.get("y", 0.0))
388
+ handle_stick_movement(stick_name, x, y)
389
+
390
+ # Trigger event
391
+ elif "trigger" in msg:
392
+ trigger_name = msg["trigger"] # "left" or "right"
393
+ value = float(msg.get("value", 0.0))
394
+ handle_trigger(trigger_name, value)
395
+
396
+ # Axis event (single axis value)
397
+ elif "axis" in msg:
398
+ axis_idx = msg["axis"]
399
+ value = float(msg.get("value", 0.0))
400
+ # Map axis index to appropriate handler
401
+ # 0-1: left stick X/Y, 2-3: right stick X/Y, 4-5: triggers
402
+ if axis_idx in (0, 1): # Left stick components
403
+ handle_stick_movement("left", value if axis_idx == 0 else 0, value if axis_idx == 1 else 0)
404
+ elif axis_idx in (2, 3): # Right stick components
405
+ handle_stick_movement("right", value if axis_idx == 2 else 0, value if axis_idx == 3 else 0)
406
+ elif axis_idx == 4: # Left trigger
407
+ handle_trigger("left", value)
408
+ elif axis_idx == 5: # Right trigger
409
+ handle_trigger("right", value)
410
+
411
+
412
+ def check_permissions():
413
+ """
414
+ Warn user if Accessibility permissions are not granted.
415
+ On macOS, pynput requires accessibility permissions to function.
416
+ """
417
+ print("[gamepad-bridge] ========================================", flush=True)
418
+ print("[gamepad-bridge] macOS Gamepad Bridge Initialized", flush=True)
419
+ print("[gamepad-bridge] ========================================", flush=True)
420
+ print("[gamepad-bridge] ⚠ PERMISSION REQUIRED: Accessibility", flush=True)
421
+ print("[gamepad-bridge] Please grant access in System Settings →", flush=True)
422
+ print("[gamepad-bridge] Security & Privacy → Accessibility", flush=True)
423
+ print("[gamepad-bridge] Add this application to the list", flush=True)
424
+ print("[gamepad-bridge] ========================================", flush=True)
425
+
426
+
427
+ def _emit_gp_binary(slot, payload):
428
+ magic, lx, ly, rx, ry, lt, rt, cppBtns, hx, hy, slot_check = struct.unpack('<BhhhhBBHbbB', payload)
429
+
430
+ def _bit(mask): return bool(cppBtns & mask)
431
+
432
+ handle_button_event(0, _bit(1 << 0)) # A
433
+ handle_button_event(1, _bit(1 << 1)) # B
434
+ handle_button_event(2, _bit(1 << 2)) # X
435
+ handle_button_event(3, _bit(1 << 3)) # Y
436
+ handle_button_event(4, _bit(1 << 4)) # LB
437
+ handle_button_event(5, _bit(1 << 5)) # RB
438
+ handle_button_event(8, _bit(1 << 8)) # BACK
439
+ handle_button_event(9, _bit(1 << 9)) # START
440
+ handle_button_event(10, _bit(1 << 10)) # L3
441
+ handle_button_event(11, _bit(1 << 11)) # R3
442
+
443
+ handle_button_event(12, hy == -1)
444
+ handle_button_event(13, hy == 1)
445
+ handle_button_event(14, hx == -1)
446
+ handle_button_event(15, hx == 1)
447
+
448
+ lx_f = lx / 32767.0
449
+ ly_f = ly / 32767.0
450
+ handle_stick_movement("left", lx_f, -ly_f)
451
+
452
+ rx_f = rx / 32767.0
453
+ ry_f = ry / 32767.0
454
+ handle_stick_movement("right", rx_f, -ry_f)
455
+
456
+ lt_f = lt / 255.0
457
+ rt_f = rt / 255.0
458
+ handle_trigger("left", lt_f)
459
+ handle_trigger("right", rt_f)
460
+
461
+ def stdin_thread():
462
+ stdin_raw = open(sys.stdin.fileno(), 'rb', buffering=0)
463
+ for raw_line in stdin_raw:
464
+ line = raw_line.decode('utf-8', errors='replace').strip()
465
+ if line:
466
+ event_queue.put(('json', line))
467
+
468
+ def udp_thread(sock):
469
+ while True:
470
+ try:
471
+ data, _ = sock.recvfrom(1024)
472
+ if data and len(data) == 16 and data[0] == 0x01:
473
+ slot = data[15]
474
+ event_queue.put(('binary', slot, data))
475
+ except Exception:
476
+ pass
477
+
478
+ def run():
479
+ global running
480
+ check_permissions()
481
+
482
+ udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
483
+ udp_sock.bind(('127.0.0.1', 0))
484
+ udp_port = udp_sock.getsockname()[1]
485
+ print(json.dumps({"type": "udp_ready", "udp_port": udp_port}), flush=True)
486
+
487
+ threading.Thread(target=stdin_thread, daemon=True).start()
488
+ threading.Thread(target=udp_thread, args=(udp_sock,), daemon=True).start()
489
+
490
+ try:
491
+ while True:
492
+ ev = event_queue.get()
493
+ if ev[0] == 'binary':
494
+ _emit_gp_binary(ev[1], ev[2])
495
+ continue
496
+
497
+ line = ev[1]
498
+ try:
499
+ msg = json.loads(line)
500
+
501
+ # ── Disconnect / cleanup ────────────────────────────────────
502
+ if msg.get("type") in ["disconnect_viewer", "flush_neutral"]:
503
+ vid = str(msg.get("viewer_id", ""))
504
+ if vid in viewer_ids:
505
+ viewer_ids.discard(vid)
506
+ print(f"[gamepad-bridge] Viewer {vid} disconnected", flush=True)
507
+ if not viewer_ids:
508
+ reset_all_keys()
509
+ continue
510
+
511
+ # ── Gamepad event ───────────────────────────────────────────
512
+ if msg.get("type") == "gamepad":
513
+ process_gamepad_event(msg)
514
+ continue
515
+
516
+ # ── Config messages (ignored for gamepad bridge) ────────────
517
+ if msg.get("type") in ["set_force_xboxone", "set_enable_dualshock", "set_enable_motion", "set-input-mode"]:
518
+ continue
519
+
520
+ except json.JSONDecodeError:
521
+ pass # Ignore invalid JSON
522
+ except Exception as e:
523
+ print(f"[gamepad-bridge] Error: {e}", flush=True)
524
+
525
+ except KeyboardInterrupt:
526
+ print("[gamepad-bridge] Shutting down gracefully", flush=True)
527
+ reset_all_keys()
528
+ sys.exit(0)
529
+
530
+
531
+ if __name__ == "__main__":
532
+ run()