@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,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; } };
|