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,406 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { basename, extname, join } from 'path';
3
+ import { Emulator } from '../../Emulator';
4
+ import {
5
+ getSupportedExtensions,
6
+ getCoreFactory,
7
+ findMatchingCores,
8
+ } from '../../frontend/coreRegistry';
9
+ import type { CoreFactory } from '../../frontend/coreRegistry';
10
+ import { getCoresDirectory } from '../../frontend/config';
11
+ import { getPreferredCoreId, setPreferredCoreId } from '../../frontend/corePreferences';
12
+ import { getSaveStateService } from '../../frontend/serviceProvider';
13
+ import { SettingsManager } from '../../frontend/SettingsManager';
14
+ import {
15
+ selectCore,
16
+ showSaveStateDialog,
17
+ showCorruptedStateDialog,
18
+ showNetplayDisconnectedDialog,
19
+ } from '../../ui';
20
+ import type { SaveStateInfo, SaveStateChoice, CorruptedStateInfo, NetplayOptions } from '../../ui';
21
+ import { STDIN_SETTLE_DELAY_MS } from '../../ui';
22
+ import { logger } from '../../utils/logger';
23
+ import { getErrorMessage } from '../../utils/getErrorMessage';
24
+ import {
25
+ DEFAULT_TERMINAL_WIDTH_WIDE,
26
+ DEFAULT_TERMINAL_HEIGHT_TALL,
27
+ CHAR_ASPECT_RATIO_4_3,
28
+ } from '../../rendering';
29
+ import { fitToTerminal } from '../../rendering/shared/fitToTerminal';
30
+ import { registerLibretroCore } from '../../cores/libretro/loader';
31
+ import type { CliOptions } from '../parseArgs';
32
+ import type { RunEmulatorResult } from './types';
33
+
34
+ export * from './types';
35
+
36
+ /**
37
+ * Validate a state file exists and is readable
38
+ * @returns true if file exists and can be read, false otherwise
39
+ */
40
+ const validateStateFile = (statePath: string): boolean => {
41
+ if (!existsSync(statePath)) {
42
+ return false;
43
+ }
44
+
45
+ try {
46
+ const data = readFileSync(statePath);
47
+ // For JSON files, try to parse to validate
48
+ // For binary files (libretro), just check the file is not empty
49
+ if (data.length === 0) {
50
+ return false;
51
+ }
52
+
53
+ // Try to parse as JSON (native cores) - if it fails, assume it's binary (libretro)
54
+ const str = data.toString("utf-8");
55
+ if (str.startsWith("{")) {
56
+ JSON.parse(str); // Validate JSON is parseable
57
+ }
58
+
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Analyze a corrupted state file and determine if it can be loaded.
67
+ * All cores use raw binary state format.
68
+ */
69
+ const analyzeCorruptedState = (statePath: string, romName: string): CorruptedStateInfo => {
70
+ const info: CorruptedStateInfo = {
71
+ path: statePath,
72
+ romName,
73
+ fileReadable: false,
74
+ isBinary: true,
75
+ validJson: false,
76
+ canAttemptLoad: false,
77
+ errorReason: 'Unknown error',
78
+ };
79
+
80
+ // Try to read the file
81
+ let data: Buffer;
82
+ try {
83
+ data = readFileSync(statePath);
84
+ info.fileReadable = true;
85
+ } catch (err) {
86
+ info.errorReason = `Cannot read file: ${getErrorMessage(err)}`;
87
+ return info;
88
+ }
89
+
90
+ if (data.length > 0) {
91
+ info.canAttemptLoad = true;
92
+ info.errorReason = 'Binary state file may be corrupted';
93
+ } else {
94
+ info.errorReason = 'File is empty';
95
+ }
96
+
97
+ return info;
98
+ };
99
+
100
+ // Calculate display size to fit terminal while maintaining aspect ratio
101
+ const calculateDisplaySize = (requestedWidth?: number, requestedHeight?: number): { width: number; height: number } => {
102
+ const termCols = process.stdout.columns || DEFAULT_TERMINAL_WIDTH_WIDE;
103
+ const termRows = process.stdout.rows || DEFAULT_TERMINAL_HEIGHT_TALL;
104
+
105
+ // Reserve 1 row for status line
106
+ const availableRows = termRows - 1;
107
+
108
+ return fitToTerminal({
109
+ availableCols: termCols,
110
+ availableRows,
111
+ aspectRatio: CHAR_ASPECT_RATIO_4_3,
112
+ requestedWidth,
113
+ requestedHeight,
114
+ });
115
+ };
116
+
117
+ /**
118
+ * Run the emulator for a given ROM
119
+ * @param resumeGame If true, skip save state dialog and resume directly
120
+ * @param resumeCoreId If provided, use this core (bypasses core selector when resuming)
121
+ * @param netplay If provided, netplay options from the UI (overrides CLI options)
122
+ * @returns Result indicating whether to continue and if the game was played
123
+ */
124
+ export const runEmulator = async (romPath: string, options: CliOptions, resumeGame?: boolean, resumeCoreId?: string, netplay?: NetplayOptions): Promise<RunEmulatorResult> => {
125
+ // Check if joining netplay (used for skipping dialogs)
126
+ // netplayConnect can be empty string for LAN discovery, so check !== undefined
127
+ const isJoiningNetplay = netplay?.mode === 'join' || options.netplayConnect !== undefined;
128
+
129
+ // Detect or validate core for the ROM
130
+ let coreFactory: CoreFactory | undefined;
131
+
132
+ if (options.core) {
133
+ // User specified a core explicitly via --core flag
134
+ let factory = getCoreFactory(options.core);
135
+
136
+ // If core not found, try lazy loading (for cores skipped during startup scan)
137
+ // mupen64plus is skipped during startup due to macOS loading issues
138
+ if (!factory && options.core.includes('mupen64plus')) {
139
+ const coresDir = getCoresDirectory();
140
+ const ext = process.platform === 'darwin' ? '.dylib' : process.platform === 'win32' ? '.dll' : '.so';
141
+ const corePath = join(coresDir, `${options.core}_libretro${ext}`);
142
+ if (existsSync(corePath)) {
143
+ const coreId = registerLibretroCore(corePath);
144
+ if (coreId) {
145
+ factory = getCoreFactory(coreId);
146
+ }
147
+ }
148
+ }
149
+
150
+ if (!factory) {
151
+ const errorMsg = `Unknown core '${options.core}'`;
152
+ console.error(`Error: ${errorMsg}`);
153
+ console.error("Use --list-cores to see available cores.");
154
+ logger.error(errorMsg, 'Core');
155
+ return { shouldContinue: false, gameWasPlayed: false };
156
+ }
157
+ coreFactory = factory;
158
+ } else if (resumeCoreId) {
159
+ // Resuming a game - use the same core that was used before
160
+ const factory = getCoreFactory(resumeCoreId);
161
+ if (!factory) {
162
+ // Core no longer available - fall back to normal selection
163
+ const warnMsg = `Core '${resumeCoreId}' no longer available, selecting alternative.`;
164
+ console.error(`Warning: ${warnMsg}`);
165
+ logger.warn(warnMsg, 'Core');
166
+ } else {
167
+ coreFactory = factory;
168
+ }
169
+ }
170
+
171
+ // If coreFactory not yet set, do normal core detection
172
+ if (!coreFactory) {
173
+ // Find all cores that support this file extension
174
+ const matchingCores = findMatchingCores(romPath);
175
+
176
+ if (matchingCores.length === 0) {
177
+ const supportedExts = getSupportedExtensions().join(", ");
178
+ const errorMsg = `Unsupported ROM format for '${romPath}'`;
179
+ console.error(`Error: ${errorMsg}`);
180
+ console.error(`Supported formats: ${supportedExts}`);
181
+ console.error("Use --list-cores to see available cores.");
182
+ logger.error(`${errorMsg}. Supported: ${supportedExts}`, 'Core');
183
+ return { shouldContinue: true, gameWasPlayed: false }; // Return to browser instead of exiting
184
+ } else if (matchingCores.length === 1) {
185
+ // Only one core matches - use it directly
186
+ coreFactory = matchingCores[0].factory;
187
+ } else if (isJoiningNetplay) {
188
+ // Netplay join mode - auto-select first matching core to skip dialog
189
+ // The netplay protocol will validate CRC anyway
190
+ coreFactory = matchingCores[0].factory;
191
+ } else {
192
+ // Check for a saved core preference for this extension
193
+ const ext = extname(romPath).toLowerCase();
194
+ const preferredId = getPreferredCoreId(ext);
195
+ const preferred = preferredId
196
+ ? matchingCores.find(c => c.id === preferredId)
197
+ : undefined;
198
+
199
+ if (preferred) {
200
+ coreFactory = preferred.factory;
201
+ } else {
202
+ // Multiple cores match and no saved preference - show selection dialog
203
+ const selection = await selectCore(matchingCores, basename(romPath), {
204
+ nativeMode: options.config.video_driver === 'native',
205
+ scaleFactor: options.config.menu_scale_factor,
206
+ });
207
+ if (!selection) {
208
+ // User cancelled - return to browser
209
+ return { shouldContinue: true, gameWasPlayed: false };
210
+ }
211
+ coreFactory = selection.factory;
212
+ if (selection.remember) {
213
+ setPreferredCoreId(ext, selection.id);
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ // Calculate display size (auto-fit to terminal if not specified) - for terminal mode
220
+ const displaySize = calculateDisplaySize(options.width, options.height);
221
+
222
+ const systemInfo = coreFactory.getSystemInfo();
223
+
224
+ // Check for saved state (unless disabled or joining netplay session)
225
+ // When joining netplay, the client receives state from the host
226
+ let shouldRestore = false;
227
+ let statePathToLoad: string | null = null;
228
+
229
+ if (options.enableSaveState && !isJoiningNetplay) {
230
+ const saveStateService = getSaveStateService();
231
+
232
+ // Find any existing save state (checks .state.auto first, then legacy formats)
233
+ const foundStatePath = saveStateService.findExistingStatePath(romPath);
234
+ const stateFileExists = foundStatePath !== null;
235
+ const isValidState = stateFileExists && validateStateFile(foundStatePath);
236
+
237
+ // If resumeGame is true and there's a valid state, skip dialogs and resume directly
238
+ if (resumeGame && isValidState) {
239
+ shouldRestore = true;
240
+ statePathToLoad = foundStatePath;
241
+ } else if (stateFileExists && !isValidState) {
242
+ // Corrupted save state - analyze and show detailed dialog
243
+ const corruptedInfo = analyzeCorruptedState(foundStatePath, basename(romPath));
244
+ const choice = await showCorruptedStateDialog(corruptedInfo, {
245
+ nativeMode: options.config.video_driver === 'native',
246
+ scaleFactor: options.config.menu_scale_factor,
247
+ });
248
+ if (choice === 'cancel') {
249
+ return { shouldContinue: true, gameWasPlayed: false };
250
+ } else if (choice === 'try_load') {
251
+ // User wants to try loading anyway - attempt it
252
+ shouldRestore = true;
253
+ statePathToLoad = foundStatePath;
254
+ }
255
+ // If 'continue', will start fresh and overwrite on save
256
+ } else if (isValidState) {
257
+ // Show save state dialog
258
+ const saveStateInfo: SaveStateInfo = {
259
+ path: foundStatePath,
260
+ romName: basename(romPath),
261
+ coreName: systemInfo.name,
262
+ };
263
+
264
+ // Reset stdin for the dialog
265
+ process.stdin.removeAllListeners();
266
+ if (process.stdin.isTTY) {
267
+ process.stdin.setRawMode(false);
268
+ }
269
+ process.stdin.pause();
270
+ await new Promise(resolve => setTimeout(resolve, STDIN_SETTLE_DELAY_MS));
271
+
272
+ const choice: SaveStateChoice = await showSaveStateDialog(saveStateInfo, {
273
+ nativeMode: options.config.video_driver === 'native',
274
+ scaleFactor: options.config.menu_scale_factor,
275
+ });
276
+
277
+ if (choice === 'cancel') {
278
+ return { shouldContinue: true, gameWasPlayed: false };
279
+ } else if (choice === 'delete') {
280
+ // Delete the save state file
281
+ saveStateService.deleteState(romPath);
282
+ } else {
283
+ // Resume
284
+ shouldRestore = true;
285
+ statePathToLoad = foundStatePath;
286
+ }
287
+ }
288
+ }
289
+
290
+ try {
291
+ // Only pass explicit dimensions if user specified them (enables auto-resize otherwise)
292
+ const explicitDimensions =
293
+ options.width !== undefined || options.height !== undefined;
294
+
295
+ // Create SettingsManager for centralized settings sync
296
+ const settingsManager = new SettingsManager(options.config, options.configPath);
297
+
298
+ const emulator = new Emulator({
299
+ romPath: romPath,
300
+ coreFactory: coreFactory,
301
+ width: explicitDimensions ? displaySize.width : undefined,
302
+ height: explicitDimensions ? displaySize.height : undefined,
303
+ colorEnabled: options.colorEnabled,
304
+ renderMode: options.renderMode,
305
+ scale: options.scale,
306
+ enableGamepad: options.enableGamepad,
307
+ enableAudio: options.enableAudio,
308
+ startMuted: options.startMuted,
309
+ enableSaveState: options.enableSaveState,
310
+ enableBatterySave: options.enableBatterySave,
311
+ showStatusBar: options.showStatusBar,
312
+ fpsLimit: options.fpsLimit,
313
+ enableDiffRendering: options.enableDiffRendering,
314
+ noRender: options.noRender,
315
+ frameLimit: options.frameLimit,
316
+ pngCompressionLevel: options.pngCompressionLevel,
317
+ gamma: options.gamma,
318
+ scanlines: options.scanlines,
319
+ saturation: options.saturation,
320
+ brightness: options.brightness,
321
+ contrast: options.contrast,
322
+ vignette: options.vignette,
323
+ bloom: options.bloom,
324
+ bloomThreshold: options.bloomThreshold,
325
+ ntsc: options.ntsc,
326
+ curvature: options.curvature,
327
+ chromaticAberration: options.chromaticAberration,
328
+ hasUserEffects: options.hasUserEffects,
329
+ config: options.config,
330
+ configPath: options.configPath,
331
+ settingsManager,
332
+ // Netplay options - UI options override CLI options
333
+ netplayHost: netplay?.mode === 'host' ? true : options.netplayHost,
334
+ netplayConnect: netplay?.mode === 'join' ? netplay.host : options.netplayConnect,
335
+ netplayPort: netplay?.port ?? options.netplayPort,
336
+ netplayPassword: netplay?.password ?? options.netplayPassword,
337
+ netplaySpectate: netplay?.spectate ?? options.netplaySpectate,
338
+ netplayNickname: netplay?.nickname ?? options.netplayNickname,
339
+ netplayInputDelay: netplay?.inputDelay ?? options.netplayInputDelay,
340
+ });
341
+
342
+ let stateLoaded = false;
343
+ if (shouldRestore && statePathToLoad) {
344
+ stateLoaded = await emulator.loadState(statePathToLoad);
345
+ }
346
+
347
+ // Set up signal handlers for graceful shutdown
348
+ const signalHandler = () => {
349
+ emulator.stop();
350
+ };
351
+ process.on('SIGINT', signalHandler);
352
+ process.on('SIGTERM', signalHandler);
353
+
354
+ try {
355
+ await emulator.run(stateLoaded);
356
+ } finally {
357
+ // Clean up signal handlers
358
+ process.removeListener('SIGINT', signalHandler);
359
+ process.removeListener('SIGTERM', signalHandler);
360
+ }
361
+ const sessionSeconds = emulator.getSessionSeconds();
362
+
363
+ // If netplay disconnected unexpectedly (not by user choice), show dialog and offer reconnection
364
+ if (emulator.wasNetplayDisconnected() && !emulator.wasIntentionalDisconnect()) {
365
+ const disconnectInfo = emulator.getNetplayDisconnectInfo();
366
+ const choice = await showNetplayDisconnectedDialog(
367
+ {
368
+ reason: disconnectInfo.reason,
369
+ host: disconnectInfo.host || undefined,
370
+ port: disconnectInfo.port || undefined,
371
+ },
372
+ {
373
+ nativeMode: options.config.video_driver === 'native',
374
+ scaleFactor: options.config.menu_scale_factor,
375
+ }
376
+ );
377
+
378
+ if (choice === 'reconnect') {
379
+ // Recursively call runEmulator to try reconnecting
380
+ return runEmulator(romPath, options, resumeGame, resumeCoreId, netplay);
381
+ }
382
+
383
+ if (choice === 'exit') {
384
+ // User pressed CTRL-C - exit the app entirely
385
+ return { shouldContinue: false, gameWasPlayed: true, coreId: systemInfo.id, sessionSeconds };
386
+ }
387
+
388
+ // User chose menu - return to browser with netplay panel
389
+ return { shouldContinue: true, gameWasPlayed: true, coreId: systemInfo.id, sessionSeconds, showNetplayOnReturn: true };
390
+ }
391
+
392
+ // If user intentionally disconnected from netplay, return to browser without disconnect dialog
393
+ if (emulator.wasIntentionalDisconnect()) {
394
+ return { shouldContinue: true, gameWasPlayed: true, coreId: systemInfo.id, sessionSeconds };
395
+ }
396
+
397
+ return { shouldContinue: true, gameWasPlayed: true, coreId: systemInfo.id, sessionSeconds };
398
+ } catch (error) {
399
+ const errorMsg = getErrorMessage(error);
400
+ console.error("Error:", errorMsg);
401
+ logger.error(errorMsg, 'Emulator');
402
+ // If netplay was requested and failed, show netplay panel on return to browser
403
+ const netplayWasRequested = isJoiningNetplay || options.netplayHost || !!netplay;
404
+ return { shouldContinue: true, gameWasPlayed: false, showNetplayOnReturn: netplayWasRequested };
405
+ }
406
+ };
@@ -0,0 +1,7 @@
1
+ export interface RunEmulatorResult {
2
+ shouldContinue: boolean; // true = return to browser, false = exit app
3
+ gameWasPlayed: boolean; // true = emulator ran, false = cancelled before running
4
+ coreId?: string; // Core ID that was used (for resume game feature)
5
+ sessionSeconds?: number; // Estimated runtime in seconds based on frame count
6
+ showNetplayOnReturn?: boolean; // true = show netplay panel when returning to browser
7
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Application-wide constants
3
+ */
4
+
5
+ declare const __APP_VERSION__: string;
6
+ declare const __BUILD_DATE__: string;
7
+
8
+ /** Application version (e.g., "0.1.0") */
9
+ export const VERSION =
10
+ typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
11
+
12
+ /** Build date in YYYYMMDD format (e.g., "20260121") */
13
+ export const BUILD_DATE =
14
+ typeof __BUILD_DATE__ !== 'undefined' ? __BUILD_DATE__ : '';
15
+
16
+ /** Application version with build date (e.g., "0.1.0 (20260121)") */
17
+ export const VERSION_WITH_DATE = BUILD_DATE
18
+ ? `${VERSION} (${BUILD_DATE})`
19
+ : VERSION;
@@ -0,0 +1,35 @@
1
+ import { StandardButton } from '.';
2
+
3
+ /**
4
+ * Default keyboard mappings for standard buttons.
5
+ * These are the keys that map to each standard button.
6
+ */
7
+ export const DEFAULT_KEYBOARD_MAP: Map<string, StandardButton> = new Map([
8
+ // Face buttons - primary mappings
9
+ ['k', StandardButton.A],
10
+ ['z', StandardButton.A],
11
+ ['j', StandardButton.B],
12
+ ['x', StandardButton.B],
13
+ ['i', StandardButton.X],
14
+ ['u', StandardButton.Y],
15
+
16
+ // Shoulder buttons
17
+ ['q', StandardButton.L],
18
+ ['e', StandardButton.R],
19
+
20
+ // Control buttons
21
+ ['Enter', StandardButton.Start],
22
+ [' ', StandardButton.Select],
23
+
24
+ // D-pad - WASD
25
+ ['w', StandardButton.Up],
26
+ ['s', StandardButton.Down],
27
+ ['a', StandardButton.Left],
28
+ ['d', StandardButton.Right],
29
+
30
+ // D-pad - Arrow keys
31
+ ['ArrowUp', StandardButton.Up],
32
+ ['ArrowDown', StandardButton.Down],
33
+ ['ArrowLeft', StandardButton.Left],
34
+ ['ArrowRight', StandardButton.Right],
35
+ ]);
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Standard Button Definitions
3
+ *
4
+ * These are the "physical" buttons that the frontend maps from keyboard/gamepad input.
5
+ * The InputMapper translates these to core-specific button IDs based on button names.
6
+ *
7
+ * This enum covers the superset of buttons across supported systems:
8
+ * - NES: A, B, Select, Start, D-pad (8 buttons)
9
+ * - GBA: A, B, L, R, Select, Start, D-pad (10 buttons)
10
+ * - SNES: A, B, X, Y, L, R, Select, Start, D-pad (12 buttons)
11
+ */
12
+
13
+ export enum StandardButton {
14
+ // Face buttons (right side of controller)
15
+ A = 0,
16
+ B = 1,
17
+ X = 2,
18
+ Y = 3,
19
+
20
+ // Shoulder buttons
21
+ L = 4,
22
+ R = 5,
23
+ L2 = 6,
24
+ R2 = 7,
25
+
26
+ // Control buttons (center)
27
+ Start = 8,
28
+ Select = 9,
29
+
30
+ // D-pad
31
+ Up = 10,
32
+ Down = 11,
33
+ Left = 12,
34
+ Right = 13,
35
+
36
+ // Analog sticks (for future use)
37
+ LeftStickUp = 14,
38
+ LeftStickDown = 15,
39
+ LeftStickLeft = 16,
40
+ LeftStickRight = 17,
41
+ RightStickUp = 18,
42
+ RightStickDown = 19,
43
+ RightStickLeft = 20,
44
+ RightStickRight = 21,
45
+ L3 = 22, // Left stick click
46
+ R3 = 23, // Right stick click
47
+
48
+ // System buttons
49
+ Guide = 24, // Xbox button / PlayStation button / Home button
50
+ }
51
+
52
+ /**
53
+ * Get the display name for a standard button
54
+ */
55
+ export const getButtonName = (button: StandardButton): string => {
56
+ switch (button) {
57
+ case StandardButton.A:
58
+ return 'A';
59
+ case StandardButton.B:
60
+ return 'B';
61
+ case StandardButton.X:
62
+ return 'X';
63
+ case StandardButton.Y:
64
+ return 'Y';
65
+ case StandardButton.L:
66
+ return 'L';
67
+ case StandardButton.R:
68
+ return 'R';
69
+ case StandardButton.L2:
70
+ return 'L2';
71
+ case StandardButton.R2:
72
+ return 'R2';
73
+ case StandardButton.Start:
74
+ return 'Start';
75
+ case StandardButton.Select:
76
+ return 'Select';
77
+ case StandardButton.Up:
78
+ return 'Up';
79
+ case StandardButton.Down:
80
+ return 'Down';
81
+ case StandardButton.Left:
82
+ return 'Left';
83
+ case StandardButton.Right:
84
+ return 'Right';
85
+ case StandardButton.LeftStickUp:
86
+ return 'LS Up';
87
+ case StandardButton.LeftStickDown:
88
+ return 'LS Down';
89
+ case StandardButton.LeftStickLeft:
90
+ return 'LS Left';
91
+ case StandardButton.LeftStickRight:
92
+ return 'LS Right';
93
+ case StandardButton.RightStickUp:
94
+ return 'RS Up';
95
+ case StandardButton.RightStickDown:
96
+ return 'RS Down';
97
+ case StandardButton.RightStickLeft:
98
+ return 'RS Left';
99
+ case StandardButton.RightStickRight:
100
+ return 'RS Right';
101
+ case StandardButton.L3:
102
+ return 'L3';
103
+ case StandardButton.R3:
104
+ return 'R3';
105
+ case StandardButton.Guide:
106
+ return 'Guide';
107
+ default:
108
+ return `Button ${button}`;
109
+ }
110
+ };
111
+
112
+
113
+ /**
114
+ * Check if two D-pad directions are opposite (for preventing simultaneous press)
115
+ */
116
+ export const areOppositeDirections = (a: StandardButton, b: StandardButton): boolean => (a === StandardButton.Up && b === StandardButton.Down) ||
117
+ (a === StandardButton.Down && b === StandardButton.Up) ||
118
+ (a === StandardButton.Left && b === StandardButton.Right) ||
119
+ (a === StandardButton.Right && b === StandardButton.Left);
120
+
121
+ // Re-export consts after enum definition to avoid circular dependency
122
+ // (consts.ts references StandardButton values)
123
+ export * from './consts';