@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,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()
|