emoemu 0.1.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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,418 @@
1
+ import HID from 'node-hid';
2
+ import { pipe, filter, map } from 'remeda';
3
+ import { safeClose } from '../../utils/safeClose';
4
+ import { StandardButton } from '../../core/button';
5
+ import { createOppositeDirections } from '../inputUtils';
6
+
7
+ export * from './consts';
8
+
9
+ import { ALL_STANDARD_BUTTONS } from './consts';
10
+ import {
11
+ GamepadProfile,
12
+ AnalogState,
13
+ findProfile,
14
+ isGamepadDevice,
15
+ gamepadProfiles,
16
+ } from '../gamepadProfiles';
17
+ import {
18
+ notifyGamepadConnected,
19
+ notifyGamepadDisconnected,
20
+ } from '../../frontend/notifications';
21
+ import {
22
+ GAMEPAD_SCAN_INTERVAL_MS,
23
+ MAX_GAMEPADS,
24
+ HEX_BASE,
25
+ PROFILE_NAME_DISPLAY_LENGTH,
26
+ ANALOG_DEBUG_DECIMALS,
27
+ } from '..';
28
+ import { logger } from '../../utils/logger';
29
+
30
+ /**
31
+ * Callback type for button state changes
32
+ */
33
+ export type GamepadButtonCallback = (
34
+ port: number,
35
+ button: StandardButton,
36
+ pressed: boolean
37
+ ) => void;
38
+
39
+ /**
40
+ * Callback type for analog axis changes
41
+ * @param port Controller port (0-based)
42
+ * @param index Analog stick (0=left, 1=right)
43
+ * @param axis Axis (0=X, 1=Y)
44
+ * @param value Normalized value from -1.0 to 1.0
45
+ */
46
+ export type GamepadAnalogCallback = (
47
+ port: number,
48
+ index: number,
49
+ axis: number,
50
+ value: number
51
+ ) => void;
52
+
53
+ /**
54
+ * Represents a connected gamepad device
55
+ */
56
+ interface ConnectedGamepad {
57
+ device: HID.HID;
58
+ profile: GamepadProfile;
59
+ deviceInfo: HID.Device;
60
+ controllerPort: 0 | 1;
61
+ lastButtonState: Map<StandardButton, boolean>;
62
+ lastAnalogState: AnalogState | null;
63
+ }
64
+
65
+
66
+ /**
67
+ * Manages gamepad input via HID devices
68
+ * Supports Xbox, PlayStation, Nintendo, and generic USB gamepads
69
+ */
70
+ export class GamepadManager {
71
+ private gamepads: ConnectedGamepad[] = [];
72
+ private scanInterval: ReturnType<typeof setInterval> | null = null;
73
+ private enabled: boolean = false;
74
+ private initialScanComplete: boolean = false;
75
+
76
+ /** Callback when button state changes */
77
+ public onButtonChange: GamepadButtonCallback | null = null;
78
+
79
+ /** Callback when analog axis changes */
80
+ public onAnalogChange: GamepadAnalogCallback | null = null;
81
+
82
+ constructor() {}
83
+
84
+ /**
85
+ * Start the gamepad manager
86
+ * Scans for devices and begins reading input
87
+ */
88
+ start(): void {
89
+ if (this.enabled) {return;}
90
+ this.enabled = true;
91
+
92
+ // Log joypad driver (RetroArch-style)
93
+ logger.info('Found joypad driver: "hid"', 'Joypad');
94
+
95
+ // Initial device scan (silent - no notifications)
96
+ this.scanForDevices();
97
+ this.initialScanComplete = true;
98
+
99
+ // Periodically scan for new devices (hotplug support)
100
+ this.scanInterval = setInterval(() => {
101
+ this.scanForDevices();
102
+ }, GAMEPAD_SCAN_INTERVAL_MS);
103
+ }
104
+
105
+ /**
106
+ * Stop the gamepad manager and release all devices
107
+ */
108
+ stop(): void {
109
+ this.enabled = false;
110
+
111
+ if (this.scanInterval) {
112
+ clearInterval(this.scanInterval);
113
+ this.scanInterval = null;
114
+ }
115
+
116
+ // Close all connected gamepads
117
+ for (const gamepad of this.gamepads) {
118
+ safeClose(gamepad.device);
119
+ }
120
+ this.gamepads = [];
121
+ }
122
+
123
+ /**
124
+ * Scan for and connect to gamepad devices
125
+ */
126
+ private scanForDevices(): void {
127
+ if (!this.enabled) {return;}
128
+
129
+ try {
130
+ const devices = HID.devices();
131
+ const gamepadDevices = devices.filter(isGamepadDevice);
132
+
133
+ for (const deviceInfo of gamepadDevices) {
134
+ // Skip if already connected
135
+ if (this.isDeviceConnected(deviceInfo)) {continue;}
136
+
137
+ // Skip if we already have max gamepads
138
+ if (this.gamepads.length >= MAX_GAMEPADS) {continue;}
139
+
140
+ // Try to connect
141
+ this.connectDevice(deviceInfo);
142
+ }
143
+ } catch {
144
+ // HID enumeration can fail on some systems - ignore silently
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check if a device is already connected
150
+ */
151
+ private isDeviceConnected(deviceInfo: HID.Device): boolean {
152
+ return this.gamepads.some(
153
+ (gp) =>
154
+ gp.deviceInfo.vendorId === deviceInfo.vendorId &&
155
+ gp.deviceInfo.productId === deviceInfo.productId &&
156
+ gp.deviceInfo.path === deviceInfo.path
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Attempt to connect to a gamepad device
162
+ */
163
+ private connectDevice(deviceInfo: HID.Device): void {
164
+ if (!deviceInfo.path) {return;}
165
+
166
+ try {
167
+ const device = new HID.HID(deviceInfo.path);
168
+ const profile = findProfile(
169
+ deviceInfo.vendorId,
170
+ deviceInfo.productId
171
+ );
172
+
173
+ // Assign to next available controller port (0-indexed for core compatibility)
174
+ const controllerPort: 0 | 1 = this.gamepads.length === 0 ? 0 : 1;
175
+
176
+ const gamepad: ConnectedGamepad = {
177
+ device,
178
+ profile,
179
+ deviceInfo,
180
+ controllerPort,
181
+ lastButtonState: new Map(),
182
+ lastAnalogState: null,
183
+ };
184
+
185
+ // Set up data handler
186
+ device.on('data', (data: Buffer) => {
187
+ this.handleInput(gamepad, data);
188
+ });
189
+
190
+ // Handle disconnection
191
+ device.on('error', () => {
192
+ this.disconnectGamepad(gamepad);
193
+ });
194
+
195
+ this.gamepads.push(gamepad);
196
+
197
+ // Log gamepad connection (RetroArch-style)
198
+ logger.info(`${profile.name} configured in port ${controllerPort + 1}`, 'Autoconf');
199
+ logger.debug(`Joypad connected: ${deviceInfo.product ?? 'Unknown'} (VID=${deviceInfo.vendorId.toString(HEX_BASE)}, PID=${deviceInfo.productId.toString(HEX_BASE)})`, 'Joypad');
200
+
201
+ // Only notify for hotplugged gamepads, not ones already connected at startup
202
+ if (this.initialScanComplete) {
203
+ notifyGamepadConnected(profile.name, controllerPort + 1);
204
+ }
205
+
206
+ logger.debug(
207
+ `Gamepad connected: ${profile.name} (${deviceInfo.product ?? 'Unknown'}) -> Player ${controllerPort + 1}`,
208
+ 'Joypad'
209
+ );
210
+ } catch {
211
+ // Failed to open device - might be in use or require permissions
212
+ }
213
+ }
214
+
215
+ /** Deadzone threshold for analog stick changes (1%) */
216
+ private static readonly ANALOG_DEADZONE = 0.01;
217
+
218
+ /**
219
+ * Handle input data from a gamepad
220
+ */
221
+ private handleInput(gamepad: ConnectedGamepad, data: Buffer): void {
222
+ logger.debug(
223
+ `[${gamepad.profile.name}] Raw: ${Array.from(data)
224
+ .map((b) => b.toString(HEX_BASE).padStart(2, '0'))
225
+ .join(' ')}`,
226
+ 'Joypad'
227
+ );
228
+
229
+ try {
230
+ const buttonStates = gamepad.profile.parseReport(data);
231
+
232
+ // Update all tracked buttons
233
+ for (const button of ALL_STANDARD_BUTTONS) {
234
+ const pressed = buttonStates.get(button) ?? false;
235
+ const wasPressed = gamepad.lastButtonState.get(button) ?? false;
236
+
237
+ // Only update if state changed
238
+ if (pressed !== wasPressed) {
239
+ // Handle opposite directions - don't allow Up+Down or Left+Right
240
+ if (pressed) {
241
+ this.handleButtonPress(button, gamepad);
242
+ } else {
243
+ // Fire callback for button release
244
+ this.onButtonChange?.(gamepad.controllerPort, button, false);
245
+ }
246
+ gamepad.lastButtonState.set(button, pressed);
247
+ }
248
+ }
249
+
250
+ // Handle analog stick input if profile supports it
251
+ if (gamepad.profile.parseAnalog && this.onAnalogChange) {
252
+ const analogState = gamepad.profile.parseAnalog(data);
253
+ if (analogState) {
254
+ this.handleAnalogInput(gamepad, analogState);
255
+ }
256
+ }
257
+ } catch {
258
+ // Parsing failed - might be unexpected report format
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Handle analog stick input changes
264
+ */
265
+ private handleAnalogInput(gamepad: ConnectedGamepad, state: AnalogState): void {
266
+ const last = gamepad.lastAnalogState;
267
+ const port = gamepad.controllerPort;
268
+ const dz = GamepadManager.ANALOG_DEADZONE;
269
+
270
+ // Debug: Log raw analog values
271
+ logger.debug(
272
+ `Analog raw: L(${state.leftX.toFixed(ANALOG_DEBUG_DECIMALS)}, ${state.leftY.toFixed(ANALOG_DEBUG_DECIMALS)}) R(${state.rightX.toFixed(ANALOG_DEBUG_DECIMALS)}, ${state.rightY.toFixed(ANALOG_DEBUG_DECIMALS)})`,
273
+ 'Joypad'
274
+ );
275
+
276
+ // Left stick X (index=0, axis=0)
277
+ if (!last || Math.abs(state.leftX - last.leftX) > dz) {
278
+ this.onAnalogChange?.(port, 0, 0, state.leftX);
279
+ }
280
+ // Left stick Y (index=0, axis=1)
281
+ if (!last || Math.abs(state.leftY - last.leftY) > dz) {
282
+ this.onAnalogChange?.(port, 0, 1, state.leftY);
283
+ }
284
+ // Right stick X (index=1, axis=0)
285
+ if (!last || Math.abs(state.rightX - last.rightX) > dz) {
286
+ this.onAnalogChange?.(port, 1, 0, state.rightX);
287
+ }
288
+ // Right stick Y (index=1, axis=1)
289
+ if (!last || Math.abs(state.rightY - last.rightY) > dz) {
290
+ this.onAnalogChange?.(port, 1, 1, state.rightY);
291
+ }
292
+
293
+ gamepad.lastAnalogState = state;
294
+ }
295
+
296
+ /**
297
+ * Opposite D-pad directions for preventing simultaneous Up+Down or Left+Right
298
+ */
299
+ private static readonly OPPOSITE_DIRECTIONS: Map<StandardButton, StandardButton> = createOppositeDirections(
300
+ StandardButton.Up,
301
+ StandardButton.Down,
302
+ StandardButton.Left,
303
+ StandardButton.Right
304
+ );
305
+
306
+ /**
307
+ * Handle button press with opposite direction logic
308
+ */
309
+ private handleButtonPress(
310
+ button: StandardButton,
311
+ gamepad: ConnectedGamepad
312
+ ): void {
313
+ // Release opposite direction if pressing a direction
314
+ const opposite = GamepadManager.OPPOSITE_DIRECTIONS.get(button);
315
+ if (opposite !== undefined && gamepad.lastButtonState.get(opposite)) {
316
+ // Release opposite direction first
317
+ this.onButtonChange?.(gamepad.controllerPort, opposite, false);
318
+ gamepad.lastButtonState.set(opposite, false);
319
+ }
320
+
321
+ // Fire callback for button press
322
+ this.onButtonChange?.(gamepad.controllerPort, button, true);
323
+ }
324
+
325
+ /**
326
+ * Disconnect a gamepad
327
+ */
328
+ private disconnectGamepad(gamepad: ConnectedGamepad): void {
329
+ const index = this.gamepads.indexOf(gamepad);
330
+ if (index === -1) {return;}
331
+
332
+ safeClose(gamepad.device);
333
+
334
+ this.gamepads.splice(index, 1);
335
+ notifyGamepadDisconnected(gamepad.profile.name, gamepad.controllerPort + 1);
336
+
337
+ // Release all buttons on the controller
338
+ for (const button of ALL_STANDARD_BUTTONS) {
339
+ if (gamepad.lastButtonState.get(button)) {
340
+ this.onButtonChange?.(gamepad.controllerPort, button, false);
341
+ }
342
+ }
343
+
344
+ logger.debug(
345
+ `Gamepad disconnected: ${gamepad.profile.name} (Player ${gamepad.controllerPort + 1})`,
346
+ 'Joypad'
347
+ );
348
+ }
349
+
350
+ /**
351
+ * Get number of connected gamepads
352
+ */
353
+ getConnectedCount(): number {
354
+ return this.gamepads.length;
355
+ }
356
+
357
+ /**
358
+ * Get debug info about connected gamepads
359
+ */
360
+ getDebugInfo(): string {
361
+ if (this.gamepads.length === 0) {
362
+ return 'No gamepads';
363
+ }
364
+
365
+ return pipe(
366
+ this.gamepads,
367
+ map((gp) => `P${gp.controllerPort + 1}: ${gp.profile.name.substring(0, PROFILE_NAME_DISPLAY_LENGTH)}`)
368
+ ).join(', ');
369
+ }
370
+
371
+ /**
372
+ * Get short status string for player 1's input device
373
+ */
374
+ getPlayer1Status(): string | null {
375
+ const p1 = this.gamepads.find((gp) => gp.controllerPort === 0);
376
+ return p1 ? p1.profile.name : null;
377
+ }
378
+
379
+ /**
380
+ * List all detected gamepad devices (for diagnostics)
381
+ */
382
+ static listDevices(): Array<{
383
+ vendorId: number;
384
+ productId: number;
385
+ product: string;
386
+ manufacturer: string;
387
+ profile: string;
388
+ path: string;
389
+ }> {
390
+ try {
391
+ return pipe(
392
+ HID.devices(),
393
+ filter(isGamepadDevice),
394
+ map((d) => ({
395
+ vendorId: d.vendorId,
396
+ productId: d.productId,
397
+ product: d.product ?? 'Unknown',
398
+ manufacturer: d.manufacturer ?? 'Unknown',
399
+ profile: findProfile(d.vendorId, d.productId).name,
400
+ path: d.path ?? '',
401
+ }))
402
+ );
403
+ } catch {
404
+ return [];
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Get list of supported controller profiles
410
+ */
411
+ static getSupportedProfiles(): string[] {
412
+ return pipe(
413
+ gamepadProfiles,
414
+ filter((p) => p.vendorIds.length > 0),
415
+ map((p) => p.name)
416
+ );
417
+ }
418
+ }
@@ -0,0 +1,86 @@
1
+ import { Button } from '../Controller';
2
+ import { createOppositeDirections } from '../inputUtils';
3
+ import {
4
+ KITTY_KEY_ARROW_UP,
5
+ KITTY_KEY_ARROW_DOWN,
6
+ KITTY_KEY_ARROW_LEFT,
7
+ KITTY_KEY_ARROW_RIGHT,
8
+ KEY_CODE_W,
9
+ KEY_CODE_S,
10
+ KEY_CODE_A,
11
+ KEY_CODE_D,
12
+ KEY_CODE_K,
13
+ KEY_CODE_Z,
14
+ KEY_CODE_J,
15
+ KEY_CODE_X,
16
+ KEY_CODE_ENTER,
17
+ KEY_CODE_SPACE,
18
+ } from '..';
19
+
20
+ /**
21
+ * Kitty keyboard protocol key codes (Unicode codepoints)
22
+ * https://sw.kovidgoyal.net/kitty/keyboard-protocol/
23
+ */
24
+ export const KITTY_KEY_TO_BUTTON: Map<number, Button> = new Map([
25
+ // WASD for D-Pad (lowercase)
26
+ [KEY_CODE_W, Button.Up],
27
+ [KEY_CODE_S, Button.Down],
28
+ [KEY_CODE_A, Button.Left],
29
+ [KEY_CODE_D, Button.Right],
30
+
31
+ // Action buttons
32
+ [KEY_CODE_K, Button.A],
33
+ [KEY_CODE_Z, Button.A],
34
+ [KEY_CODE_J, Button.B],
35
+ [KEY_CODE_X, Button.B],
36
+
37
+ // Start/Select
38
+ [KEY_CODE_ENTER, Button.Start],
39
+ [KEY_CODE_SPACE, Button.Select],
40
+ ]);
41
+
42
+ // Special keys use different codes in Kitty protocol
43
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
44
+ export const KITTY_SPECIAL_KEYS: Map<number, Button> = new Map([
45
+ [KITTY_KEY_ARROW_UP, Button.Up],
46
+ [KITTY_KEY_ARROW_DOWN, Button.Down],
47
+ [KITTY_KEY_ARROW_LEFT, Button.Left],
48
+ [KITTY_KEY_ARROW_RIGHT, Button.Right],
49
+ ]);
50
+
51
+ /**
52
+ * Legacy key mappings for non-Kitty terminals
53
+ */
54
+ export const LEGACY_KEY_TO_BUTTON: Map<string, Button> = new Map([
55
+ // WASD
56
+ ['w', Button.Up], ['W', Button.Up],
57
+ ['s', Button.Down], ['S', Button.Down],
58
+ ['a', Button.Left], ['A', Button.Left],
59
+ ['d', Button.Right], ['D', Button.Right],
60
+
61
+ // Arrow keys (legacy escape sequences)
62
+ ['\x1b[A', Button.Up],
63
+ ['\x1b[B', Button.Down],
64
+ ['\x1b[C', Button.Right],
65
+ ['\x1b[D', Button.Left],
66
+
67
+ // Action buttons
68
+ ['k', Button.A], ['K', Button.A],
69
+ ['z', Button.A], ['Z', Button.A],
70
+ ['j', Button.B], ['J', Button.B],
71
+ ['x', Button.B], ['X', Button.B],
72
+
73
+ // Start/Select
74
+ ['\r', Button.Start],
75
+ [' ', Button.Select],
76
+ ]);
77
+
78
+ /**
79
+ * D-pad buttons that are mutually exclusive (opposite directions)
80
+ */
81
+ export const OPPOSITE_DIRECTIONS: Map<Button, Button> = createOppositeDirections(
82
+ Button.Up,
83
+ Button.Down,
84
+ Button.Left,
85
+ Button.Right
86
+ );