@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,829 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+ const { EventEmitter } = require('events');
5
+ const dgram = require('dgram');
6
+
7
+ // ── Visualizer event bus — host.js listens on inputDriver.events ──────────────
8
+ const events = new EventEmitter();
9
+
10
+ // ── Shared Buffers for Zero-Copy Native C++ Submission ──
11
+ // Buffer layout MUST match uinputBridge.cpp exactly.
12
+ // GAMEPAD packet (16 bytes):
13
+ // [0] = 0x01 (PKT::GAMEPAD)
14
+ // [1-2] = lx (int16LE)
15
+ // [3-4] = ly (int16LE)
16
+ // [5-6] = rx (int16LE)
17
+ // [7-8] = ry (int16LE)
18
+ // [9] = lt (uint8, 0-255)
19
+ // [10] = rt (uint8, 0-255)
20
+ // [11-12] = btn (uint16LE, C++ W3C_BTN bit format)
21
+ // [13] = hx (int8, dpad X: -1/0/1)
22
+ // [14] = hy (int8, dpad Y: -1/0/1)
23
+ // [15] = slot (uint8)
24
+ const _gpBuf = Buffer.alloc(16);
25
+ const _alBuf = Buffer.alloc(104);
26
+ const _flBuf = Buffer.alloc(2);
27
+ const _frBuf = Buffer.alloc(2);
28
+
29
+ _alBuf[0] = 0x10; // PKT::ALLOC_GP
30
+ _flBuf[0] = 0x20; // PKT::FLUSH
31
+ _frBuf[0] = 0x11; // PKT::FREE_GP
32
+
33
+ // ── Button Bitmask Converter ───────────────────────────────────────────────────
34
+ // The viewer.js uses the KBM_BTN_MAP bit layout. The C++ bridge uses a different
35
+ // W3C_BTN enum. This function converts between the two AND extracts dpad as hx/hy
36
+ // (the C++ bridge uses ABS_HAT0X/Y for dpad, not button bits).
37
+ //
38
+ // JS viewer bit layout: C++ W3C_BTN layout:
39
+ // bit 0 = A bit 0 = A (BTN_SOUTH)
40
+ // bit 1 = B bit 1 = B (BTN_EAST)
41
+ // bit 2 = X bit 2 = *Y-slot (BTN_WEST = X physical)
42
+ // bit 3 = Y bit 3 = *X-slot (BTN_NORTH = Y physical)
43
+ // bit 4 = D-Up → hx/hy bit 4 = LB (BTN_TL)
44
+ // bit 5 = D-Down → hx/hy bit 5 = RB (BTN_TR)
45
+ // bit 6 = D-Left → hx/hy bit 8 = BACK (BTN_SELECT)
46
+ // bit 7 = D-Right→ hx/hy bit 9 = START (BTN_START)
47
+ // bit 8 = LB → C++ bit 4 bit 10 = LS (BTN_THUMBL)
48
+ // bit 9 = RB → C++ bit 5 bit 11 = RS (BTN_THUMBR)
49
+ // bit 10 = L3 (* C++ X/Y naming is swapped but emits correctly)
50
+ // bit 11 = R3
51
+ // bit 12 = START → C++ bit 9
52
+ // bit 13 = SELECT → C++ bit 8
53
+ // bit 14 = GUIDE (C++ reads uint16 so bit16 unreachable — skip)
54
+ function _jsBtnsToCpp(jsBtns) {
55
+ let cpp = 0;
56
+ // A, B, X, Y — bits 0-3 pass through (C++ X/Y label swap is intentional, emits correctly)
57
+ cpp |= (jsBtns & 0x000F);
58
+ // LB: JS bit8 → C++ bit4
59
+ if (jsBtns & 0x0100) cpp |= 0x0010;
60
+ // RB: JS bit9 → C++ bit5
61
+ if (jsBtns & 0x0200) cpp |= 0x0020;
62
+ // L3: JS bit10 → C++ bit10 (unchanged)
63
+ if (jsBtns & 0x0400) cpp |= 0x0400;
64
+ // R3: JS bit11 → C++ bit11 (unchanged)
65
+ if (jsBtns & 0x0800) cpp |= 0x0800;
66
+ // START: JS bit12 → C++ bit9
67
+ if (jsBtns & 0x1000) cpp |= 0x0200;
68
+ // SELECT/BACK: JS bit13 → C++ bit8
69
+ if (jsBtns & 0x2000) cpp |= 0x0100;
70
+ // GUIDE: JS bit14 → C++ bit16 — uint16 can't hold bit16, skip
71
+
72
+ // Dpad bits 4-7 → ABS_HAT values written to [13][14] in the buffer
73
+ const hx = (jsBtns & 0x0040) ? -1 : (jsBtns & 0x0080) ? 1 : 0; // LEFT / RIGHT
74
+ const hy = (jsBtns & 0x0010) ? -1 : (jsBtns & 0x0020) ? 1 : 0; // UP / DOWN
75
+ return { cpp, hx, hy };
76
+ }
77
+
78
+ // ── State ──────────────────────────────────────────────────────────────────────
79
+ const viewerSlots = new Map();
80
+ const slotViewers = new Map();
81
+ const viewerCtrlType = new Map();
82
+ const viewerModes = new Map();
83
+
84
+ // KBM Emulation State
85
+ const kbmStates = new Map();
86
+
87
+ // Tracks millisecond activity for Auto-Eviction
88
+ const slotLastUsed = new Map();
89
+
90
+ let _bridge = null;
91
+ let _pythonProc = null;
92
+ let _udpSocket = null;
93
+ let _pythonUdpPort = 0;
94
+
95
+ // HIDMaestro backend toggle — set before init()
96
+ let _hidmaestroEnabled = false;
97
+ function setHidMaestroEnabled(enabled) {
98
+ _hidmaestroEnabled = !!enabled;
99
+ }
100
+
101
+ let GAME_PROFILES = {};
102
+ let KBM_BINDINGS = { keys: {}, mouse: { sensitivity: 1.5, deadzone: 0.1 } };
103
+
104
+ // Global default profile key — updated whenever the host broadcasts ctrl-settings.
105
+ // This ensures all newly allocated slots inherit the host's chosen controller type
106
+ // rather than always falling back to xbox360.
107
+ let _defaultProfileKey = 'xbox360';
108
+ let _hybridInputEnabled = false;
109
+
110
+
111
+ const KBM_BTN_MAP = {
112
+ 'A': 0x0001, 'B': 0x0002, 'X': 0x0004, 'Y': 0x0008,
113
+ 'UP': 0x0010, 'DOWN': 0x0020, 'LEFT': 0x0040, 'RIGHT': 0x0080,
114
+ 'LB': 0x0100, 'RB': 0x0200, 'L3': 0x0400, 'R3': 0x0800,
115
+ 'START': 0x1000, 'SELECT': 0x2000, 'GUIDE': 0x4000
116
+ };
117
+
118
+ const BUTTON_ALIASES = {
119
+ BTN_SOUTH: 'A', BTN_EAST: 'B', BTN_WEST: 'X', BTN_NORTH: 'Y',
120
+ BTN_TL: 'LB', BTN_TR: 'RB', BTN_TL2: 'LT', BTN_TR2: 'RT',
121
+ BTN_SELECT: 'SELECT', BTN_START: 'START',
122
+ BTN_THUMBL: 'L3', BTN_THUMBR: 'R3',
123
+ BTN_DPAD_UP: 'UP', BTN_DPAD_DOWN: 'DOWN', BTN_DPAD_LEFT: 'LEFT', BTN_DPAD_RIGHT: 'RIGHT'
124
+ };
125
+
126
+ const PROFILES = {
127
+ xbox360: { vendor: 0x045E, product: 0x028E, version: 0x0114, name: "Microsoft X-Box 360 pad" },
128
+ xbox: { vendor: 0x045E, product: 0x028E, version: 0x0114, name: "Microsoft X-Box 360 pad" },
129
+ xboxone: { vendor: 0x045E, product: 0x02EA, version: 0x0301, name: "Microsoft X-Box One S pad" },
130
+ ds4: { vendor: 0x054C, product: 0x05C4, version: 0x8111, name: "Sony Computer Entertainment Wireless Controller" },
131
+ ps4: { vendor: 0x054C, product: 0x05C4, version: 0x8111, name: "Sony Computer Entertainment Wireless Controller" },
132
+ playstation: { vendor: 0x054C, product: 0x05C4, version: 0x8111, name: "Sony Computer Entertainment Wireless Controller" },
133
+ dualshock4: { vendor: 0x054C, product: 0x05C4, version: 0x8111, name: "Sony Computer Entertainment Wireless Controller" },
134
+ dualsense: { vendor: 0x054C, product: 0x0CE6, version: 0x8111, name: "Sony Interactive Entertainment Wireless Controller" },
135
+ switchpro: { vendor: 0x0500, product: 0x2009, version: 0x8111, name: "Nintendo Switch Pro Controller" },
136
+ switch: { vendor: 0x0500, product: 0x2009, version: 0x8111, name: "Nintendo Switch Pro Controller" },
137
+ nintendo: { vendor: 0x0500, product: 0x2009, version: 0x8111, name: "Nintendo Switch Pro Controller" }
138
+ };
139
+
140
+ // ── Initialization ─────────────────────────────────────────────────────────────
141
+ const isWin = process.platform === 'win32';
142
+ const isMac = process.platform === 'darwin';
143
+
144
+ function init(screenWidth, screenHeight) {
145
+ _loadProfiles();
146
+
147
+ // On Windows and macOS the uinputBridge C++ addon is Linux-only.
148
+ // Skip it entirely and go straight to the Python sidecar.
149
+ if (!isWin && !isMac) {
150
+ // 1. Try Native C++ Fast Lane (Linux only)
151
+ try {
152
+ const nodePath = path.join(__dirname, '..', 'native', 'build', 'Release', 'uinputBridge.node');
153
+ _bridge = require(nodePath);
154
+ _bridge.initializeDevice(screenWidth || 1920, screenHeight || 1080);
155
+ console.log(`[input] Native uinputBridge loaded: ${nodePath}`);
156
+ return true;
157
+ } catch (e) {
158
+ console.warn(`[input] Native bridge failed to load (${e.message}). Falling back to Python.`);
159
+ _bridge = null;
160
+ }
161
+ } else if (isWin) {
162
+ console.log('[input] Windows detected — skipping uinputBridge, using Python/' + (_hidmaestroEnabled ? 'HIDMaestro' : 'ViGEmBus') + ' sidecar.');
163
+ } else {
164
+ console.log('[input] macOS detected — skipping uinputBridge, using Python/pynput sidecar.');
165
+ }
166
+
167
+ _udpSocket = dgram.createSocket('udp4');
168
+ _udpSocket.on('error', (err) => {
169
+ console.error('[input] UDP socket error:\n' + err.stack);
170
+ _udpSocket.close();
171
+ });
172
+
173
+ // 2. Python Sidecar — platform-aware script selection
174
+ let scriptName;
175
+ if (isWin) scriptName = _hidmaestroEnabled ? 'windows_hidmaestro.py' : 'windows_vigem.py';
176
+ else if (isMac) scriptName = 'mac_gamepad_bridge.py';
177
+ else scriptName = 'linux_uinput.py';
178
+ const pythonScript = path.join(__dirname, '..', 'python', scriptName);
179
+ if (!fs.existsSync(pythonScript)) {
180
+ console.error(`[input] FATAL: Python fallback not found at ${pythonScript}`);
181
+ return false;
182
+ }
183
+
184
+ const pythonCmd = isWin ? 'python' : 'python3';
185
+ const spawnOpts = { stdio: ['pipe', 'pipe', 'pipe'] };
186
+ if (isWin) spawnOpts.windowsHide = true;
187
+ _pythonProc = spawn(pythonCmd, [pythonScript], spawnOpts);
188
+
189
+ _pythonProc.stderr.on('data', (chunk) => {
190
+ const s = chunk.toString('utf8').trim();
191
+ if (s) console.error('[input][python stderr]', s);
192
+ });
193
+
194
+ // Parse stdout from the Python sidecar as JSON lines.
195
+ // The sidecar emits structured { "type": "...", "message": "..." } payloads
196
+ // so we can detect ViGEmBus driver failures and surface them to the Electron UI.
197
+ let _stdoutBuf = '';
198
+ _pythonProc.stdout.on('data', (chunk) => {
199
+ _stdoutBuf += chunk.toString('utf8');
200
+ const lines = _stdoutBuf.split('\n');
201
+ _stdoutBuf = lines.pop(); // keep incomplete last line in buffer
202
+ for (const line of lines) {
203
+ const trimmed = line.trim();
204
+ if (!trimmed) continue;
205
+ // Try JSON first
206
+ try {
207
+ const msg = JSON.parse(trimmed);
208
+ if (msg.type === 'error') {
209
+ console.error(`[input][python] ${msg.message}`);
210
+ events.emit('input-error', { message: msg.message, code: msg.code || 'PYTHON_ERROR' });
211
+ } else if (msg.type === 'ready') {
212
+ console.log('[input][python] Sidecar ready:', msg.message || '');
213
+ events.emit('input-ready', { message: msg.message });
214
+ } else if (msg.type === 'udp_ready') {
215
+ _pythonUdpPort = msg.udp_port;
216
+ console.log(`[input][python] UDP listening on port ${_pythonUdpPort}`);
217
+ } else if (msg.type === 'log') {
218
+ console.log('[input][python]', msg.message);
219
+ } else if (msg.type === 'rumble') {
220
+ // Python sidecar detected an EV_FF event on the uinput device.
221
+ // Forward it through events so server.js can route it to the viewer.
222
+ events.emit('rumble', {
223
+ viewerId: msg.viewerId || '',
224
+ strong: msg.strong || 0,
225
+ weak: msg.weak || 0,
226
+ duration: msg.duration || 200,
227
+ });
228
+ }
229
+ } catch (_) {
230
+ // Plain string log — pass through
231
+ console.log('[input][python]', trimmed);
232
+ // Still try to detect ViGEmBus errors in plain text output
233
+ if (trimmed.toLowerCase().includes('vigembus') || trimmed.toLowerCase().includes('vigem')) {
234
+ events.emit('input-error', { message: trimmed, code: 'VIGEMBUS_MISSING' });
235
+ }
236
+ }
237
+ }
238
+ });
239
+
240
+ _pythonProc.on('error', e => {
241
+ console.error('[uinput] Python spawn error:', e.message);
242
+ events.emit('input-error', { message: `Python sidecar failed to start: ${e.message}`, code: 'SPAWN_ERROR' });
243
+ });
244
+ _pythonProc.on('close', (code) => {
245
+ _pythonProc = null;
246
+ console.log(`[uinput] Python sidecar exited (code ${code})`);
247
+ if (code !== 0 && code !== null) {
248
+ events.emit('input-error', { message: `Python sidecar exited with code ${code}`, code: 'SIDECAR_EXIT' });
249
+ }
250
+ });
251
+
252
+ console.log(`[input] Python sidecar started: ${pythonScript}`);
253
+ return true;
254
+ }
255
+
256
+ function _loadProfiles() {
257
+ try {
258
+ const pth = path.join(__dirname, '..', 'config', 'game_profiles.csv');
259
+ if (fs.existsSync(pth)) {
260
+ const lines = fs.readFileSync(pth, 'utf8').split('\n');
261
+ lines.forEach(line => {
262
+ const [title, ctrl, kbm, hybrid] = line.split(',').map(s => s?.trim());
263
+ if (title && ctrl) GAME_PROFILES[title.toLowerCase()] = { ctrl, kbm, hybrid: hybrid === 'true' };
264
+ });
265
+ console.log(`[input] CSV database loaded ${Object.keys(GAME_PROFILES).length} profiles.`);
266
+ }
267
+ } catch (e) { console.warn('[input] Failed to load CSV:', e.message); }
268
+
269
+ try {
270
+ const pth = path.join(__dirname, '..', 'config', 'kbm_bindings.json');
271
+ if (fs.existsSync(pth)) {
272
+ KBM_BINDINGS = JSON.parse(fs.readFileSync(pth, 'utf8'));
273
+ console.log('[input] JSON KBM fallback loaded.');
274
+ }
275
+ } catch (e) { console.warn('[input] Failed to load KBM JSON:', e.message); }
276
+ }
277
+
278
+ // ── Slot Allocator & LRU Garbage Collection ────────────────────────────────────
279
+ function _allocateSlot(viewerId, profileKey) {
280
+ if (viewerSlots.has(viewerId)) {
281
+ const s = viewerSlots.get(viewerId);
282
+ slotLastUsed.set(s, Date.now());
283
+ return s;
284
+ }
285
+
286
+ for (let i = 0; i < 16; i++) {
287
+ if (!slotViewers.has(i)) {
288
+ _claimSlot(i, viewerId, profileKey);
289
+ return i;
290
+ }
291
+ }
292
+
293
+ // SLOTS FULL: Evict oldest inactive controller
294
+ let oldestSlot = -1;
295
+ let oldestTime = Infinity;
296
+ for (let i = 0; i < 16; i++) {
297
+ const t = slotLastUsed.get(i) || 0;
298
+ if (t < oldestTime) {
299
+ oldestTime = t;
300
+ oldestSlot = i;
301
+ }
302
+ }
303
+
304
+ if (oldestSlot >= 0) {
305
+ console.warn(`[input] Slot limit reached. Auto-evicting inactive slot ${oldestSlot}`);
306
+ const owner = slotViewers.get(oldestSlot);
307
+ _freeSlot(owner);
308
+ _claimSlot(oldestSlot, viewerId, profileKey);
309
+ return oldestSlot;
310
+ }
311
+
312
+ console.error('[input] FATAL: No free gamepad slots available');
313
+ return -1;
314
+ }
315
+
316
+ function _claimSlot(slotIndex, viewerId, profileKey) {
317
+ viewerSlots.set(viewerId, slotIndex);
318
+ slotViewers.set(slotIndex, viewerId);
319
+ slotLastUsed.set(slotIndex, Date.now());
320
+
321
+ // Resolve the best profile: per-viewer override → host global default → xbox360
322
+ const resolvedKey = profileKey || viewerCtrlType.get(viewerId) || _defaultProfileKey || 'xbox360';
323
+
324
+ if (_pythonProc) {
325
+ try {
326
+ _pythonProc.stdin.write(JSON.stringify({ type: 'allocate_slot', pad_id: viewerId, slot: slotIndex, profile: resolvedKey }) + '\n');
327
+ } catch (_) {}
328
+ }
329
+
330
+ if (!_bridge) return; // Python handles its own slots
331
+
332
+ const profile = PROFILES[resolvedKey] || PROFILES.xbox360;
333
+ _alBuf[1] = slotIndex;
334
+ _alBuf.writeUInt16LE(profile.vendor, 2);
335
+ _alBuf.writeUInt16LE(profile.product, 4);
336
+ _alBuf.writeUInt16LE(profile.version, 6);
337
+ _alBuf.fill(0, 8, 104);
338
+ Buffer.from(profile.name).copy(_alBuf, 8, 0, Math.min(31, profile.name.length));
339
+ Buffer.from(viewerId).copy(_alBuf, 40, 0, Math.min(63, viewerId.length));
340
+
341
+ _bridge.submitInputPacket(_alBuf);
342
+ console.log(`[input] ALLOC slot ${slotIndex} for ${viewerId} as ${resolvedKey} (${profile.name})`);
343
+ }
344
+
345
+ function _freeSlot(viewerId) {
346
+ const slot = viewerSlots.get(viewerId);
347
+ if (slot === undefined) return;
348
+
349
+ if (_pythonProc) {
350
+ try {
351
+ _pythonProc.stdin.write(JSON.stringify({ type: 'free_slot', pad_id: viewerId, slot: slot }) + '\n');
352
+ } catch (_) {}
353
+ }
354
+
355
+ if (_bridge) {
356
+ _flBuf[1] = slot;
357
+ _bridge.submitInputPacket(_flBuf);
358
+ _frBuf[1] = slot;
359
+ _bridge.submitInputPacket(_frBuf);
360
+ }
361
+
362
+ viewerSlots.delete(viewerId);
363
+ slotViewers.delete(slot);
364
+ slotLastUsed.delete(slot);
365
+ kbmStates.delete(viewerId);
366
+ }
367
+
368
+ // ── Emulation Handlers ─────────────────────────────────────────────────────────
369
+ function _handleGamepad(msg) {
370
+ const viewerId = msg.pad_id;
371
+ if (!viewerId) return;
372
+
373
+ if (!_bridge && !_pythonProc) return;
374
+
375
+ const profileKey = viewerCtrlType.get(viewerId) || _defaultProfileKey || 'xbox360';
376
+ const slotIndex = _allocateSlot(viewerId, profileKey);
377
+ console.log(`[DEBUG GAMEPAD] Viewer ${viewerId} allocated slot ${slotIndex}`);
378
+ if (slotIndex < 0) return;
379
+
380
+ if (!_bridge) return;
381
+
382
+ // Convert JS viewer bitmask to C++ W3C_BTN format and extract dpad as hx/hy
383
+ const { cpp: cppBtns, hx, hy } = _jsBtnsToCpp(msg.buttons || 0);
384
+
385
+ // Write packet in the EXACT layout uinputBridge.cpp expects.
386
+ // axes arrive as int16 (-32767..+32767) from the normalizer — write directly.
387
+ // lt/rt arrive as 0..1 floats from the normalizer — scale to 0..255.
388
+ _gpBuf[0] = 0x01;
389
+ _gpBuf.writeInt16LE(Math.max(-32767, Math.min(32767, msg.lx || 0)), 1);
390
+ _gpBuf.writeInt16LE(Math.max(-32767, Math.min(32767, msg.ly || 0)), 3);
391
+ _gpBuf.writeInt16LE(Math.max(-32767, Math.min(32767, msg.rx || 0)), 5);
392
+ _gpBuf.writeInt16LE(Math.max(-32767, Math.min(32767, msg.ry || 0)), 7);
393
+ _gpBuf[9] = Math.round(Math.max(0, Math.min(1, msg.lt || 0)) * 255);
394
+ _gpBuf[10] = Math.round(Math.max(0, Math.min(1, msg.rt || 0)) * 255);
395
+ _gpBuf.writeUInt16LE(cppBtns, 11);
396
+ _gpBuf.writeInt8(hx, 13);
397
+ _gpBuf.writeInt8(hy, 14);
398
+ _gpBuf[15] = slotIndex;
399
+
400
+ _bridge.submitInputPacket(_gpBuf);
401
+ events.emit('input-packet', {
402
+ source: 'gamepad', viewerId, slotIndex,
403
+ buttons: msg.buttons || 0,
404
+ lt: msg.lt || 0, rt: msg.rt || 0,
405
+ lx: msg.lx || 0, ly: msg.ly || 0,
406
+ rx: msg.rx || 0, ry: msg.ry || 0,
407
+ });
408
+ }
409
+
410
+ function _emitKbmBinding(padId, key, isDown, binds) {
411
+ const isFlat = typeof Object.values(binds)[0] === 'string';
412
+ const slotIdx = viewerSlots.get(padId);
413
+ if (slotIdx === undefined) return;
414
+
415
+ // Grab or initialize the persistent state for this KBM user
416
+ let state = kbmStates.get(padId);
417
+ if (!state) {
418
+ state = { buttons: 0, lx: 0, ly: 0, rx: 0, ry: 0, hx: 0, hy: 0, lt: 0, rt: 0 };
419
+ kbmStates.set(padId, state);
420
+ }
421
+
422
+ if (isFlat) {
423
+ const target = binds[key];
424
+ if (!target) return;
425
+
426
+ const aliasMap = { BTN_SOUTH: 'BTN_A', BTN_EAST: 'BTN_B', BTN_NORTH: 'BTN_X', BTN_WEST: 'BTN_Y' };
427
+ const resolved = aliasMap[target] || target;
428
+
429
+ if (resolved.startsWith('BTN_')) {
430
+ // Map Linux BTN_ names to the JS KBM_BTN_MAP bit positions
431
+ const btnBit = {
432
+ BTN_A: 0x0001, BTN_B: 0x0002, BTN_X: 0x0004, BTN_Y: 0x0008,
433
+ BTN_TL: 0x0100, BTN_TR: 0x0200,
434
+ BTN_SELECT: 0x2000, BTN_START: 0x1000,
435
+ BTN_THUMBL: 0x0400, BTN_THUMBR: 0x0800,
436
+ BTN_MODE: 0x4000,
437
+ }[resolved];
438
+
439
+ if (btnBit !== undefined) {
440
+ if (isDown) state.buttons |= btnBit;
441
+ else state.buttons &= ~btnBit;
442
+ }
443
+ } else if (resolved.startsWith('ABS_')) {
444
+ // Apply axis values
445
+ if (resolved === 'ABS_Y_UP') state.ly = isDown ? -32767 : 0;
446
+ if (resolved === 'ABS_Y_DOWN') state.ly = isDown ? 32767 : 0;
447
+ if (resolved === 'ABS_X_LEFT') state.lx = isDown ? -32767 : 0;
448
+ if (resolved === 'ABS_X_RIGHT') state.lx = isDown ? 32767 : 0;
449
+ }
450
+ } else {
451
+ // Nested JSON logic
452
+ const btnTarget = binds.buttons?.[key];
453
+ if (btnTarget) {
454
+ const btnBit = { BTN_A: 0x0001, BTN_B: 0x0002, BTN_X: 0x0004, BTN_Y: 0x0008 }[btnTarget];
455
+ if (btnBit !== undefined) {
456
+ if (isDown) state.buttons |= btnBit;
457
+ else state.buttons &= ~btnBit;
458
+ }
459
+ }
460
+ for (const section of ['left_stick', 'dpad']) {
461
+ const m = binds[section]?.[key];
462
+ if (m) {
463
+ if (m.axis === 'ABS_X') state.lx = isDown ? m.val : 0;
464
+ if (m.axis === 'ABS_Y') state.ly = isDown ? m.val : 0;
465
+ }
466
+ }
467
+ }
468
+
469
+ // Now send the FULL PERSISTENT STATE to the C++ module using correct buffer layout
470
+ _gpBuf.fill(0, 0, 16);
471
+ _gpBuf[0] = 0x01; // PKT::GAMEPAD
472
+ // state.buttons uses the JS KBM_BTN_MAP format — convert before writing
473
+ const { cpp: cppBtns, hx: kbmHx, hy: kbmHy } = _jsBtnsToCpp(state.buttons);
474
+ // Axes in _emitKbmBinding are already in -32767..+32767 integer range
475
+ _gpBuf.writeInt16LE(state.lx || 0, 1);
476
+ _gpBuf.writeInt16LE(state.ly || 0, 3);
477
+ _gpBuf.writeInt16LE(state.rx || 0, 5);
478
+ _gpBuf.writeInt16LE(state.ry || 0, 7);
479
+ _gpBuf[9] = Math.round((state.lt || 0) * 255);
480
+ _gpBuf[10] = Math.round((state.rt || 0) * 255);
481
+ _gpBuf.writeUInt16LE(cppBtns, 11);
482
+ _gpBuf.writeInt8(kbmHx, 13);
483
+ _gpBuf.writeInt8(kbmHy, 14);
484
+ _gpBuf[15] = slotIdx;
485
+
486
+ _bridge.submitInputPacket(_gpBuf);
487
+ }
488
+
489
+ function _handleKbm(msg) {
490
+ const viewerId = msg.pad_id || (msg.viewerId + '_0');
491
+ if (!viewerId) return;
492
+
493
+ const profileKey = viewerCtrlType.get(viewerId) || _defaultProfileKey || 'xbox360';
494
+ const slotIndex = _allocateSlot(viewerId, profileKey);
495
+ if (slotIndex < 0) {
496
+ console.log(`[DEBUG KBM] _handleKbm dropped due to slotIndex < 0`);
497
+ return;
498
+ }
499
+
500
+ let state = kbmStates.get(viewerId);
501
+ if (!state) {
502
+ state = { buttons: 0, lt: 0, rt: 0, lx: 0, ly: 0, rx: 0, ry: 0, keys: {} };
503
+ kbmStates.set(viewerId, state);
504
+ }
505
+
506
+ // --- THE FIX: Built-in default layout matching viewer.js 'KEY_' prefix ---
507
+ const defaultKeys = {
508
+ 'KEY_W': 'LS_UP', 'KEY_A': 'LS_LEFT', 'KEY_S': 'LS_DOWN', 'KEY_D': 'LS_RIGHT',
509
+ 'KEY_SPACE': 'A', 'KEY_LEFTSHIFT': 'L3', 'KEY_LEFTCTRL': 'B', 'KEY_ESC': 'START', 'KEY_TAB': 'SELECT',
510
+ 'KEY_E': 'X', 'KEY_R': 'Y', 'KEY_F': 'LB', 'KEY_G': 'RB', 'KEY_C': 'R3',
511
+ 'KEY_UP': 'UP', 'KEY_DOWN': 'DOWN', 'KEY_LEFT': 'LEFT', 'KEY_RIGHT': 'RIGHT',
512
+ 'BTN_LEFT': 'RT', 'BTN_RIGHT': 'LT', 'BTN_MIDDLE': 'RB'
513
+ };
514
+
515
+ let flatKeys = { ...defaultKeys };
516
+ if (typeof KBM_BINDINGS !== 'undefined' && KBM_BINDINGS) {
517
+ if (KBM_BINDINGS.buttons) {
518
+ for (const [k, v] of Object.entries(KBM_BINDINGS.buttons)) {
519
+ if (!v) continue;
520
+ const normalized = String(v).trim().toUpperCase();
521
+ flatKeys[k] = BUTTON_ALIASES[normalized] || normalized.replace(/^BTN_/, '');
522
+ }
523
+ }
524
+ if (KBM_BINDINGS.left_stick) {
525
+ for (const [k, v] of Object.entries(KBM_BINDINGS.left_stick)) {
526
+ if (v.axis === 'ABS_Y') flatKeys[k] = v.val < 0 ? 'LS_UP' : 'LS_DOWN';
527
+ if (v.axis === 'ABS_X') flatKeys[k] = v.val < 0 ? 'LS_LEFT' : 'LS_RIGHT';
528
+ }
529
+ }
530
+ if (KBM_BINDINGS.right_stick) {
531
+ for (const [k, v] of Object.entries(KBM_BINDINGS.right_stick)) {
532
+ if (v.axis === 'ABS_RY') flatKeys[k] = v.val < 0 ? 'RS_UP' : 'RS_DOWN';
533
+ if (v.axis === 'ABS_RX') flatKeys[k] = v.val < 0 ? 'RS_LEFT' : 'RS_RIGHT';
534
+ }
535
+ }
536
+ if (KBM_BINDINGS.dpad) {
537
+ for (const [k, v] of Object.entries(KBM_BINDINGS.dpad)) {
538
+ if (v.axis === 'ABS_HAT0Y') flatKeys[k] = v.val < 0 ? 'UP' : 'DOWN';
539
+ if (v.axis === 'ABS_HAT0X') flatKeys[k] = v.val < 0 ? 'LEFT' : 'RIGHT';
540
+ }
541
+ }
542
+ if (KBM_BINDINGS.triggers) {
543
+ for (const [k, v] of Object.entries(KBM_BINDINGS.triggers)) {
544
+ flatKeys[k] = String(v).toUpperCase();
545
+ }
546
+ }
547
+ }
548
+ const layout = { keys: flatKeys, mouse: { sensitivity: KBM_BINDINGS?.right_stick_multiplier ? KBM_BINDINGS.right_stick_multiplier / 1000 : 1.5, deadzone: 0.1 } };
549
+
550
+ if (msg.event === 'keydown' || msg.event === 'keyup') {
551
+ // Try the loaded layout first, fallback to the hardcoded default
552
+ const action = layout.keys[msg.key] || defaultKeys[msg.key];
553
+
554
+ if (!action) {
555
+ console.log(`[DEBUG KBM] _handleKbm dropped because ${msg.key} has no mapping action!`);
556
+ return;
557
+ }
558
+
559
+ const isDown = (msg.event === 'keydown');
560
+
561
+ // FIX: Ignore OS key-repeats. If it's already held down,
562
+ // do not force a release or spam the bridge. Just let the game read the continuous hold.
563
+ if (state.keys[action] === isDown) {
564
+ return;
565
+ }
566
+
567
+ state.keys[action] = isDown;
568
+
569
+ if (KBM_BTN_MAP[action]) {
570
+ if (isDown) state.buttons |= KBM_BTN_MAP[action];
571
+ else state.buttons &= ~KBM_BTN_MAP[action];
572
+ } else if (action === 'LT') {
573
+ state.lt = isDown ? 1.0 : 0.0;
574
+ } else if (action === 'RT') {
575
+ state.rt = isDown ? 1.0 : 0.0;
576
+ } else if (action.startsWith('LS_')) {
577
+ // Revert back to -1.0 to 1.0 float range, because _sendKbmStateToBuffer multiplies by 32767
578
+ state.lx = (state.keys['LS_RIGHT'] ? 1.0 : 0) - (state.keys['LS_LEFT'] ? 1.0 : 0);
579
+ state.ly = (state.keys['LS_DOWN'] ? 1.0 : 0) - (state.keys['LS_UP'] ? 1.0 : 0);
580
+ } else if (action.startsWith('RS_')) {
581
+ state.rx = (state.keys['RS_RIGHT'] ? 1.0 : 0) - (state.keys['RS_LEFT'] ? 1.0 : 0);
582
+ state.ry = (state.keys['RS_DOWN'] ? 1.0 : 0) - (state.keys['RS_UP'] ? 1.0 : 0);
583
+ }
584
+ }
585
+ else if (msg.event === 'mousemove') {
586
+ const mult = KBM_BINDINGS?.right_stick_multiplier || 1500;
587
+ const deadzone = 0.1; // 10% deadzone
588
+
589
+ // Python matched formula: (dx * mult) / 32767.0 to fit into the -1.0 to 1.0 float range expected by _sendKbmStateToBuffer
590
+ let dx = (msg.dx * mult) / 32767.0;
591
+ let dy = (msg.dy * mult) / 32767.0;
592
+
593
+ dx = Math.max(-1.0, Math.min(1.0, dx));
594
+ dy = Math.max(-1.0, Math.min(1.0, dy));
595
+
596
+ if (Math.abs(dx) < deadzone) dx = 0;
597
+ if (Math.abs(dy) < deadzone) dy = 0;
598
+
599
+ state.rx = dx;
600
+ state.ry = dy;
601
+
602
+ if (state.resetTimer) clearTimeout(state.resetTimer);
603
+ state.resetTimer = setTimeout(() => {
604
+ state.rx = 0;
605
+ state.ry = 0;
606
+ if (typeof _sendKbmStateToBuffer === 'function') _sendKbmStateToBuffer(slotIndex, state);
607
+ }, 32); // 32ms matches the old Python timeout
608
+ }
609
+
610
+ if (typeof _sendKbmStateToBuffer === 'function') {
611
+ console.log(`[DEBUG KBM] calling _sendKbmStateToBuffer: lx=${state.lx}, ly=${state.ly}, buttons=${state.buttons}`);
612
+ _sendKbmStateToBuffer(slotIndex, state);
613
+ }
614
+ }
615
+
616
+ // Ensure this helper function exists right below _handleKbm
617
+ function _sendKbmStateToBuffer(slotIndex, state) {
618
+ if (!_bridge) return;
619
+
620
+ // state.buttons uses JS KBM_BTN_MAP format — convert to C++ W3C_BTN and extract dpad
621
+ const { cpp: cppBtns, hx, hy } = _jsBtnsToCpp(state.buttons);
622
+
623
+ _gpBuf[0] = 0x01;
624
+ // KBM lx/ly/rx/ry are -1..1 floats; scale to int16 range for C++
625
+ _gpBuf.writeInt16LE(Math.round((state.lx || 0) * 32767), 1);
626
+ _gpBuf.writeInt16LE(Math.round((state.ly || 0) * 32767), 3);
627
+ _gpBuf.writeInt16LE(Math.round((state.rx || 0) * 32767), 5);
628
+ _gpBuf.writeInt16LE(Math.round((state.ry || 0) * 32767), 7);
629
+ _gpBuf[9] = Math.round((state.lt || 0) * 255);
630
+ _gpBuf[10] = Math.round((state.rt || 0) * 255);
631
+ _gpBuf.writeUInt16LE(cppBtns, 11);
632
+ _gpBuf.writeInt8(hx, 13);
633
+ _gpBuf.writeInt8(hy, 14);
634
+ _gpBuf[15] = slotIndex;
635
+
636
+ _bridge.submitInputPacket(_gpBuf);
637
+ events.emit('input-packet', {
638
+ source: 'kbm', slotIndex,
639
+ buttons: state.buttons,
640
+ lt: state.lt, rt: state.rt,
641
+ lx: state.lx, ly: state.ly,
642
+ rx: state.rx, ry: state.ry,
643
+ });
644
+ }
645
+
646
+ // ── Dispatcher & Exports ──────────────────────────────────────────────────────
647
+ // ── Payload Schema Validation ─────────────────────────────────────────────────
648
+ // Malicious or corrupted viewer payloads can crash the C++ bridge or overflow
649
+ // integer buffers. Validate and clamp every field before it reaches the backend.
650
+ const MAX_PAYLOAD_BYTES = 4096;
651
+
652
+ function _clampAxis(val) {
653
+ // Axes: -32767..+32767 as integers (already scaled by viewer.js)
654
+ return Math.max(-32767, Math.min(32767, Number(val) || 0));
655
+ }
656
+ function _clampTrigger(val) {
657
+ // Triggers: 0..1 float range
658
+ return Math.max(0, Math.min(1, Number(val) || 0));
659
+ }
660
+ function _clampButtons(val) {
661
+ // 16-bit bitmask. Strip 0x4000 (GUIDE/HOME button) so viewers cannot open system menus.
662
+ const n = Number(val) || 0;
663
+ return (n & 0xBFFF) >>> 0;
664
+ }
665
+ function _clampDelta(val) {
666
+ // Mouse delta: sane screen range
667
+ return Math.max(-4096, Math.min(4096, Number(val) || 0));
668
+ }
669
+
670
+ function _validateGamepadMsg(msg) {
671
+ // Size guard: reject absurdly large objects
672
+ try {
673
+ if (JSON.stringify(msg).length > MAX_PAYLOAD_BYTES) return null;
674
+ } catch (_) { return null; }
675
+
676
+ return {
677
+ type: 'gamepad',
678
+ pad_id: String(msg.pad_id || msg.viewerId || '').slice(0, 64),
679
+ viewer_id: String(msg.viewer_id || msg.viewerId || '').slice(0, 64),
680
+ viewerId: String(msg.viewerId || '').slice(0, 64),
681
+ buttons: _clampButtons(msg.buttons),
682
+ lt: _clampTrigger(msg.lt),
683
+ rt: _clampTrigger(msg.rt),
684
+ lx: _clampAxis(msg.lx),
685
+ ly: _clampAxis(msg.ly),
686
+ rx: _clampAxis(msg.rx),
687
+ ry: _clampAxis(msg.ry),
688
+ axes: Array.isArray(msg.axes) ? msg.axes.map(_clampAxis) : [],
689
+ btns: Array.isArray(msg.btns) ? msg.btns.map(b => ({ pressed: !!b.pressed, value: Math.max(0, Math.min(255, Number(b.value) || 0)) })) : []
690
+ };
691
+ }
692
+
693
+ function _validateKbmMsg(msg) {
694
+ try {
695
+ if (JSON.stringify(msg).length > MAX_PAYLOAD_BYTES) return null;
696
+ } catch (_) { return null; }
697
+
698
+ const event = String(msg.event || '').slice(0, 32);
699
+ if (!['keydown', 'keyup', 'mousemove', 'mousedown', 'mouseup'].includes(event)) return null;
700
+
701
+ return {
702
+ type: msg.type,
703
+ pad_id: String(msg.pad_id || msg.viewerId || '').slice(0, 64),
704
+ viewerId: String(msg.viewerId || '').slice(0, 64),
705
+ event,
706
+ key: String(msg.key || '').slice(0, 32),
707
+ button: typeof msg.button === 'number' ? msg.button : undefined,
708
+ dx: _clampDelta(msg.dx),
709
+ dy: _clampDelta(msg.dy),
710
+ };
711
+ }
712
+
713
+ function send(msg) {
714
+
715
+ // ── Schema validation — drop malformed or oversized payloads silently ──────
716
+ let validated = msg;
717
+ if (msg.type === 'gamepad') {
718
+ validated = _validateGamepadMsg(msg);
719
+ if (!validated) {
720
+ console.warn(`[DEBUG GP] DROPPED by validator — pad_id="${msg.pad_id}" lx=${msg.lx} btns=${msg.buttons}`);
721
+ return; // drop
722
+ }
723
+ // Diagnostics: print bridge/python state so we know which path runs
724
+ console.log(`[DEBUG GP] pad_id="${validated.pad_id}" bridge=${!!_bridge} python=${!!_pythonProc} hybrid=${_hybridInputEnabled}`);
725
+ } else if (msg.type === 'kbm' || msg.type === 'keyboard') {
726
+ validated = _validateKbmMsg(msg);
727
+ if (!validated) return; // drop
728
+ }
729
+
730
+ // Drop immediately if both backends are dead
731
+ if (!_bridge && !_pythonProc) return;
732
+
733
+ // Fallback passthrough to Python if Native module failed, OR if Hybrid Mode is explicitly enabled
734
+ if ((!_bridge || _hybridInputEnabled) && _pythonProc && _pythonProc.stdin.writable) {
735
+ try { _pythonProc.stdin.write(JSON.stringify(validated) + '\n'); } catch (e) { }
736
+
737
+ // Ensure the input visualizer still works when using Python sidecar
738
+ if (validated.type === 'gamepad') {
739
+ events.emit('input-packet', {
740
+ source: 'python_gamepad', viewerId: validated.viewer_id || validated.viewerId, slotIndex: 'PY',
741
+ buttons: validated.buttons || 0,
742
+ lt: validated.lt || 0, rt: validated.rt || 0,
743
+ lx: validated.lx || 0, ly: validated.ly || 0,
744
+ rx: validated.rx || 0, ry: validated.ry || 0,
745
+ });
746
+ }
747
+ return;
748
+ }
749
+
750
+ if (validated.type === 'gamepad') {
751
+ _handleGamepad(validated);
752
+ } else if (validated.type === 'kbm' || validated.type === 'keyboard') {
753
+ console.log(`[DEBUG KBM] Orchestrator send() routing to _handleKbm`);
754
+ _handleKbm(validated);
755
+ } else if (msg.type === 'set-ctrl-type') {
756
+ // Update per-viewer map AND the global default so new connections inherit the type
757
+ if (msg.viewerId) viewerCtrlType.set(msg.viewerId, msg.ctrlType || 'xbox360');
758
+ _defaultProfileKey = msg.ctrlType || 'xbox360';
759
+ } else if (msg.type === 'ctrl-settings-hybrid') {
760
+ _hybridInputEnabled = !!msg.enabled;
761
+ console.log(`[input] Hybrid mode ${msg.enabled ? 'ENABLED: Routing via Python' : 'DISABLED: Restoring C++ bridge'}`);
762
+ } else if (msg.type === 'set-input-mode') {
763
+ viewerModes.set(msg.viewerId, msg.mode);
764
+ } else if (msg.type === 'disconnect_viewer') {
765
+ _freeSlot(msg.viewer_id);
766
+ } else if (msg.type === 'flush_neutral') {
767
+ const slot = viewerSlots.get(msg.viewer_id);
768
+ if (slot !== undefined && _bridge) {
769
+ _flBuf[1] = slot;
770
+ _bridge.submitInputPacket(_flBuf);
771
+ }
772
+ } else if (msg.type === 'destroy_all') {
773
+ destroy();
774
+ }
775
+ }
776
+
777
+ function sendBinary(viewerId, buf) {
778
+ if (buf[0] !== 0x01) return; // Only GAMEPAD for now
779
+
780
+ const padIndex = buf[1];
781
+ const padId = viewerId + '_' + padIndex;
782
+ const profileKey = viewerCtrlType.get(padId) || viewerCtrlType.get(viewerId) || _defaultProfileKey || 'xbox360';
783
+ const slotIndex = _allocateSlot(padId, profileKey);
784
+ if (slotIndex < 0) return;
785
+
786
+ const jsBtns = buf.readUInt16LE(2);
787
+ const { cpp: cppBtns, hx, hy } = _jsBtnsToCpp(jsBtns);
788
+
789
+ _gpBuf[0] = 0x01;
790
+ buf.copy(_gpBuf, 1, 4, 12);
791
+ _gpBuf[9] = buf[12];
792
+ _gpBuf[10] = buf[13];
793
+ _gpBuf.writeUInt16LE(cppBtns, 11);
794
+ _gpBuf.writeInt8(hx, 13);
795
+ _gpBuf.writeInt8(hy, 14);
796
+ _gpBuf[15] = slotIndex;
797
+
798
+ if (_bridge && !_hybridInputEnabled) {
799
+ _bridge.submitInputPacket(_gpBuf);
800
+ return;
801
+ }
802
+
803
+ if (_pythonUdpPort > 0 && _udpSocket) {
804
+ _udpSocket.send(_gpBuf, 0, 16, _pythonUdpPort, '127.0.0.1');
805
+ }
806
+ }
807
+
808
+ function destroy() {
809
+ for (const vid of viewerSlots.keys()) {
810
+ _freeSlot(vid);
811
+ }
812
+ if (_bridge && _bridge.destroyDevice) {
813
+ _bridge.destroyDevice();
814
+ }
815
+ if (_pythonProc) {
816
+ if (_pythonProc.stdin?.writable) {
817
+ _pythonProc.stdin.write(JSON.stringify({ type: 'destroy_all' }) + '\n');
818
+ }
819
+ _pythonProc.kill();
820
+ _pythonProc = null;
821
+ }
822
+ console.log("[input] Orchestrator destroyed.");
823
+ }
824
+
825
+ function getViewerForSlot(slot) {
826
+ return slotViewers.get(Number(slot)) || null;
827
+ }
828
+
829
+ module.exports = { init, send, sendBinary, destroy, events, getViewerForSlot, setHidMaestroEnabled, get _bridge() { return _bridge; } };