@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,697 @@
1
+ """
2
+ Linux uinput backend — stable, primary target.
3
+
4
+ KBM Emulation logic:
5
+ 1. If a window-focus message arrives AND the title matches a CSV row,
6
+ use the CSV flat bindings for that game. All viewers switch to kbm_emulated.
7
+ 2. If no CSV match (or no window-focus received), fall back to kbm_bindings.json.
8
+ 3. Gamepad mode works independently and is unaffected.
9
+ 4. Hybrid toggle (ctrl-settings-hybrid) disables auto-map and resets to gamepad.
10
+
11
+ CSV format (17 data columns after title):
12
+ title, KEY_W, KEY_A, KEY_S, KEY_D, KEY_SPACE, KEY_J, KEY_K, KEY_L,
13
+ KEY_U, KEY_I, KEY_O, KEY_P, KEY_M, KEY_N, KEY_ENTER, KEY_TAB, KEY_ESCAPE
14
+ Values: BTN_* for face buttons, ABS_Y_UP / ABS_Y_DOWN / ABS_X_LEFT / ABS_X_RIGHT for sticks,
15
+ ABS_HAT0Y_UP etc for d-pad, or blank to ignore that key.
16
+ """
17
+
18
+ import sys, json, os, atexit, csv, gc
19
+ import time
20
+ import struct
21
+ import threading
22
+ import socket
23
+ import queue
24
+ import select
25
+ from collections import deque
26
+
27
+ try:
28
+ import uinput
29
+ UINPUT_OK = True
30
+ except ImportError:
31
+ print(json.dumps({"type": "error", "code": "E100", "message": "python-uinput is missing or /dev/uinput lacks permissions. Please run the setup script!"}), flush=True)
32
+ UINPUT_OK = False
33
+
34
+ if UINPUT_OK:
35
+ W3C_MAP = {
36
+ 0: uinput.BTN_A, 1: uinput.BTN_B, 2: uinput.BTN_Y, 3: uinput.BTN_X, # FIX: Swapped X/Y for PS4 Native
37
+ 4: uinput.BTN_TL, 5: uinput.BTN_TR,
38
+ 8: uinput.BTN_SELECT, 9: uinput.BTN_START,
39
+ 10: uinput.BTN_THUMBL, 11: uinput.BTN_THUMBR,
40
+ 16: uinput.BTN_MODE,
41
+ }
42
+ BTNS = list(W3C_MAP.values())
43
+ # Add L2/R2 capabilities so the CSV shooters can actually fire
44
+ if hasattr(uinput, 'BTN_TL2'): BTNS.append(uinput.BTN_TL2)
45
+ if hasattr(uinput, 'BTN_TR2'): BTNS.append(uinput.BTN_TR2)
46
+ AXES = [
47
+ uinput.ABS_X + (-32767, 32767, 16, 128),
48
+ uinput.ABS_Y + (-32767, 32767, 16, 128),
49
+ uinput.ABS_RX + (-32767, 32767, 16, 128),
50
+ uinput.ABS_RY + (-32767, 32767, 16, 128),
51
+ uinput.ABS_Z + (0, 255, 0, 0),
52
+ uinput.ABS_RZ + (0, 255, 0, 0),
53
+ uinput.ABS_HAT0X + (-1, 1, 0, 0),
54
+ uinput.ABS_HAT0Y + (-1, 1, 0, 0),
55
+ ]
56
+
57
+
58
+
59
+ # ── Controller profiles ────────────────────────────────────────────────────────
60
+ PROFILES = {
61
+ 'xbox360': (0x045E, 0x028E, 0x0110, 'Xbox 360 Controller'),
62
+ 'xboxone': (0x045E, 0x02EA, 0x0101, 'Xbox One Controller'),
63
+ 'xbox': (0x045E, 0x028E, 0x0110, 'Xbox 360 Controller'),
64
+ 'ds4': (0x054C, 0x09CC, 0x0100, 'Wireless Controller'),
65
+ 'ps4': (0x054C, 0x09CC, 0x0100, 'Wireless Controller'),
66
+ 'playstation': (0x054C, 0x09CC, 0x0100, 'Wireless Controller'),
67
+ 'dualshock4': (0x054C, 0x09CC, 0x0100, 'Wireless Controller'),
68
+ 'dualsense': (0x054C, 0x0CE6, 0x0100, 'Wireless Controller'),
69
+ 'switchpro': (0x057E, 0x2009, 0x0001, 'Pro Controller'),
70
+ 'switch': (0x057E, 0x2009, 0x0001, 'Pro Controller'),
71
+ 'nintendo': (0x057E, 0x2009, 0x0001, 'Pro Controller'),
72
+ }
73
+
74
+ # ── KBM passthrough device ────────────────────────────────────────────────────
75
+ kbm_device = None
76
+ if UINPUT_OK:
77
+ KBM_EVENTS = [uinput.REL_X, uinput.REL_Y, uinput.REL_WHEEL,
78
+ uinput.BTN_LEFT, uinput.BTN_RIGHT, uinput.BTN_MIDDLE]
79
+ for _n in dir(uinput):
80
+ if _n.startswith("KEY_"):
81
+ KBM_EVENTS.append(getattr(uinput, _n))
82
+ try:
83
+ kbm_device = uinput.Device(KBM_EVENTS, name="Virtual_KBM_Injector")
84
+ except Exception as e:
85
+ print(json.dumps({"type": "error", "code": "E101", "message": f"KBM device failed (check /dev/uinput permissions): {e}"}), flush=True)
86
+
87
+ # ── State ──────────────────────────────────────────────────────────────────────
88
+ devices = {}
89
+ device_profiles = {}
90
+ viewer_modes = {} # vid → 'gamepad' | 'kbm' | 'kbm_emulated' | 'disabled'
91
+ viewer_ctrl_type = {} # vid → profile key
92
+ devices_by_slot = {} # slot -> gp
93
+ _input_queues = {}
94
+ event_queue = queue.Queue()
95
+ _active_binds = None
96
+ _auto_map_on = True
97
+ _is_hybrid = False
98
+
99
+
100
+
101
+
102
+
103
+ # ── Find CSV ──────────────────────────────────────────────────────────────────
104
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
105
+ _CSV_PATH = None
106
+ _search = _SCRIPT_DIR
107
+ for _ in range(5):
108
+ for candidate in [
109
+ os.path.join(_search, 'config', 'game_profiles.csv'),
110
+ os.path.join(_search, 'game_profiles.csv'),
111
+ ]:
112
+ if os.path.exists(candidate):
113
+ _CSV_PATH = candidate
114
+ break
115
+ if _CSV_PATH:
116
+ break
117
+ _search = os.path.dirname(_search)
118
+
119
+ if _CSV_PATH:
120
+ print(f"[input] CSV database: {_CSV_PATH}", flush=True)
121
+ else:
122
+ print("[input] WARNING: game_profiles.csv not found — CSV auto-map disabled", flush=True)
123
+
124
+ # ── Find JSON fallback ─────────────────────────────────────────────────────────
125
+ _JSON_PATH = None
126
+ _search = _SCRIPT_DIR
127
+ for _ in range(5):
128
+ for candidate in [
129
+ os.path.join(_search, 'config', 'kbm_bindings.json'),
130
+ os.path.join(_search, 'kbm_presets', 'kbm_bindings.json'),
131
+ os.path.join(_search, 'kbm_bindings.json'),
132
+ ]:
133
+ if os.path.exists(candidate):
134
+ _JSON_PATH = candidate
135
+ break
136
+ if _JSON_PATH:
137
+ break
138
+ _search = os.path.dirname(_search)
139
+
140
+ if _JSON_PATH:
141
+ print(f"[input] JSON fallback: {_JSON_PATH}", flush=True)
142
+ else:
143
+ print("[input] WARNING: kbm_bindings.json not found — no KBM fallback", flush=True)
144
+
145
+ # ── CSV column order ───────────────────────────────────────────────────────────
146
+ CSV_KEYS = [
147
+ "KEY_W", "KEY_A", "KEY_S", "KEY_D",
148
+ "KEY_SPACE", "KEY_J", "KEY_K", "KEY_L",
149
+ "KEY_U", "KEY_I", "KEY_O", "KEY_P",
150
+ "KEY_M", "KEY_N", "KEY_ENTER", "KEY_TAB", "KEY_ESCAPE",
151
+ ]
152
+
153
+ _csv_entries = []
154
+
155
+ def _load_csv():
156
+ global _csv_entries
157
+ if not _CSV_PATH:
158
+ return
159
+ rows = []
160
+ bad = 0
161
+ try:
162
+ with open(_CSV_PATH, newline='', encoding='utf-8') as f:
163
+ for line_num, row in enumerate(csv.reader(f), start=1):
164
+ if not row or row[0].strip().startswith('#'):
165
+ continue
166
+ frag = row[0].strip().lower()
167
+ if not frag:
168
+ continue
169
+
170
+ # Validate column count — a row with fewer than 2 columns has
171
+ # no bindings and is almost always a formatting mistake.
172
+ if len(row) < 2:
173
+ print(f"[input] CSV line {line_num}: skipping row '{row[0].strip()}' — no binding columns", flush=True)
174
+ bad += 1
175
+ continue
176
+
177
+ # Validate that binding values look like key names (KEY_* or BTN_*)
178
+ # rather than garbage data from a broken export.
179
+ binds = {}
180
+ for i, key in enumerate(CSV_KEYS):
181
+ col = i + 1
182
+ if col < len(row):
183
+ val = row[col].strip()
184
+ if not val:
185
+ continue
186
+ if not (val.startswith('KEY_') or val.startswith('BTN_') or val.startswith('ABS_')):
187
+ print(f"[input] CSV line {line_num}: unrecognised binding value '{val}' for {key} — skipping column", flush=True)
188
+ bad += 1
189
+ continue
190
+ binds[key] = val
191
+
192
+ if binds:
193
+ rows.append((frag, binds))
194
+ else:
195
+ print(f"[input] CSV line {line_num}: '{row[0].strip()}' has no valid bindings — skipping", flush=True)
196
+ bad += 1
197
+
198
+ _csv_entries = rows
199
+ if bad:
200
+ print(f"[input] CSV loaded {len(_csv_entries)} profiles ({bad} row(s) skipped — check game_profiles.csv)", flush=True)
201
+ else:
202
+ print(f"[input] Loaded {len(_csv_entries)} CSV game profiles", flush=True)
203
+ except Exception as e:
204
+ print(f"[input] CSV load error: {e}", flush=True)
205
+
206
+ def resolve_csv(title: str):
207
+ if not title:
208
+ return None
209
+ tl = title.lower()
210
+ for frag, binds in _csv_entries:
211
+ if frag in tl:
212
+ return binds
213
+ return None
214
+
215
+ _load_csv()
216
+
217
+ # ── JSON fallback loader ───────────────────────────────────────────────────────
218
+ _json_binds = None
219
+
220
+ def load_json_fallback():
221
+ global _json_binds
222
+ if _json_binds is not None:
223
+ return _json_binds
224
+ if not _JSON_PATH:
225
+ return None
226
+ try:
227
+ with open(_JSON_PATH) as f:
228
+ _json_binds = json.load(f)
229
+ print(f"[input] JSON fallback loaded", flush=True)
230
+ return _json_binds
231
+ except Exception as e:
232
+ print(f"[input] JSON load error: {e}", flush=True)
233
+ return None
234
+
235
+ load_json_fallback()
236
+
237
+ # ── atexit cleanup ─────────────────────────────────────────────────────────────
238
+ def _cleanup():
239
+ global kbm_device
240
+ print("[input] Destroying virtual devices...", flush=True)
241
+ _btn_held.clear()
242
+ for dev in list(devices.values()):
243
+ try: dev.destroy()
244
+ except Exception: pass
245
+ devices.clear()
246
+ if kbm_device:
247
+ try: kbm_device.destroy()
248
+ except Exception: pass
249
+ kbm_device = None
250
+
251
+
252
+ atexit.register(_cleanup)
253
+
254
+ # ── Device factory ─────────────────────────────────────────────────────────────
255
+ def make_gamepad(profile_key: str = 'xbox360'):
256
+ if not UINPUT_OK:
257
+ return None
258
+ v, p, ver, real_name = PROFILES.get(profile_key, PROFILES['xbox360'])
259
+
260
+ # bustype=3 tells Linux/Steam this is a physical USB device (BUS_USB).
261
+ return uinput.Device(BTNS + AXES, name=real_name, vendor=v, product=p, version=ver, bustype=3)
262
+
263
+ # ── Input queue (gamepad path) ─────────────────────────────────────────────────
264
+ def _enqueue(pad_id, msg):
265
+ if pad_id not in _input_queues:
266
+ _input_queues[pad_id] = deque(maxlen=64)
267
+ _input_queues[pad_id].append(msg)
268
+
269
+ def _drain(pad_id):
270
+ q = _input_queues.get(pad_id)
271
+ while q:
272
+ _emit_gp(pad_id, q.popleft())
273
+
274
+ def _emit_gp(pad_id, msg):
275
+ if not UINPUT_OK: return
276
+ gp = devices.get(pad_id)
277
+ if not gp: return
278
+
279
+ # Check if we are receiving the new flat schema from InputOrchestrator validation
280
+ if "lx" in msg or isinstance(msg.get("buttons"), int):
281
+ btns_mask = msg.get("buttons", 0)
282
+ lx = msg.get("lx", 0)
283
+ ly = msg.get("ly", 0)
284
+ rx = msg.get("rx", 0)
285
+ ry = msg.get("ry", 0)
286
+ lt = msg.get("lt", 0.0)
287
+ rt = msg.get("rt", 0.0)
288
+
289
+ # Correct bitmask values matching W3C_TO_JS in server.js
290
+ JS_BITMASK = {
291
+ 0: 0x0001, 1: 0x0002, 2: 0x0004, 3: 0x0008, # A, B, X, Y
292
+ 4: 0x0100, 5: 0x0200, # LB, RB
293
+ 8: 0x2000, 9: 0x1000, # Select, Start
294
+ 10: 0x0400, 11: 0x0800, # L3, R3
295
+ 16: 0x4000 # Guide
296
+ }
297
+ for w3c_idx, btn in W3C_MAP.items():
298
+ mask = JS_BITMASK.get(w3c_idx)
299
+ if mask:
300
+ is_pressed = (btns_mask & mask) != 0
301
+ gp.emit(btn, 1 if is_pressed else 0, syn=False)
302
+
303
+ gp.emit(uinput.ABS_X, lx, syn=False)
304
+ gp.emit(uinput.ABS_Y, ly, syn=False)
305
+ gp.emit(uinput.ABS_RX, rx, syn=False)
306
+ gp.emit(uinput.ABS_RY, ry, syn=False)
307
+ gp.emit(uinput.ABS_Z, int(lt * 255), syn=False)
308
+ gp.emit(uinput.ABS_RZ, int(rt * 255), syn=False)
309
+
310
+ # D-pad (bits 4-7 mapped from W3C 12-15)
311
+ # 0x0040 (D-Left), 0x0080 (D-Right), 0x0010 (D-Up), 0x0020 (D-Down)
312
+ hx = -1 if (btns_mask & 0x0040) else 1 if (btns_mask & 0x0080) else 0
313
+ hy = -1 if (btns_mask & 0x0010) else 1 if (btns_mask & 0x0020) else 0
314
+ gp.emit(uinput.ABS_HAT0X, hx, syn=False)
315
+ gp.emit(uinput.ABS_HAT0Y, hy, syn=False)
316
+
317
+ else:
318
+ # Legacy array schema fallback
319
+ btns = msg.get("buttons", [])
320
+ axes = msg.get("axes", [])
321
+ for w3c, btn in W3C_MAP.items():
322
+ if len(btns) > w3c:
323
+ gp.emit(btn, 1 if btns[w3c]["pressed"] else 0, syn=False)
324
+ if len(axes) >= 2:
325
+ gp.emit(uinput.ABS_X, int(axes[0]), syn=False)
326
+ gp.emit(uinput.ABS_Y, int(axes[1]), syn=False)
327
+ if len(axes) >= 4:
328
+ gp.emit(uinput.ABS_RX, int(axes[2]), syn=False)
329
+ gp.emit(uinput.ABS_RY, int(axes[3]), syn=False)
330
+ if len(btns) > 6:
331
+ gp.emit(uinput.ABS_Z, int(btns[6].get("value", 0) * 255), syn=False)
332
+ if len(btns) > 7:
333
+ gp.emit(uinput.ABS_RZ, int(btns[7].get("value", 0) * 255), syn=False)
334
+ if len(btns) > 15:
335
+ hx = -1 if btns[14]["pressed"] else 1 if btns[15]["pressed"] else 0
336
+ hy = -1 if btns[12]["pressed"] else 1 if btns[13]["pressed"] else 0
337
+ gp.emit(uinput.ABS_HAT0X, hx, syn=False)
338
+ gp.emit(uinput.ABS_HAT0Y, hy, syn=False)
339
+
340
+ gp.syn()
341
+
342
+ def _emit_gp_binary(slot, payload):
343
+ if not UINPUT_OK: return
344
+ gp = devices_by_slot.get(slot)
345
+ if not gp: return
346
+
347
+ magic, lx, ly, rx, ry, lt, rt, btns_mask, hx, hy, slot_check = struct.unpack('<BhhhhBBHbbB', payload)
348
+
349
+ for w3c_idx, btn in W3C_MAP.items():
350
+ is_pressed = (btns_mask & (1 << w3c_idx)) != 0
351
+ gp.emit(btn, 1 if is_pressed else 0, syn=False)
352
+
353
+ gp.emit(uinput.ABS_X, lx, syn=False)
354
+ gp.emit(uinput.ABS_Y, ly, syn=False)
355
+ gp.emit(uinput.ABS_RX, rx, syn=False)
356
+ gp.emit(uinput.ABS_RY, ry, syn=False)
357
+ gp.emit(uinput.ABS_Z, lt, syn=False)
358
+ gp.emit(uinput.ABS_RZ, rt, syn=False)
359
+ gp.emit(uinput.ABS_HAT0X, hx, syn=False)
360
+ gp.emit(uinput.ABS_HAT0Y, hy, syn=False)
361
+ gp.syn()
362
+
363
+ def _ensure_gp(pad_id, vid):
364
+ if not UINPUT_OK: return
365
+ wanted = viewer_ctrl_type.get(pad_id) or viewer_ctrl_type.get(vid) or viewer_ctrl_type.get("") or 'xbox360'
366
+ if device_profiles.get(pad_id) != wanted:
367
+ old = devices.pop(pad_id, None)
368
+ if old:
369
+ try: old.destroy()
370
+ except Exception: pass
371
+ gp = make_gamepad(wanted)
372
+ devices[pad_id] = gp
373
+ device_profiles[pad_id] = wanted
374
+ _, _, _, label = PROFILES.get(wanted, ('','','','unknown'))
375
+ print(f"[input] Created {label}: {pad_id[:12]}", flush=True)
376
+
377
+ # ── UNIFIED KBM emulation handler ─────────────────────────────────────────────
378
+ def _emit_kbm_event(pad_id: str, vid: str, key: str, is_down: bool, binds: dict):
379
+ if not UINPUT_OK or not binds:
380
+ return
381
+
382
+ _ensure_gp(pad_id, vid)
383
+ gp = devices.get(pad_id)
384
+ if not gp:
385
+ return
386
+
387
+ dn = 1 if is_down else 0
388
+ is_flat = isinstance(next(iter(binds.values()), None), str)
389
+
390
+ if is_flat:
391
+ target = binds.get(key)
392
+ if not target:
393
+ return
394
+
395
+ alias_map = {
396
+ "BTN_SOUTH": "BTN_A",
397
+ "BTN_EAST": "BTN_B",
398
+ "BTN_NORTH": "BTN_X",
399
+ "BTN_WEST": "BTN_Y"
400
+ }
401
+ target = alias_map.get(target, target)
402
+
403
+ if target.startswith("BTN_") and hasattr(uinput, target):
404
+ btn_const = getattr(uinput, target)
405
+ held_key = (id(gp), btn_const)
406
+ already_down = _btn_held.get(held_key, False)
407
+
408
+ # FIX: Completely ignore OS key-repeats. If it's already held down,
409
+ # do not force a release. Just let the game read the continuous hold.
410
+ if is_down and already_down:
411
+ return
412
+
413
+ _btn_held[held_key] = is_down
414
+ gp.emit(btn_const, dn)
415
+
416
+ elif target.startswith("ABS_"):
417
+ # Stop axes from spamming the kernel during key-repeats
418
+ held_key = (id(gp), target)
419
+ already_down = _btn_held.get(held_key, False)
420
+ if is_down and already_down:
421
+ return
422
+ _btn_held[held_key] = is_down
423
+
424
+ if target == "ABS_Y_UP":
425
+ gp.emit(uinput.ABS_Y, -32767 if is_down else 0)
426
+ elif target == "ABS_Y_DOWN":
427
+ gp.emit(uinput.ABS_Y, 32767 if is_down else 0)
428
+ elif target == "ABS_X_LEFT":
429
+ gp.emit(uinput.ABS_X, -32767 if is_down else 0)
430
+ elif target == "ABS_X_RIGHT":
431
+ gp.emit(uinput.ABS_X, 32767 if is_down else 0)
432
+ elif target == "ABS_HAT0Y_UP":
433
+ gp.emit(uinput.ABS_HAT0Y, -1 if is_down else 0)
434
+ elif target == "ABS_HAT0Y_DOWN":
435
+ gp.emit(uinput.ABS_HAT0Y, 1 if is_down else 0)
436
+ elif target == "ABS_HAT0X_LEFT":
437
+ gp.emit(uinput.ABS_HAT0X, -1 if is_down else 0)
438
+ elif target == "ABS_HAT0X_RIGHT":
439
+ gp.emit(uinput.ABS_HAT0X, 1 if is_down else 0)
440
+
441
+ else:
442
+ # Nested JSON Block (Your provided format)
443
+ btn_target = binds.get("buttons", {}).get(key)
444
+ if btn_target and hasattr(uinput, btn_target):
445
+ btn_const = getattr(uinput, btn_target)
446
+ held_key = (id(gp), btn_const)
447
+ already_down = _btn_held.get(held_key, False)
448
+
449
+ # FIX: Ignore OS key-repeats for buttons
450
+ if is_down and already_down:
451
+ return
452
+
453
+ _btn_held[held_key] = is_down
454
+ gp.emit(btn_const, dn)
455
+
456
+ for section in ["left_stick", "dpad"]:
457
+ m = binds.get(section, {}).get(key)
458
+ if m:
459
+ ax = m.get("axis")
460
+ val = m.get("val", 0)
461
+ if ax and hasattr(uinput, ax):
462
+ held_key = (id(gp), ax, key)
463
+ already_down = _btn_held.get(held_key, False)
464
+
465
+ # FIX: Ignore OS key-repeats for analog sticks/D-Pads
466
+ if is_down and already_down:
467
+ continue
468
+
469
+ _btn_held[held_key] = is_down
470
+ gp.emit(getattr(uinput, ax), val if is_down else 0)
471
+
472
+ gp.syn()
473
+
474
+ _last_mouse_move = {}
475
+ _btn_held = {}
476
+
477
+ def _maybe_reset_right_stick(pad_id, vid):
478
+ if not UINPUT_OK: return
479
+ t = _last_mouse_move.get(pad_id, 0)
480
+ if t and (time.monotonic() - t) >= 0.032:
481
+ gp = devices.get(pad_id)
482
+ if gp:
483
+ gp.emit(uinput.ABS_RX, 0, syn=False)
484
+ gp.emit(uinput.ABS_RY, 0, syn=True)
485
+ _last_mouse_move[pad_id] = 0
486
+
487
+ # ── Main loop ──────────────────────────────────────────────────────────────────
488
+ def stdin_thread():
489
+ stdin_raw = open(sys.stdin.fileno(), 'rb', buffering=0)
490
+ for raw_line in stdin_raw:
491
+ line = raw_line.decode('utf-8', errors='replace').strip()
492
+ if line:
493
+ event_queue.put(('json', line))
494
+
495
+ def udp_thread(sock):
496
+ while True:
497
+ try:
498
+ data, _ = sock.recvfrom(1024)
499
+ if data and len(data) == 16 and data[0] == 0x01:
500
+ slot = data[15]
501
+ event_queue.put(('binary', slot, data))
502
+ except Exception:
503
+ pass
504
+
505
+ def run():
506
+ global _active_binds, _auto_map_on, _is_hybrid
507
+ print("[input] Loaded linux_uinput backend (stable, completely unified)", flush=True)
508
+
509
+ udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
510
+ udp_sock.bind(('127.0.0.1', 0))
511
+ udp_port = udp_sock.getsockname()[1]
512
+ print(json.dumps({"type": "udp_ready", "udp_port": udp_port}), flush=True)
513
+
514
+ threading.Thread(target=stdin_thread, daemon=True).start()
515
+ threading.Thread(target=udp_thread, args=(udp_sock,), daemon=True).start()
516
+
517
+ while True:
518
+ ev = event_queue.get()
519
+ if ev[0] == 'binary':
520
+ _emit_gp_binary(ev[1], ev[2])
521
+ continue
522
+
523
+ line = ev[1]
524
+ for _rspad in list(_last_mouse_move):
525
+ if _last_mouse_move[_rspad]:
526
+ _maybe_reset_right_stick(_rspad, _rspad.split('_')[0])
527
+
528
+ try:
529
+ msg = json.loads(line)
530
+ msg_type = msg.get("type")
531
+
532
+ if msg_type == "allocate_slot":
533
+ pad_id = msg.get("pad_id")
534
+ slot = msg.get("slot")
535
+ profile = msg.get("profile", "xbox360")
536
+ viewer_ctrl_type[pad_id] = profile
537
+ _ensure_gp(pad_id, pad_id.split('_')[0])
538
+ if pad_id in devices:
539
+ devices_by_slot[slot] = devices[pad_id]
540
+ continue
541
+
542
+ if msg_type == "free_slot":
543
+ slot = msg.get("slot")
544
+ if slot in devices_by_slot:
545
+ del devices_by_slot[slot]
546
+ continue
547
+
548
+ if msg_type == "window-focus":
549
+ if not _auto_map_on:
550
+ continue
551
+ title = msg.get("title", "")
552
+ binds = resolve_csv(title)
553
+ if binds:
554
+ _active_binds = binds
555
+ for vid in list(viewer_modes.keys()):
556
+ # FIX: Removed the line that forced xbox360.
557
+ # This respects your PS4/Switch choice.
558
+ viewer_modes[vid] = 'kbm_emulated'
559
+ print(f"[input] CSV match '{title}' → kbm_emulated ({len(binds)} binds)", flush=True)
560
+ else:
561
+ _active_binds = None
562
+ print(f"[input] No CSV match for '{title}' — using JSON fallback if kbm_emulated", flush=True)
563
+ continue
564
+
565
+ if msg_type == "reload-csv":
566
+ _load_csv()
567
+ continue
568
+
569
+ if msg_type == "ctrl-settings-hybrid":
570
+ _is_hybrid = bool(msg.get("enabled", False))
571
+ _auto_map_on = not _is_hybrid
572
+ for vid in list(viewer_modes.keys()):
573
+ if viewer_modes[vid] not in ['kbm_emulated', 'kbm']:
574
+ viewer_modes[vid] = 'hybrid' if _is_hybrid else 'gamepad'
575
+ _active_binds = None
576
+ print(f"[input] Hybrid mode {'ON' if _is_hybrid else 'OFF'} (Auto-map {'ON' if _auto_map_on else 'OFF'})", flush=True)
577
+ continue
578
+
579
+ if msg_type == "set-ctrl-type":
580
+ vid_tag = str(msg.get("viewerId") or msg.get("viewer_id", ""))
581
+ raw_ctrl = str(msg.get("ctrlType") or msg.get("ctrl_type", "xbox360"))
582
+ ctrl = raw_ctrl.lower()
583
+
584
+ viewer_ctrl_type[vid_tag] = ctrl
585
+ print(f"[input] Viewer {vid_tag} set to {ctrl}", flush=True)
586
+ continue
587
+
588
+ if msg_type == "set-input-mode":
589
+ vid = str(msg.get("viewerId", "")).split('_')[0]
590
+ viewer_modes[vid] = msg.get("mode", "gamepad")
591
+ continue
592
+
593
+ if msg_type in ["flush_neutral", "disconnect_viewer"]:
594
+ vid = str(msg.get("viewer_id", ""))
595
+ keys = [k for k in list(devices) if k.startswith(vid + "_") or k == vid]
596
+ for k in keys:
597
+ dev = devices.pop(k, None)
598
+ if dev:
599
+ try: dev.destroy()
600
+ except Exception: pass
601
+ _input_queues.pop(k, None)
602
+ viewer_modes.pop(vid, None)
603
+ viewer_ctrl_type.pop(vid, None)
604
+ gc.collect()
605
+ continue
606
+
607
+ if msg_type == "destroy_all":
608
+ _cleanup()
609
+ continue
610
+
611
+ # ====================================================================
612
+ # THE RE-ORDERED INPUT PIPELINE STARTS HERE
613
+ # ====================================================================
614
+
615
+ pad_raw = str(msg.get("pad_id", ""))
616
+ vid = str(msg.get("viewer_id") or msg.get("viewerId", ""))
617
+
618
+ # FIX: If mobile touch controls stripped the ID to save bandwidth,
619
+ # extract it directly from the pad_id (turns "v1_99" back into "v1")
620
+ if not vid and pad_raw:
621
+ vid = pad_raw.split("_")[0]
622
+
623
+ if msg_type in ["kbm", "keyboard"]:
624
+ active_pads = [p for p in devices.keys() if p.startswith(vid + "_")]
625
+ pad_id = active_pads[0] if active_pads else f"{vid}_0"
626
+ else:
627
+ pad_id = pad_raw if pad_raw else f"{vid}_0"
628
+
629
+ mode = viewer_modes.get(vid, "hybrid" if _is_hybrid else "gamepad")
630
+
631
+ if msg_type in ["kbm", "keyboard"] and kbm_device and mode in ["kbm", "hybrid", "kbm_emulated"]:
632
+ ev = msg.get("event")
633
+ if ev == "mousemove":
634
+ dx, dy = msg.get("dx", 0), msg.get("dy", 0)
635
+ if dx: kbm_device.emit(uinput.REL_X, dx, syn=False)
636
+ if dy: kbm_device.emit(uinput.REL_Y, dy, syn=False)
637
+ kbm_device.syn()
638
+ elif ev in ["keydown", "keyup"]:
639
+ k = msg.get("key", "")
640
+ v = 1 if ev == "keydown" else 0
641
+ if hasattr(uinput, k):
642
+ kbm_device.emit(getattr(uinput, k), v)
643
+ elif ev in ["mousedown", "mouseup"]:
644
+ b = msg.get("button", 0)
645
+ v = 1 if ev == "mousedown" else 0
646
+ u = uinput.BTN_LEFT if b == 0 else uinput.BTN_MIDDLE if b == 1 else uinput.BTN_RIGHT
647
+ kbm_device.emit(u, v)
648
+ continue
649
+
650
+ if msg_type == "gamepad" and mode in ["gamepad", "hybrid", "kbm_emulated"]:
651
+ _ensure_gp(pad_id, vid)
652
+ _enqueue(pad_id, msg)
653
+ _drain(pad_id)
654
+
655
+ elif msg_type in ["kbm", "keyboard"] and mode in ["kbm_emulated", "hybrid"]:
656
+ ev = msg.get("event")
657
+
658
+ if ev in ["keydown", "keyup", "mousedown", "mouseup"]:
659
+ binds = _active_binds if _active_binds else load_json_fallback()
660
+ if binds:
661
+ is_down = ev in ["keydown", "mousedown"]
662
+
663
+ if ev in ["keydown", "keyup"]:
664
+ target_key = msg.get("key", "")
665
+ else:
666
+ btn_idx = msg.get("button", 0)
667
+ if btn_idx == 0: target_key = "BTN_LEFT"
668
+ elif btn_idx == 1: target_key = "BTN_MIDDLE"
669
+ elif btn_idx == 2: target_key = "BTN_RIGHT"
670
+ else: target_key = ""
671
+
672
+ _emit_kbm_event(pad_id, vid, target_key, is_down, binds)
673
+
674
+ elif ev == "mousemove":
675
+ binds = _active_binds if _active_binds else load_json_fallback()
676
+
677
+ if binds and binds.get("right_stick_mouse", False):
678
+ mult = binds.get("right_stick_multiplier", 1500)
679
+ dx, dy = msg.get("dx", 0), msg.get("dy", 0)
680
+
681
+ _ensure_gp(pad_id, vid)
682
+ gp = devices.get(pad_id)
683
+ if gp and (dx or dy):
684
+ rx = max(-32767, min(32767, int(dx * mult)))
685
+ ry = max(-32767, min(32767, int(dy * mult)))
686
+
687
+ gp.emit(uinput.ABS_RX, rx, syn=False)
688
+ gp.emit(uinput.ABS_RY, ry, syn=True)
689
+ _last_mouse_move[pad_id] = time.monotonic()
690
+
691
+ except json.JSONDecodeError:
692
+ pass
693
+ except Exception as e:
694
+ print(f"[input] Error: {e}", flush=True)
695
+
696
+ if __name__ == "__main__":
697
+ run()