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,436 @@
1
+ /**
2
+ * Input Mapper
3
+ *
4
+ * Translates physical inputs (keyboard keys, gamepad buttons) to core-specific
5
+ * button IDs. This allows the same physical controls to work across different
6
+ * emulated systems.
7
+ *
8
+ * Flow:
9
+ * 1. Frontend receives keyboard/gamepad input
10
+ * 2. Input is translated to StandardButton enum
11
+ * 3. StandardButton is mapped to core-specific button ID via button name matching
12
+ * 4. Core receives setButtonState(port, coreButtonId, pressed)
13
+ */
14
+
15
+ import { pipe, map, filter, isNonNull } from 'remeda';
16
+ import type { ButtonDefinition } from '../../core/core';
17
+ import {
18
+ StandardButton,
19
+ DEFAULT_KEYBOARD_MAP,
20
+ areOppositeDirections,
21
+ } from '../../core/button';
22
+ import { logger } from '../../utils/logger';
23
+
24
+ export * from './consts';
25
+
26
+ import { BUTTON_NAME_MAP, ANALOG_INDEX, ANALOG_AXIS } from './consts';
27
+
28
+ /**
29
+ * Callback type for button state changes
30
+ */
31
+ export type ButtonChangeCallback = (
32
+ port: number,
33
+ button: number,
34
+ pressed: boolean
35
+ ) => void;
36
+
37
+ /**
38
+ * Callback type for analog axis changes
39
+ * @param port Controller port (0-based)
40
+ * @param index Analog stick (0=left, 1=right)
41
+ * @param axis Axis (0=X, 1=Y)
42
+ * @param value Normalized value from -1.0 to 1.0
43
+ */
44
+ export type AnalogChangeCallback = (
45
+ port: number,
46
+ index: number,
47
+ axis: number,
48
+ value: number
49
+ ) => void;
50
+
51
+
52
+ /**
53
+ * Maps physical inputs to core-specific buttons
54
+ */
55
+ export class InputMapper {
56
+ /** Core's button definitions */
57
+ private coreButtons: ButtonDefinition[];
58
+
59
+ /** Map from StandardButton to core button ID */
60
+ private standardToCore: Map<StandardButton, number>;
61
+
62
+ /** Map from keyboard key to StandardButton */
63
+ private keyboardMap: Map<string, StandardButton>;
64
+
65
+ /** Current button state per port: port -> (coreButtonId -> pressed) */
66
+ private portState: Map<number, Map<number, boolean>>;
67
+
68
+ /** Analog axis state per port: port -> index -> axis -> value */
69
+ private analogState: Map<number, Map<number, Map<number, number>>>;
70
+
71
+ /** Callback when button state changes */
72
+ public onButtonChange: ButtonChangeCallback | null = null;
73
+
74
+ /** Callback when analog axis state changes */
75
+ public onAnalogChange: AnalogChangeCallback | null = null;
76
+
77
+ /**
78
+ * Create an input mapper for a specific core's buttons.
79
+ *
80
+ * @param coreButtons Button definitions from SystemInfo.buttons
81
+ * @param maxPlayers Maximum number of controller ports
82
+ */
83
+ constructor(coreButtons: ButtonDefinition[], maxPlayers: number = 2) {
84
+ this.coreButtons = coreButtons;
85
+ this.keyboardMap = new Map(DEFAULT_KEYBOARD_MAP);
86
+
87
+ // Initialize port state
88
+ this.portState = new Map();
89
+ this.analogState = new Map();
90
+ for (let port = 0; port < maxPlayers; port++) {
91
+ this.portState.set(port, new Map());
92
+ this.analogState.set(port, new Map());
93
+ }
94
+
95
+ // Build mapping from StandardButton to core button IDs by name matching
96
+ this.standardToCore = this.buildDefaultMapping();
97
+ }
98
+
99
+ /**
100
+ * Build default mapping from StandardButton to core buttons by matching names.
101
+ * This allows automatic mapping without explicit configuration.
102
+ */
103
+ private buildDefaultMapping(): Map<StandardButton, number> {
104
+ return new Map(
105
+ pipe(
106
+ this.coreButtons,
107
+ map((button) => {
108
+ const name = button.name.toLowerCase();
109
+ const match = BUTTON_NAME_MAP.find((m) => m.names.includes(name));
110
+ return match ? ([match.button, button.id] as const) : null;
111
+ }),
112
+ filter(isNonNull)
113
+ )
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Handle a keyboard key event.
119
+ *
120
+ * @param key The key that was pressed/released (e.g., 'a', 'Enter', 'ArrowUp')
121
+ * @param pressed Whether the key is pressed (true) or released (false)
122
+ * @param port Controller port (default 0)
123
+ */
124
+ handleKey(key: string, pressed: boolean, port: number = 0): void {
125
+ const standardButton = this.keyboardMap.get(key);
126
+ if (standardButton === undefined) {return;}
127
+
128
+ this.handleStandardButton(standardButton, pressed, port);
129
+ }
130
+
131
+ /** D-pad to analog axis mapping for keyboard-to-analog conversion */
132
+ private static readonly DIRECTION_TO_ANALOG: ReadonlyMap<
133
+ StandardButton,
134
+ { axis: number; value: number }
135
+ > = new Map([
136
+ // Left stick: index=0, axis=0 (X), axis=1 (Y)
137
+ // X: negative = left, positive = right
138
+ // Y: negative = up, positive = down
139
+ [StandardButton.Up, { axis: ANALOG_AXIS.Y, value: -1 }],
140
+ [StandardButton.Down, { axis: ANALOG_AXIS.Y, value: 1 }],
141
+ [StandardButton.Left, { axis: ANALOG_AXIS.X, value: -1 }],
142
+ [StandardButton.Right, { axis: ANALOG_AXIS.X, value: 1 }],
143
+ ]);
144
+
145
+ /** Track keyboard-driven analog state to handle opposite directions */
146
+ private keyboardAnalogState: Map<number, Map<number, number>> = new Map();
147
+
148
+ /**
149
+ * Handle a standard button event (from keyboard or gamepad).
150
+ *
151
+ * @param standardButton The standard button
152
+ * @param pressed Whether pressed or released
153
+ * @param port Controller port
154
+ */
155
+ handleStandardButton(
156
+ standardButton: StandardButton,
157
+ pressed: boolean,
158
+ port: number = 0
159
+ ): void {
160
+ const coreButton = this.standardToCore.get(standardButton);
161
+ if (coreButton === undefined) {return;}
162
+
163
+ // Handle opposite direction prevention for D-pad
164
+ if (pressed) {
165
+ const portState = this.portState.get(port);
166
+ if (portState) {
167
+ // Check if opposite direction is pressed
168
+ for (const [otherStandard, otherCore] of this.standardToCore) {
169
+ if (
170
+ areOppositeDirections(standardButton, otherStandard) &&
171
+ portState.get(otherCore)
172
+ ) {
173
+ // Release opposite direction first
174
+ this.setButton(port, otherCore, false);
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ this.setButton(port, coreButton, pressed);
181
+
182
+ // Also send analog input for direction buttons (for systems like N64)
183
+ this.handleDirectionAsAnalog(standardButton, pressed, port);
184
+ }
185
+
186
+ /**
187
+ * Convert direction button to analog stick input.
188
+ * This allows keyboard arrows to control analog sticks (useful for N64).
189
+ */
190
+ private handleDirectionAsAnalog(
191
+ standardButton: StandardButton,
192
+ pressed: boolean,
193
+ port: number
194
+ ): void {
195
+ const analogMapping = InputMapper.DIRECTION_TO_ANALOG.get(standardButton);
196
+ if (!analogMapping) {return;}
197
+
198
+ // Initialize keyboard analog tracking for this port if needed
199
+ if (!this.keyboardAnalogState.has(port)) {
200
+ this.keyboardAnalogState.set(port, new Map([[ANALOG_AXIS.X, 0], [ANALOG_AXIS.Y, 0]]));
201
+ }
202
+ const portAnalog = this.keyboardAnalogState.get(port)!;
203
+
204
+ // Calculate new axis value
205
+ const currentValue = portAnalog.get(analogMapping.axis) ?? 0;
206
+ let newValue: number;
207
+
208
+ if (pressed) {
209
+ // Set axis to direction value
210
+ newValue = analogMapping.value;
211
+ } else {
212
+ // On release, check if opposite direction is still held
213
+ // If so, revert to that direction; otherwise, center the axis
214
+ const oppositeButton = this.getOppositeDirection(standardButton);
215
+ const oppositeHeld = oppositeButton !== undefined &&
216
+ this.portState.get(port)?.get(this.standardToCore.get(oppositeButton)!) === true;
217
+
218
+ if (oppositeHeld) {
219
+ // Opposite direction is held, set to its value
220
+ const oppositeMapping = InputMapper.DIRECTION_TO_ANALOG.get(oppositeButton);
221
+ newValue = oppositeMapping?.value ?? 0;
222
+ } else {
223
+ // No direction held, center the axis
224
+ newValue = 0;
225
+ }
226
+ }
227
+
228
+ // Update tracking and fire callback if value changed
229
+ if (newValue !== currentValue) {
230
+ portAnalog.set(analogMapping.axis, newValue);
231
+ // Debug: Log keyboard-to-analog conversion
232
+ logger.debug(`Keyboard analog: button=${StandardButton[standardButton]} axis=${analogMapping.axis} value=${newValue}`, 'Input');
233
+ this.onAnalogChange?.(port, ANALOG_INDEX.LEFT, analogMapping.axis, newValue);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Get the opposite direction button for a given direction.
239
+ */
240
+ private getOppositeDirection(button: StandardButton): StandardButton | undefined {
241
+ switch (button) {
242
+ case StandardButton.Up: return StandardButton.Down;
243
+ case StandardButton.Down: return StandardButton.Up;
244
+ case StandardButton.Left: return StandardButton.Right;
245
+ case StandardButton.Right: return StandardButton.Left;
246
+ default: return undefined;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Handle gamepad button by index (from HID report).
252
+ * This maps the gamepad button index to a StandardButton.
253
+ *
254
+ * @param gamepadButton Gamepad button index (system-specific)
255
+ * @param pressed Whether pressed or released
256
+ * @param port Controller port
257
+ */
258
+ handleGamepadButton(
259
+ gamepadButton: StandardButton,
260
+ pressed: boolean,
261
+ port: number = 0
262
+ ): void {
263
+ this.handleStandardButton(gamepadButton, pressed, port);
264
+ }
265
+
266
+ /**
267
+ * Handle analog stick axis input.
268
+ *
269
+ * @param index Analog stick (0=left, 1=right from ANALOG_INDEX)
270
+ * @param axis Axis (0=X, 1=Y from ANALOG_AXIS)
271
+ * @param value Normalized value from -1.0 to 1.0
272
+ * @param port Controller port (default 0)
273
+ */
274
+ handleAnalogAxis(
275
+ index: number,
276
+ axis: number,
277
+ value: number,
278
+ port: number = 0
279
+ ): void {
280
+ const portState = this.analogState.get(port);
281
+ if (!portState) {return;}
282
+
283
+ // Initialize stick state if needed
284
+ if (!portState.has(index)) {
285
+ portState.set(index, new Map([[ANALOG_AXIS.X, 0], [ANALOG_AXIS.Y, 0]]));
286
+ }
287
+
288
+ const stickState = portState.get(index);
289
+ if (!stickState) {return;}
290
+
291
+ // Only update if value changed significantly (avoid noise)
292
+ const oldValue = stickState.get(axis) ?? 0;
293
+ const DEADZONE = 0.01; // 1% deadzone for noise filtering
294
+ if (Math.abs(value - oldValue) > DEADZONE) {
295
+ stickState.set(axis, value);
296
+ this.onAnalogChange?.(port, index, axis, value);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Get the analog state for a port.
302
+ *
303
+ * @param port Controller port
304
+ * @returns Map of index -> axis -> value
305
+ */
306
+ getAnalogState(port: number): Map<number, Map<number, number>> {
307
+ return new Map(
308
+ Array.from(this.analogState.get(port) ?? []).map(([index, axes]) => [
309
+ index,
310
+ new Map(axes),
311
+ ])
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Set a core button state directly.
317
+ *
318
+ * @param port Controller port
319
+ * @param coreButton Core-specific button ID
320
+ * @param pressed Whether pressed or released
321
+ */
322
+ private setButton(port: number, coreButton: number, pressed: boolean): void {
323
+ const portState = this.portState.get(port);
324
+ if (!portState) {return;}
325
+
326
+ const wasPressed = portState.get(coreButton) ?? false;
327
+ if (pressed !== wasPressed) {
328
+ portState.set(coreButton, pressed);
329
+ this.onButtonChange?.(port, coreButton, pressed);
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Get the current button state for a port.
335
+ *
336
+ * @param port Controller port
337
+ * @returns Map of core button ID to pressed state
338
+ */
339
+ getButtonState(port: number): Map<number, boolean> {
340
+ return new Map(this.portState.get(port) ?? []);
341
+ }
342
+
343
+ /**
344
+ * Get pressed buttons as a display string.
345
+ *
346
+ * @param port Controller port
347
+ * @returns Space-separated button names (with arrow characters for D-pad)
348
+ */
349
+ getPressedButtons(port: number = 0): string {
350
+ const portState = this.portState.get(port);
351
+ if (!portState) {return '';}
352
+
353
+ // Map direction names to Unicode arrows
354
+ const formatButtonName = (name: string): string => {
355
+ switch (name.toLowerCase()) {
356
+ case 'up': return '↑';
357
+ case 'down': return '↓';
358
+ case 'left': return '←';
359
+ case 'right': return '→';
360
+ default: return name;
361
+ }
362
+ };
363
+
364
+ return pipe(
365
+ this.coreButtons,
366
+ filter((button) => portState.get(button.id) === true),
367
+ map((button) => formatButtonName(button.name))
368
+ ).join(' ');
369
+ }
370
+
371
+ /**
372
+ * Clear all button and analog states (e.g., when losing focus).
373
+ */
374
+ clear(): void {
375
+ for (const portState of this.portState.values()) {
376
+ for (const [button, pressed] of portState) {
377
+ if (pressed) {
378
+ portState.set(button, false);
379
+ // Note: We don't call onButtonChange here to avoid spamming
380
+ // the core during focus loss. The core should handle this gracefully.
381
+ }
382
+ }
383
+ }
384
+
385
+ // Clear analog states
386
+ for (const portAnalog of this.analogState.values()) {
387
+ for (const stickState of portAnalog.values()) {
388
+ stickState.set(ANALOG_AXIS.X, 0);
389
+ stickState.set(ANALOG_AXIS.Y, 0);
390
+ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Set a custom keyboard mapping.
396
+ *
397
+ * @param key Keyboard key
398
+ * @param standardButton StandardButton to map to, or undefined to remove
399
+ */
400
+ setKeyMapping(key: string, standardButton: StandardButton | undefined): void {
401
+ if (standardButton === undefined) {
402
+ this.keyboardMap.delete(key);
403
+ } else {
404
+ this.keyboardMap.set(key, standardButton);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Get the keyboard mapping.
410
+ *
411
+ * @returns Copy of the keyboard map
412
+ */
413
+ getKeyboardMap(): Map<string, StandardButton> {
414
+ return new Map(this.keyboardMap);
415
+ }
416
+
417
+ /**
418
+ * Check if a standard button is mapped to a core button.
419
+ *
420
+ * @param standardButton The standard button to check
421
+ * @returns true if mapped
422
+ */
423
+ isButtonMapped(standardButton: StandardButton): boolean {
424
+ return this.standardToCore.has(standardButton);
425
+ }
426
+
427
+ /**
428
+ * Get the core button ID for a standard button.
429
+ *
430
+ * @param standardButton The standard button
431
+ * @returns Core button ID or undefined if not mapped
432
+ */
433
+ getCoreButton(standardButton: StandardButton): number | undefined {
434
+ return this.standardToCore.get(standardButton);
435
+ }
436
+ }