@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,18 @@
1
+ {
2
+ "targets": [
3
+ {
4
+ "target_name": "uinputBridge",
5
+ "sources": [ "uinputBridge.cpp" ],
6
+ "include_dirs": [
7
+ "<!@(node -p \"require('node-addon-api').include\")"
8
+ ],
9
+ "dependencies": [
10
+ "<!(node -p \"require('node-addon-api').gyp\")"
11
+ ],
12
+ "cflags!": [ "-fno-exceptions" ],
13
+ "cflags_cc!": [ "-fno-exceptions" ],
14
+ "cflags_cc": [ "-std=c++17", "-O3" ],
15
+ "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,613 @@
1
+ #include <napi.h>
2
+ #include <linux/uinput.h>
3
+ #include <linux/input.h>
4
+ #include <fcntl.h>
5
+ #include <unistd.h>
6
+ #include <string.h>
7
+ #include <iostream>
8
+ #include <map>
9
+ #include <thread>
10
+ #include <mutex>
11
+ #include <functional>
12
+ #include <dirent.h>
13
+ #include <fstream>
14
+ #include <string>
15
+
16
+ // ── Packet type constants ──────────────────────────────────────────────────────
17
+ namespace PKT {
18
+ enum {
19
+ GAMEPAD = 0x01, MOUSE_REL = 0x02, MOUSE_ABS = 0x03, MOUSE_BTN = 0x04,
20
+ WHEEL = 0x05, KEY = 0x06, ALLOC_GP = 0x10, FREE_GP = 0x11,
21
+ FLUSH = 0x20, DESTROY = 0xFF
22
+ };
23
+ }
24
+
25
+ // ── W3C button bitmask (matches JS viewer and InputOrchestrator) ───────────────
26
+ enum W3C_BTN {
27
+ A = 1<<0, B = 1<<1, Y = 1<<2, X = 1<<3, LB = 1<<4, RB = 1<<5,
28
+ BACK = 1<<8, START = 1<<9, LS = 1<<10, RS = 1<<11, GUIDE = 1<<16
29
+ };
30
+
31
+ // ── Axis fuzz/flat matching linux_uinput.py AXES tuples ───────────────────────
32
+ // Sticks: (-32767, 32767, fuzz=16, flat=128)
33
+ // Triggers: (0, 255, fuzz=0, flat=0)
34
+ // Hat: (-1, 1, fuzz=0, flat=0)
35
+ static void set_abs(struct uinput_user_dev& uud, int axis,
36
+ int32_t mn, int32_t mx, int32_t fuzz, int32_t flat) {
37
+ uud.absmin [axis] = mn;
38
+ uud.absmax [axis] = mx;
39
+ uud.absfuzz[axis] = fuzz;
40
+ uud.absflat[axis] = flat;
41
+ }
42
+
43
+ // ── Global file descriptors ────────────────────────────────────────────────────
44
+ int kbm_fd = -1;
45
+ std::map<uint8_t, int> gp_fds; // slot → uinput write fd
46
+ std::map<uint8_t, std::string> gp_names; // slot → device name (for sysfs lookup)
47
+
48
+ // ── Rumble tracking ────────────────────────────────────────────────────────────
49
+ // slot → eventX read fd; g_padViewers: slot-string → viewerId string
50
+ static std::map<uint8_t, int> g_rumbleFds;
51
+ static std::map<std::string, std::string> g_padViewers;
52
+ static std::mutex g_rumbleMtx;
53
+
54
+ // JS-side callback invoked from the rumble watcher thread (via threadsafe function)
55
+ using RumbleTSFN = Napi::ThreadSafeFunction;
56
+ static RumbleTSFN g_rumbleTsfn;
57
+ static bool g_tsfnValid = false;
58
+
59
+ // ── Helper: write one kernel input_event ──────────────────────────────────────
60
+ void emit(int fd, uint16_t type, uint16_t code, int32_t val) {
61
+ if (fd < 0) return;
62
+ struct input_event ie = {};
63
+ ie.type = type;
64
+ ie.code = code;
65
+ ie.value = val;
66
+ if (write(fd, &ie, sizeof(ie)) < 0) { /* EAGAIN or device closed – drop */ }
67
+ }
68
+ void syn(int fd) { emit(fd, EV_SYN, SYN_REPORT, 0); }
69
+
70
+ // ── Rumble: find /dev/input/eventX by device name via sysfs ──────────────────
71
+ static std::string find_event_node(const std::string& dev_name) {
72
+ const char* sys_input = "/sys/class/input";
73
+ DIR* d = opendir(sys_input);
74
+ if (!d) return "";
75
+ struct dirent* ent;
76
+ while ((ent = readdir(d)) != nullptr) {
77
+ std::string entry(ent->d_name);
78
+ if (entry.rfind("event", 0) != 0) continue;
79
+ std::string name_path = std::string(sys_input) + "/" + entry + "/device/name";
80
+ std::ifstream f(name_path);
81
+ if (!f.is_open()) continue;
82
+ std::string name;
83
+ std::getline(f, name);
84
+ if (name == dev_name) {
85
+ closedir(d);
86
+ return std::string("/dev/input/") + entry;
87
+ }
88
+ }
89
+ closedir(d);
90
+ return "";
91
+ }
92
+
93
+ // ── Rumble watcher thread ─────────────────────────────────────────────────────
94
+ // Reads EV_FF events from the eventX node and fires g_rumbleTsfn back to JS.
95
+ // Format emitted to JS callback: { pad_id, viewerId, strong, weak, duration }
96
+ static void rumble_watcher_thread(uint8_t slot, int fd, std::string viewerId) {
97
+ // Size of one linux input_event: timeval(8 or 16 bytes) + type(2) + code(2) + value(4)
98
+ // Use the kernel's own sizeof to be safe with 32/64-bit time_t variants
99
+ const size_t EV_SIZE = sizeof(struct input_event);
100
+ uint8_t buf[sizeof(struct input_event)];
101
+
102
+ while (true) {
103
+ // Check if this watcher is still current
104
+ {
105
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
106
+ auto it = g_rumbleFds.find(slot);
107
+ if (it == g_rumbleFds.end() || it->second != fd) break;
108
+ }
109
+
110
+ // Block up to 1s waiting for data (select so we can recheck the guard above)
111
+ fd_set rfds;
112
+ FD_ZERO(&rfds);
113
+ FD_SET(fd, &rfds);
114
+ struct timeval tv = { 1, 0 };
115
+ int r = select(fd + 1, &rfds, nullptr, nullptr, &tv);
116
+ if (r <= 0) continue;
117
+
118
+ ssize_t n = read(fd, buf, EV_SIZE);
119
+ if (n < (ssize_t)EV_SIZE) continue;
120
+
121
+ struct input_event ie;
122
+ memcpy(&ie, buf, sizeof(ie));
123
+
124
+ // EV_FF (0x15) with type FF_RUMBLE (0x50)
125
+ if (ie.type != 0x15 || ie.code != 0x50) continue;
126
+
127
+ float strong, weak;
128
+ int duration;
129
+ if (ie.value > 0) {
130
+ strong = std::min(1.0f, ie.value / 65535.0f);
131
+ weak = strong * 0.6f;
132
+ duration = 200;
133
+ } else {
134
+ strong = weak = 0.0f;
135
+ duration = 0;
136
+ }
137
+
138
+ if (!g_tsfnValid) continue;
139
+
140
+ // Pack into heap struct so the lambda captures it safely
141
+ struct RumbleData {
142
+ uint8_t slot; std::string viewerId;
143
+ float strong, weak; int duration;
144
+ };
145
+ auto* rd = new RumbleData{ slot, viewerId, strong, weak, duration };
146
+
147
+ g_rumbleTsfn.NonBlockingCall(rd, [](Napi::Env env, Napi::Function cb, RumbleData* rd) {
148
+ Napi::Object obj = Napi::Object::New(env);
149
+ obj.Set("slot", Napi::Number::New(env, rd->slot));
150
+ obj.Set("viewerId", Napi::String::New(env, rd->viewerId));
151
+ obj.Set("strong", Napi::Number::New(env, rd->strong));
152
+ obj.Set("weak", Napi::Number::New(env, rd->weak));
153
+ obj.Set("duration", Napi::Number::New(env, rd->duration));
154
+ cb.Call({ obj });
155
+ delete rd;
156
+ });
157
+ }
158
+ close(fd);
159
+ }
160
+
161
+ // ── Attach rumble watcher for a newly created gamepad ─────────────────────────
162
+ static void attach_rumble_watcher(uint8_t slot, const std::string& dev_name, const std::string& viewerId) {
163
+ // Give kernel 300ms to register the sysfs entry (same as Python side)
164
+ std::thread([slot, dev_name, viewerId]() {
165
+ usleep(300000);
166
+ std::string node = find_event_node(dev_name);
167
+ if (node.empty()) return;
168
+
169
+ int fd = open(node.c_str(), O_RDONLY | O_NONBLOCK);
170
+ if (fd < 0) return;
171
+
172
+ {
173
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
174
+ auto old = g_rumbleFds.find(slot);
175
+ if (old != g_rumbleFds.end()) { close(old->second); }
176
+ g_rumbleFds[slot] = fd;
177
+ g_padViewers[std::to_string(slot)] = viewerId;
178
+ }
179
+ rumble_watcher_thread(slot, fd, viewerId);
180
+ }).detach();
181
+ }
182
+
183
+ // ── N-API: register JS rumble callback ────────────────────────────────────────
184
+ Napi::Value SetRumbleCallback(const Napi::CallbackInfo& info) {
185
+ if (info.Length() < 1 || !info[0].IsFunction()) return info.Env().Undefined();
186
+ if (g_tsfnValid) { g_rumbleTsfn.Release(); g_tsfnValid = false; }
187
+ g_rumbleTsfn = RumbleTSFN::New(
188
+ info.Env(), info[0].As<Napi::Function>(), "rumbleCallback", 0, 1
189
+ );
190
+ g_tsfnValid = true;
191
+ return info.Env().Undefined();
192
+ }
193
+
194
+ // ── N-API: init mouse/keyboard device ─────────────────────────────────────────
195
+ Napi::Boolean InitializeDevice(const Napi::CallbackInfo& info) {
196
+ Napi::Env env = info.Env();
197
+ int screenW = info.Length() > 0 ? info[0].As<Napi::Number>().Int32Value() : 1920;
198
+ int screenH = info.Length() > 1 ? info[1].As<Napi::Number>().Int32Value() : 1080;
199
+
200
+ kbm_fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK);
201
+ if (kbm_fd < 0) return Napi::Boolean::New(env, false);
202
+
203
+ // EV_SYN must be registered explicitly on some kernel versions
204
+ ioctl(kbm_fd, UI_SET_EVBIT, EV_SYN);
205
+
206
+ // Keyboard
207
+ ioctl(kbm_fd, UI_SET_EVBIT, EV_KEY);
208
+ for (int i = 1; i < 255; i++) ioctl(kbm_fd, UI_SET_KEYBIT, i);
209
+
210
+ // Mouse buttons
211
+ ioctl(kbm_fd, UI_SET_KEYBIT, BTN_LEFT);
212
+ ioctl(kbm_fd, UI_SET_KEYBIT, BTN_RIGHT);
213
+ ioctl(kbm_fd, UI_SET_KEYBIT, BTN_MIDDLE);
214
+
215
+ // Relative axes (mouse movement + scroll)
216
+ ioctl(kbm_fd, UI_SET_EVBIT, EV_REL);
217
+ ioctl(kbm_fd, UI_SET_RELBIT, REL_X);
218
+ ioctl(kbm_fd, UI_SET_RELBIT, REL_Y);
219
+ ioctl(kbm_fd, UI_SET_RELBIT, REL_WHEEL);
220
+ ioctl(kbm_fd, UI_SET_RELBIT, REL_HWHEEL);
221
+
222
+ // Absolute axes removed from KBM to prevent SDL2 misidentifying it as a Gamepad
223
+
224
+ struct uinput_user_dev uud = {};
225
+ snprintf(uud.name, UINPUT_MAX_NAME_SIZE, "Virtual Gamepad KBM");
226
+ uud.id.bustype = BUS_USB;
227
+ uud.id.vendor = 0x1234;
228
+ uud.id.product = 0x5678;
229
+ uud.id.version = 1;
230
+
231
+ if (write(kbm_fd, &uud, sizeof(uud)) < 0) {
232
+ close(kbm_fd); kbm_fd = -1;
233
+ return Napi::Boolean::New(env, false);
234
+ }
235
+ ioctl(kbm_fd, UI_DEV_CREATE);
236
+ return Napi::Boolean::New(env, true);
237
+ }
238
+
239
+ // ── N-API: fast-lane binary packet router ─────────────────────────────────────
240
+ Napi::Value SubmitInputPacket(const Napi::CallbackInfo& info) {
241
+ if (!info[0].IsBuffer()) return info.Env().Undefined();
242
+
243
+ Napi::Buffer<uint8_t> buffer = info[0].As<Napi::Buffer<uint8_t>>();
244
+ uint8_t* data = buffer.Data();
245
+ if (buffer.Length() < 1) return info.Env().Undefined();
246
+
247
+ uint8_t type = data[0];
248
+
249
+ switch (type) {
250
+
251
+ // ── GAMEPAD STATE (16 bytes) ──────────────────────────────────────────
252
+ case PKT::GAMEPAD: {
253
+ uint8_t slot = data[15];
254
+ auto it = gp_fds.find(slot);
255
+ if (it == gp_fds.end()) break;
256
+ int fd = it->second;
257
+
258
+ int16_t lx = *reinterpret_cast<int16_t* >(&data[1]);
259
+ int16_t ly = *reinterpret_cast<int16_t* >(&data[3]);
260
+ int16_t rx = *reinterpret_cast<int16_t* >(&data[5]);
261
+ int16_t ry = *reinterpret_cast<int16_t* >(&data[7]);
262
+ uint8_t lt = data[9];
263
+ uint8_t rt = data[10];
264
+ uint16_t btn = *reinterpret_cast<uint16_t*>(&data[11]);
265
+ int8_t hx = *reinterpret_cast<int8_t* >(&data[13]);
266
+ int8_t hy = *reinterpret_cast<int8_t* >(&data[14]);
267
+
268
+ emit(fd, EV_ABS, ABS_X, lx);
269
+ emit(fd, EV_ABS, ABS_Y, ly);
270
+ emit(fd, EV_ABS, ABS_RX, rx);
271
+ emit(fd, EV_ABS, ABS_RY, ry);
272
+ emit(fd, EV_ABS, ABS_Z, lt);
273
+ emit(fd, EV_ABS, ABS_RZ, rt);
274
+ emit(fd, EV_ABS, ABS_HAT0X, hx);
275
+ emit(fd, EV_ABS, ABS_HAT0Y, hy);
276
+
277
+ emit(fd, EV_KEY, BTN_SOUTH, (btn & W3C_BTN::A) ? 1 : 0);
278
+ emit(fd, EV_KEY, BTN_EAST, (btn & W3C_BTN::B) ? 1 : 0);
279
+ emit(fd, EV_KEY, BTN_WEST, (btn & W3C_BTN::Y) ? 1 : 0);
280
+ emit(fd, EV_KEY, BTN_NORTH, (btn & W3C_BTN::X) ? 1 : 0);
281
+ emit(fd, EV_KEY, BTN_TL, (btn & W3C_BTN::LB) ? 1 : 0);
282
+ emit(fd, EV_KEY, BTN_TR, (btn & W3C_BTN::RB) ? 1 : 0);
283
+ emit(fd, EV_KEY, BTN_SELECT, (btn & W3C_BTN::BACK) ? 1 : 0);
284
+ emit(fd, EV_KEY, BTN_START, (btn & W3C_BTN::START) ? 1 : 0);
285
+ emit(fd, EV_KEY, BTN_THUMBL, (btn & W3C_BTN::LS) ? 1 : 0);
286
+ emit(fd, EV_KEY, BTN_THUMBR, (btn & W3C_BTN::RS) ? 1 : 0);
287
+ emit(fd, EV_KEY, BTN_MODE, (btn & W3C_BTN::GUIDE) ? 1 : 0);
288
+
289
+ syn(fd);
290
+ break;
291
+ }
292
+
293
+ // ── MOUSE RELATIVE ────────────────────────────────────────────────────
294
+ case PKT::MOUSE_REL: {
295
+ int16_t dx = *reinterpret_cast<int16_t*>(&data[1]);
296
+ int16_t dy = *reinterpret_cast<int16_t*>(&data[3]);
297
+ emit(kbm_fd, EV_REL, REL_X, dx);
298
+ emit(kbm_fd, EV_REL, REL_Y, dy);
299
+ syn(kbm_fd);
300
+ break;
301
+ }
302
+
303
+ // ── MOUSE ABSOLUTE ────────────────────────────────────────────────────
304
+ case PKT::MOUSE_ABS: {
305
+ uint16_t nx = *reinterpret_cast<uint16_t*>(&data[1]);
306
+ uint16_t ny = *reinterpret_cast<uint16_t*>(&data[3]);
307
+ emit(kbm_fd, EV_ABS, ABS_X, nx);
308
+ emit(kbm_fd, EV_ABS, ABS_Y, ny);
309
+ syn(kbm_fd);
310
+ break;
311
+ }
312
+
313
+ // ── MOUSE BUTTONS ─────────────────────────────────────────────────────
314
+ case PKT::MOUSE_BTN: {
315
+ uint8_t btns = data[1];
316
+ uint8_t down = data[2];
317
+ if (btns & 0x01) emit(kbm_fd, EV_KEY, BTN_LEFT, down);
318
+ if (btns & 0x02) emit(kbm_fd, EV_KEY, BTN_RIGHT, down);
319
+ if (btns & 0x04) emit(kbm_fd, EV_KEY, BTN_MIDDLE, down);
320
+ syn(kbm_fd);
321
+ break;
322
+ }
323
+
324
+ // ── SCROLL WHEEL ─────────────────────────────────────────────────────
325
+ case PKT::WHEEL: {
326
+ int16_t dy = *reinterpret_cast<int16_t*>(&data[1]);
327
+ int16_t dx = *reinterpret_cast<int16_t*>(&data[3]);
328
+ emit(kbm_fd, EV_REL, REL_WHEEL, dy / 120);
329
+ emit(kbm_fd, EV_REL, REL_HWHEEL, dx / 120);
330
+ syn(kbm_fd);
331
+ break;
332
+ }
333
+
334
+ // ── KEYBOARD KEY ─────────────────────────────────────────────────────
335
+ case PKT::KEY: {
336
+ uint16_t code = *reinterpret_cast<uint16_t*>(&data[1]);
337
+ uint8_t down = data[3];
338
+ emit(kbm_fd, EV_KEY, code, down);
339
+ syn(kbm_fd);
340
+ break;
341
+ }
342
+
343
+ // ── ALLOC GAMEPAD SLOT ────────────────────────────────────────────────
344
+ // Packet layout (40 bytes):
345
+ // [0] = 0x10 (PKT::ALLOC_GP)
346
+ // [1] = slot
347
+ // [2-3] = vendor id (uint16LE)
348
+ // [4-5] = product id (uint16LE)
349
+ // [6-7] = version (uint16LE)
350
+ // [8-39] = device name (32 bytes, null-padded)
351
+ case PKT::ALLOC_GP: {
352
+ uint8_t slot = data[1];
353
+ uint16_t vid = *reinterpret_cast<uint16_t*>(&data[2]);
354
+ uint16_t pid = *reinterpret_cast<uint16_t*>(&data[4]);
355
+ uint16_t ver = *reinterpret_cast<uint16_t*>(&data[6]);
356
+
357
+ // Read device name from packet
358
+ char dev_name[33] = {};
359
+ memcpy(dev_name, &data[8], 32);
360
+ std::string dev_name_str(dev_name);
361
+
362
+ // Read optional viewerId from [40..103] if the caller wrote it
363
+ // (InputOrchestrator currently doesn't — we fall back to slot string)
364
+ std::string viewer_id = std::to_string(slot);
365
+
366
+ // Destroy old slot if reusing
367
+ auto old_it = gp_fds.find(slot);
368
+ if (old_it != gp_fds.end()) {
369
+ ioctl(old_it->second, UI_DEV_DESTROY);
370
+ close(old_it->second);
371
+ gp_fds.erase(old_it);
372
+ gp_names.erase(slot);
373
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
374
+ auto rfd = g_rumbleFds.find(slot);
375
+ if (rfd != g_rumbleFds.end()) { close(rfd->second); g_rumbleFds.erase(rfd); }
376
+ }
377
+
378
+ int fd = open("/dev/uinput", O_RDWR | O_NONBLOCK);
379
+ if (fd < 0) break;
380
+
381
+ // EV_SYN (explicit, some kernels require it)
382
+ ioctl(fd, UI_SET_EVBIT, EV_SYN);
383
+
384
+ // Buttons
385
+ ioctl(fd, UI_SET_EVBIT, EV_KEY);
386
+ ioctl(fd, UI_SET_KEYBIT, BTN_SOUTH); ioctl(fd, UI_SET_KEYBIT, BTN_EAST);
387
+ ioctl(fd, UI_SET_KEYBIT, BTN_NORTH); ioctl(fd, UI_SET_KEYBIT, BTN_WEST);
388
+ ioctl(fd, UI_SET_KEYBIT, BTN_TL); ioctl(fd, UI_SET_KEYBIT, BTN_TR);
389
+ ioctl(fd, UI_SET_KEYBIT, BTN_SELECT); ioctl(fd, UI_SET_KEYBIT, BTN_START);
390
+ ioctl(fd, UI_SET_KEYBIT, BTN_MODE);
391
+ ioctl(fd, UI_SET_KEYBIT, BTN_THUMBL); ioctl(fd, UI_SET_KEYBIT, BTN_THUMBR);
392
+
393
+ // Absolute axes
394
+ ioctl(fd, UI_SET_EVBIT, EV_ABS);
395
+ ioctl(fd, UI_SET_ABSBIT, ABS_X); ioctl(fd, UI_SET_ABSBIT, ABS_Y);
396
+ ioctl(fd, UI_SET_ABSBIT, ABS_RX); ioctl(fd, UI_SET_ABSBIT, ABS_RY);
397
+ ioctl(fd, UI_SET_ABSBIT, ABS_Z); ioctl(fd, UI_SET_ABSBIT, ABS_RZ);
398
+ ioctl(fd, UI_SET_ABSBIT, ABS_HAT0X); ioctl(fd, UI_SET_ABSBIT, ABS_HAT0Y);
399
+
400
+ // Force Feedback
401
+ ioctl(fd, UI_SET_EVBIT, EV_FF);
402
+ ioctl(fd, UI_SET_FFBIT, FF_RUMBLE);
403
+
404
+ // Build uinput_user_dev with fuzz/flat matching linux_uinput.py:
405
+ // sticks: fuzz=16, flat=128 (prevents stick-drift noise)
406
+ // triggers: fuzz=0, flat=0
407
+ // hat: fuzz=0, flat=0
408
+ struct uinput_user_dev uud = {};
409
+ set_abs(uud, ABS_X, -32767, 32767, 16, 128);
410
+ set_abs(uud, ABS_Y, -32767, 32767, 16, 128);
411
+ set_abs(uud, ABS_RX, -32767, 32767, 16, 128);
412
+ set_abs(uud, ABS_RY, -32767, 32767, 16, 128);
413
+ set_abs(uud, ABS_Z, 0, 255, 0, 0);
414
+ set_abs(uud, ABS_RZ, 0, 255, 0, 0);
415
+ set_abs(uud, ABS_HAT0X, -1, 1, 0, 0);
416
+ set_abs(uud, ABS_HAT0Y, -1, 1, 0, 0);
417
+ uud.ff_effects_max = 16;
418
+
419
+ uud.id.bustype = BUS_USB;
420
+ uud.id.vendor = vid;
421
+ uud.id.product = pid;
422
+ uud.id.version = ver;
423
+ memcpy(uud.name, dev_name, 32);
424
+
425
+ if (write(fd, &uud, sizeof(uud)) < 0) {
426
+ close(fd);
427
+ break;
428
+ }
429
+ ioctl(fd, UI_DEV_CREATE);
430
+ gp_fds[slot] = fd;
431
+ gp_names[slot] = dev_name_str;
432
+
433
+ // Spawn background thread:
434
+ // 1. ACK FF upload/erase so games never deadlock
435
+ // 2. Intercept EV_FF play events (which come back to the write fd
436
+ // via uinput_dev_ff_playback) and fire the rumble callback.
437
+ //
438
+ // Key insight: EV_FF play events come back on the WRITE fd (fd),
439
+ // NOT on the separate eventX read node. The old rumble_watcher was
440
+ // looking in the wrong place.
441
+ std::thread([fd, slot, viewer_id]() {
442
+ // Per-slot effect magnitude table: effect_id -> {strong, weak}
443
+ std::map<int, std::pair<uint16_t,uint16_t>> effect_mags;
444
+
445
+ while (true) {
446
+ {
447
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
448
+ auto it = gp_fds.find(slot);
449
+ if (it == gp_fds.end() || it->second != fd) break;
450
+ }
451
+ fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds);
452
+ struct timeval tv = { 1, 0 };
453
+ if (select(fd + 1, &rfds, nullptr, nullptr, &tv) <= 0) continue;
454
+
455
+ struct input_event ie;
456
+ if (read(fd, &ie, sizeof(ie)) < (ssize_t)sizeof(ie)) continue;
457
+
458
+ if (ie.type == EV_UINPUT) {
459
+ if (ie.code == UI_FF_UPLOAD) {
460
+ struct uinput_ff_upload req; memset(&req, 0, sizeof(req));
461
+ req.request_id = ie.value;
462
+ ioctl(fd, UI_BEGIN_FF_UPLOAD, &req);
463
+ // Store magnitudes so the EV_FF play event has values
464
+ if (req.effect.type == FF_RUMBLE) {
465
+ effect_mags[req.effect.id] = {
466
+ req.effect.u.rumble.strong_magnitude,
467
+ req.effect.u.rumble.weak_magnitude
468
+ };
469
+ }
470
+ req.retval = 0;
471
+ ioctl(fd, UI_END_FF_UPLOAD, &req);
472
+ } else if (ie.code == UI_FF_ERASE) {
473
+ struct uinput_ff_erase req; memset(&req, 0, sizeof(req));
474
+ req.request_id = ie.value;
475
+ ioctl(fd, UI_BEGIN_FF_ERASE, &req);
476
+ effect_mags.erase(req.effect_id);
477
+ req.retval = 0;
478
+ ioctl(fd, UI_END_FF_ERASE, &req);
479
+ }
480
+ } else if (ie.type == EV_FF) {
481
+ // EV_FF play command from the game — fire the rumble callback
482
+ if (!g_tsfnValid) continue;
483
+
484
+ float strong = 0.0f, weak = 0.0f;
485
+ int duration = 200;
486
+
487
+ if (ie.value > 0) {
488
+ auto it = effect_mags.find(ie.code);
489
+ if (it != effect_mags.end()) {
490
+ strong = std::min(1.0f, it->second.first / 65535.0f);
491
+ weak = std::min(1.0f, it->second.second / 65535.0f);
492
+ } else {
493
+ // Unknown effect — use moderate defaults
494
+ strong = 0.5f; weak = 0.3f;
495
+ }
496
+ }
497
+ // ie.value == 0 means stop: strong=weak=0
498
+
499
+ std::string real_viewer;
500
+ {
501
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
502
+ auto vi = g_padViewers.find(std::to_string(slot));
503
+ if (vi != g_padViewers.end()) real_viewer = vi->second;
504
+ }
505
+
506
+ struct RumbleData {
507
+ uint8_t slot; std::string viewerId;
508
+ float strong, weak; int duration;
509
+ };
510
+ auto* rd = new RumbleData{ slot, real_viewer, strong, weak, duration };
511
+ g_rumbleTsfn.NonBlockingCall(rd, [](Napi::Env env, Napi::Function cb, RumbleData* rd) {
512
+ Napi::Object obj = Napi::Object::New(env);
513
+ obj.Set("slot", Napi::Number::New(env, rd->slot));
514
+ obj.Set("viewerId", Napi::String::New(env, rd->viewerId));
515
+ obj.Set("strong", Napi::Number::New(env, rd->strong));
516
+ obj.Set("weak", Napi::Number::New(env, rd->weak));
517
+ obj.Set("duration", Napi::Number::New(env, rd->duration));
518
+ cb.Call({ obj });
519
+ delete rd;
520
+ });
521
+ }
522
+ }
523
+ }).detach();
524
+ break;
525
+ }
526
+
527
+ // ── FREE GAMEPAD SLOT ─────────────────────────────────────────────────
528
+ case PKT::FREE_GP: {
529
+ uint8_t slot = data[1];
530
+ auto it = gp_fds.find(slot);
531
+ if (it != gp_fds.end()) {
532
+ ioctl(it->second, UI_DEV_DESTROY);
533
+ close(it->second);
534
+ gp_fds.erase(it);
535
+ gp_names.erase(slot);
536
+ }
537
+ {
538
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
539
+ auto rfd = g_rumbleFds.find(slot);
540
+ if (rfd != g_rumbleFds.end()) { close(rfd->second); g_rumbleFds.erase(rfd); }
541
+ }
542
+ break;
543
+ }
544
+
545
+ // ── FLUSH (neutralise all axes/buttons) ───────────────────────────────
546
+ case PKT::FLUSH: {
547
+ uint8_t slot = data[1];
548
+ auto it = gp_fds.find(slot);
549
+ if (it == gp_fds.end()) break;
550
+ int fd = it->second;
551
+ emit(fd, EV_ABS, ABS_X, 0); emit(fd, EV_ABS, ABS_Y, 0);
552
+ emit(fd, EV_ABS, ABS_RX, 0); emit(fd, EV_ABS, ABS_RY, 0);
553
+ emit(fd, EV_ABS, ABS_Z, 0); emit(fd, EV_ABS, ABS_RZ, 0);
554
+ emit(fd, EV_ABS, ABS_HAT0X, 0); emit(fd, EV_ABS, ABS_HAT0Y, 0);
555
+ for (auto code : {BTN_SOUTH, BTN_EAST, BTN_WEST, BTN_NORTH,
556
+ BTN_TL, BTN_TR, BTN_SELECT, BTN_START,
557
+ BTN_MODE, BTN_THUMBL, BTN_THUMBR}) {
558
+ emit(fd, EV_KEY, code, 0);
559
+ }
560
+ syn(fd);
561
+ break;
562
+ }
563
+
564
+ // ── DESTROY ALL ───────────────────────────────────────────────────────
565
+ case PKT::DESTROY: {
566
+ for (auto& [slot, fd] : gp_fds) {
567
+ ioctl(fd, UI_DEV_DESTROY);
568
+ close(fd);
569
+ }
570
+ gp_fds.clear();
571
+ gp_names.clear();
572
+ if (kbm_fd >= 0) {
573
+ ioctl(kbm_fd, UI_DEV_DESTROY);
574
+ close(kbm_fd);
575
+ kbm_fd = -1;
576
+ }
577
+ {
578
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
579
+ for (auto& [slot, fd] : g_rumbleFds) close(fd);
580
+ g_rumbleFds.clear();
581
+ }
582
+ if (g_tsfnValid) { g_rumbleTsfn.Release(); g_tsfnValid = false; }
583
+ break;
584
+ }
585
+ }
586
+ return info.Env().Undefined();
587
+ }
588
+
589
+ // ── N-API: explicit destroy (called from JS destroy()) ───────────────────────
590
+ Napi::Value DestroyDevice(const Napi::CallbackInfo& info) {
591
+ if (kbm_fd >= 0) { ioctl(kbm_fd, UI_DEV_DESTROY); close(kbm_fd); kbm_fd = -1; }
592
+ for (auto& [slot, fd] : gp_fds) { ioctl(fd, UI_DEV_DESTROY); close(fd); }
593
+ gp_fds.clear();
594
+ gp_names.clear();
595
+ {
596
+ std::lock_guard<std::mutex> lk(g_rumbleMtx);
597
+ for (auto& [slot, fd] : g_rumbleFds) close(fd);
598
+ g_rumbleFds.clear();
599
+ }
600
+ if (g_tsfnValid) { g_rumbleTsfn.Release(); g_tsfnValid = false; }
601
+ return info.Env().Undefined();
602
+ }
603
+
604
+ // ── Module init ───────────────────────────────────────────────────────────────
605
+ Napi::Object Init(Napi::Env env, Napi::Object exports) {
606
+ exports.Set("initializeDevice", Napi::Function::New(env, InitializeDevice));
607
+ exports.Set("submitInputPacket", Napi::Function::New(env, SubmitInputPacket));
608
+ exports.Set("destroyDevice", Napi::Function::New(env, DestroyDevice));
609
+ exports.Set("setRumbleCallback", Napi::Function::New(env, SetRumbleCallback));
610
+ return exports;
611
+ }
612
+
613
+ NODE_API_MODULE(uinputBridge, Init)
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@nearcade/virtual-gamepad",
3
+ "version": "1.0.0",
4
+ "description": "Cross-platform virtual gamepad injection for Node.js. Create and control virtual gamepad devices on Linux (uinput), Windows (ViGEmBus/HIDMaestro), and macOS (KBM fallback).",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "lib/",
9
+ "native/",
10
+ "python/",
11
+ "windows/",
12
+ "config/"
13
+ ],
14
+ "scripts": {
15
+ "install": "node-gyp rebuild --directory=native 2>/dev/null || echo 'Native addon build skipped (Linux-only, run manually if needed)'",
16
+ "rebuild": "node-gyp rebuild --directory=native",
17
+ "test": "node test.js",
18
+ "prepublishOnly": "echo 'Run npm run rebuild before publishing if targeting Linux'"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/TheRealFame/nvirtual-gamepad.git"
23
+ },
24
+ "keywords": [
25
+ "gamepad",
26
+ "virtual-gamepad",
27
+ "uinput",
28
+ "vigembus",
29
+ "hidmaestro",
30
+ "controller",
31
+ "input-injection",
32
+ "electron",
33
+ "remote-play"
34
+ ],
35
+ "author": "Cutefame <support@cutefame.net>",
36
+ "license": "MIT",
37
+ "bugs": {
38
+ "url": "https://github.com/TheRealFame/nvirtual-gamepad/issues"
39
+ },
40
+ "homepage": "https://github.com/TheRealFame/nvirtual-gamepad#readme",
41
+ "os": [
42
+ "linux",
43
+ "win32",
44
+ "darwin"
45
+ ],
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "gypfile": true,
50
+ "dependencies": {
51
+ "node-addon-api": "^8.0.0"
52
+ },
53
+ "peerDependencies": {
54
+ "node-addon-api": "^8.0.0"
55
+ }
56
+ }