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
package/src/index.ts ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { VERSION_WITH_DATE as VERSION, BUILD_DATE } from "./consts";
4
+ import { statSync } from "fs";
5
+ import { expandPath } from "./utils/paths";
6
+ import { detectKittyGraphicsSupport } from "./utils/kitty";
7
+ import { showWarningDialog } from "./ui";
8
+ import { logger } from "./utils/logger";
9
+ import { cpus } from "os";
10
+ import { loadConfig, getPlaylistsDirectory } from "./frontend/config";
11
+ import { updateServices } from "./frontend/serviceProvider";
12
+
13
+ import { isFensterAvailable, getWindowManager } from "./rendering/nativeUi";
14
+ import {
15
+ generatePlaylistsBySystem,
16
+ updatePlaylistRuntime,
17
+ buildPlaylistIndex,
18
+ normalizePath,
19
+ } from "./frontend/playlist";
20
+
21
+ // Load libretro cores from default paths (not RetroArch - use --retroarch flag for that)
22
+ import {
23
+ loadDefaultLibretroCores,
24
+ loadRetroArchCores,
25
+ loadCoresFromConfig,
26
+ } from "./cores/libretro/loader";
27
+ import { launchBrowser, importDirectory, validateRomFile } from "./ui";
28
+ import type { RomInfo } from "./ui";
29
+ import { STDIN_SETTLE_DELAY_MS } from "./ui";
30
+
31
+ import { parseArgs, updateOptionsFromConfig } from "./cli/parseArgs";
32
+ import {
33
+ printUsage,
34
+ debugGamepad,
35
+ listGamepads,
36
+ listCoresCommand,
37
+ installCoreCommand,
38
+ removeCoreCommand,
39
+ clearLogsCommand,
40
+ generatePlaylistCommand,
41
+ } from "./cli/commands";
42
+ import { runEmulator } from "./cli/runEmulator";
43
+
44
+ loadDefaultLibretroCores();
45
+
46
+ // Build date format constants (YYYYMMDD)
47
+ const BUILD_DATE_LENGTH = 8;
48
+ const BUILD_DATE_YEAR_END = 4;
49
+ const BUILD_DATE_MONTH_END = 6;
50
+
51
+ /**
52
+ * Format build date from YYYYMMDD to "Mon DD YYYY" format
53
+ * e.g., "20260121" -> "Jan 21 2026"
54
+ */
55
+ const formatBuildDate = (dateStr: string): string => {
56
+ if (!dateStr || dateStr.length !== BUILD_DATE_LENGTH) {
57
+ return '';
58
+ }
59
+ const year = parseInt(dateStr.slice(0, BUILD_DATE_YEAR_END), 10);
60
+ const month = parseInt(dateStr.slice(BUILD_DATE_YEAR_END, BUILD_DATE_MONTH_END), 10) - 1;
61
+ const day = parseInt(dateStr.slice(BUILD_DATE_MONTH_END, BUILD_DATE_LENGTH), 10);
62
+ const date = new Date(year, month, day);
63
+ if (isNaN(date.getTime())) {
64
+ return '';
65
+ }
66
+ // Format without comma: "Jan 21 2026"
67
+ const formatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
68
+ const parts = formatter.formatToParts(date);
69
+ const monthPart = parts.find(p => p.type === 'month')?.value ?? '';
70
+ const dayPart = parts.find(p => p.type === 'day')?.value ?? '';
71
+ const yearPart = parts.find(p => p.type === 'year')?.value ?? '';
72
+ return `${monthPart} ${dayPart} ${yearPart}`;
73
+ };
74
+
75
+ const main = async (): Promise<void> => {
76
+ const args = process.argv.slice(2);
77
+ const options = parseArgs(args);
78
+
79
+ // Ensure the shared native window is torn down on any exit path.
80
+ process.on("exit", () => {
81
+ const wm = getWindowManager();
82
+ if (wm.isInitialized()) {
83
+ wm.destroy();
84
+ }
85
+ });
86
+
87
+ // Check native window availability if native mode is requested
88
+ const nativeRequested = options.renderMode === "native" || options.config.video_driver === "native";
89
+ if (nativeRequested && !isFensterAvailable()) {
90
+ const warnMsg = "Native window mode is not available. Falling back to Kitty graphics protocol.";
91
+ logger.warn(warnMsg, "Video");
92
+ const choice = await showWarningDialog(warnMsg, { title: "Native Mode Not Available" });
93
+ if (choice === "exit") {
94
+ process.exit(1);
95
+ }
96
+ if (options.renderMode === "native") {
97
+ options.renderMode = "kitty";
98
+ }
99
+ if (options.config.video_driver === "native") {
100
+ options.config.video_driver = "kitty";
101
+ }
102
+ }
103
+
104
+ // Check Kitty graphics support if using Kitty mode (explicit or auto/default)
105
+ const willUseKitty = options.renderMode === "kitty" ||
106
+ options.renderMode === undefined ||
107
+ options.config.video_driver === "kitty" ||
108
+ options.config.video_driver === null;
109
+ if (willUseKitty && !await detectKittyGraphicsSupport()) {
110
+ const warnMsg = "Your terminal does not support the Kitty graphics protocol. " +
111
+ "For the best experience, we recommend using a terminal that supports it:\n\n" +
112
+ " \u2022 Ghostty (recommended): https://ghostty.org\n" +
113
+ " \u2022 iTerm2: https://iterm2.com\n" +
114
+ " \u2022 kitty: https://sw.kovidgoyal.net/kitty\n\n" +
115
+ "The emulator will fall back to Unicode half-block rendering.";
116
+ logger.warn("Terminal does not support Kitty graphics protocol", "Video");
117
+ const choice = await showWarningDialog(warnMsg, { title: "Terminal Compatibility" });
118
+ if (choice === "exit") {
119
+ process.exit(1);
120
+ }
121
+ // Fall back to terminal (Unicode half-block) mode
122
+ if (options.renderMode === "kitty" || options.renderMode === undefined) {
123
+ options.renderMode = "terminal";
124
+ }
125
+ if (options.config.video_driver === "kitty" || options.config.video_driver === null) {
126
+ options.config.video_driver = "terminal";
127
+ }
128
+ }
129
+
130
+ // Configure logging from config options
131
+ // Set custom log directory if specified (with ~ expansion)
132
+ if (options.config.log_dir) {
133
+ logger.setLogDirectory(expandPath(options.config.log_dir));
134
+ }
135
+
136
+ // Set whether to log to file or console
137
+ logger.setLogToFile(options.config.log_to_file);
138
+
139
+ // Set timestamped file mode (only applies when log_to_file is true)
140
+ logger.setUseTimestampedFile(options.config.log_to_file_timestamp);
141
+
142
+ // Enable logging based on config (log_verbosity)
143
+ logger.setEnabled(options.config.log_verbosity);
144
+
145
+ // Enable stderr output if --verbose flag is set
146
+ if (options.verbose) {
147
+ logger.setEnabled(true); // --verbose implies logging enabled
148
+ logger.setLogToStderr(true);
149
+ }
150
+
151
+ // Log startup information (RetroArch-style)
152
+ logger.info(`emoemu ${VERSION}`, 'emoemu');
153
+ logger.info('=== Build =======================================');
154
+ logger.info(`Version: ${VERSION}`);
155
+ const builtDate = formatBuildDate(BUILD_DATE);
156
+ if (builtDate) {
157
+ logger.info(`Built: ${builtDate}`);
158
+ }
159
+ logger.info(`CPU Model Name: ${cpus()[0]?.model ?? 'Unknown'}`);
160
+ logger.info(`Node.js: ${process.version}`);
161
+ logger.info('=================================================');
162
+
163
+ // Handle --clear-logs early (clears logs and continues)
164
+ if (options.clearLogs) {
165
+ clearLogsCommand();
166
+ }
167
+
168
+ // Load cores from config's libretro_directory if specified (RetroArch-compatible)
169
+ if (options.config.libretro_directory) {
170
+ loadCoresFromConfig(options.config.libretro_directory);
171
+ }
172
+
173
+ // Load RetroArch cores if requested (before listing or detecting cores)
174
+ if (options.loadRetroArch) {
175
+ loadRetroArchCores();
176
+ }
177
+
178
+ // Handle --list-gamepads before checking for ROM
179
+ if (options.listGamepads) {
180
+ listGamepads();
181
+ process.exit(0);
182
+ }
183
+
184
+ // Handle --list-cores before checking for ROM
185
+ if (options.listCoresFlag) {
186
+ listCoresCommand();
187
+ process.exit(0);
188
+ }
189
+
190
+ // Handle --install-core before checking for ROM
191
+ if (options.installCore) {
192
+ await installCoreCommand(options.installCore);
193
+ // If no ROM specified, exit after installation
194
+ if (!options.romPath) {
195
+ process.exit(0);
196
+ }
197
+ // Otherwise continue to run the emulator with the installed core
198
+ }
199
+
200
+ // Handle --remove-core before checking for ROM
201
+ if (options.removeCore) {
202
+ removeCoreCommand(options.removeCore);
203
+ process.exit(0);
204
+ }
205
+
206
+ // Handle --debug-gamepad before checking for ROM
207
+ if (options.debugGamepad) {
208
+ debugGamepad();
209
+ // debugGamepad() doesn't return - runs until Ctrl+C
210
+ return;
211
+ }
212
+
213
+ // Handle --generate-playlist before checking for ROM
214
+ if (options.generatePlaylist) {
215
+ const scanPath = typeof options.generatePlaylist === 'string'
216
+ ? options.generatePlaylist
217
+ : process.cwd();
218
+ generatePlaylistCommand(
219
+ scanPath,
220
+ options.scanDepth,
221
+ options.playlistOutput,
222
+ options.singlePlaylist,
223
+ options.windowsPaths
224
+ );
225
+ process.exit(0);
226
+ }
227
+
228
+ if (options.help) {
229
+ printUsage();
230
+ process.exit(0);
231
+ }
232
+
233
+ if (options.showVersion) {
234
+ console.log(`emoemu ${VERSION}`);
235
+ process.exit(0);
236
+ }
237
+
238
+ // Track the last played ROM and filter for restoring browser state
239
+ let lastPlayedRom: string | undefined;
240
+ let lastPlayedRomInfo: RomInfo | undefined;
241
+ let lastPlayedCoreId: string | undefined; // Core ID used for last played game (for resume)
242
+ let lastFilter: string | undefined;
243
+ let showSettingsOnMount = false; // Show settings menu after exiting a game
244
+ let showNetplayOnMount = false; // Show netplay panel after netplay connection failure
245
+
246
+ // Handle CLI path argument (directory or ROM file)
247
+ if (options.romPath) {
248
+ const playlistDir = getPlaylistsDirectory(options.config);
249
+
250
+ try {
251
+ const stats = statSync(options.romPath);
252
+ if (stats.isDirectory()) {
253
+ // Directory provided: import ROMs with progress bar UI, then show browser
254
+ await importDirectory(options.romPath, options.scanDepth, options.config);
255
+ } else {
256
+ // ROM file provided: auto-import silently and launch immediately
257
+ const playlistIndex = buildPlaylistIndex(playlistDir);
258
+ const normalizedPath = normalizePath(options.romPath);
259
+ if (!playlistIndex.has(normalizedPath)) {
260
+ const validateResult = validateRomFile(options.romPath);
261
+ if (validateResult.valid) {
262
+ generatePlaylistsBySystem([validateResult.rom], playlistDir);
263
+ }
264
+ }
265
+
266
+ const result = await runEmulator(options.romPath, options);
267
+ if (!result.shouldContinue) {
268
+ // Still update runtime even when exiting (user might press Esc to quit)
269
+ if (result.gameWasPlayed && result.sessionSeconds !== undefined) {
270
+ updatePlaylistRuntime(options.romPath, playlistDir, result.sessionSeconds);
271
+ }
272
+ process.exit(0);
273
+ }
274
+ // Check if netplay disconnected - show netplay panel instead of settings
275
+ if (result.showNetplayOnReturn) {
276
+ showNetplayOnMount = true;
277
+ showSettingsOnMount = false;
278
+ lastPlayedRom = options.romPath;
279
+ lastPlayedCoreId = result.coreId;
280
+ } else if (result.gameWasPlayed) {
281
+ lastPlayedRom = options.romPath;
282
+ lastPlayedCoreId = result.coreId; // Track which core was used
283
+ // Get RomInfo for the resume game feature
284
+ const validateResult = validateRomFile(options.romPath);
285
+ if (validateResult.valid) {
286
+ lastPlayedRomInfo = validateResult.rom;
287
+ }
288
+ showSettingsOnMount = true; // Show settings when returning from a game
289
+
290
+ // Update playlist runtime (RetroArch compatible)
291
+ if (result.sessionSeconds !== undefined) {
292
+ updatePlaylistRuntime(options.romPath, playlistDir, result.sessionSeconds);
293
+ }
294
+ }
295
+ }
296
+ } catch {
297
+ // Path doesn't exist - treat as ROM path and let runEmulator handle the error
298
+ const playlistIndex = buildPlaylistIndex(playlistDir);
299
+ const normalizedPath = normalizePath(options.romPath);
300
+ if (!playlistIndex.has(normalizedPath)) {
301
+ const validateResult = validateRomFile(options.romPath);
302
+ if (validateResult.valid) {
303
+ generatePlaylistsBySystem([validateResult.rom], playlistDir);
304
+ }
305
+ }
306
+
307
+ const result = await runEmulator(options.romPath, options);
308
+ if (!result.shouldContinue) {
309
+ if (result.gameWasPlayed && result.sessionSeconds !== undefined) {
310
+ updatePlaylistRuntime(options.romPath, playlistDir, result.sessionSeconds);
311
+ }
312
+ process.exit(0);
313
+ }
314
+ // Check if netplay disconnected - show netplay panel instead of settings
315
+ if (result.showNetplayOnReturn) {
316
+ showNetplayOnMount = true;
317
+ showSettingsOnMount = false;
318
+ lastPlayedRom = options.romPath;
319
+ lastPlayedCoreId = result.coreId;
320
+ } else if (result.gameWasPlayed) {
321
+ lastPlayedRom = options.romPath;
322
+ lastPlayedCoreId = result.coreId;
323
+ const validateResult = validateRomFile(options.romPath);
324
+ if (validateResult.valid) {
325
+ lastPlayedRomInfo = validateResult.rom;
326
+ }
327
+ showSettingsOnMount = true;
328
+ if (result.sessionSeconds !== undefined) {
329
+ updatePlaylistRuntime(options.romPath, playlistDir, result.sessionSeconds);
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ // Main browser loop - user can only exit the app from here
336
+ for (;;) {
337
+ // Reset stdin state for Ink to take over
338
+ process.stdin.removeAllListeners('data');
339
+ process.stdin.removeAllListeners('keypress');
340
+ process.stdin.removeAllListeners('readable');
341
+
342
+ // Small delay to let event loop settle before launching browser
343
+ await new Promise(resolve => setTimeout(resolve, STDIN_SETTLE_DELAY_MS));
344
+
345
+ const result = await launchBrowser(options.scanDepth, lastPlayedRom, lastFilter, options.config, options.configPath, showSettingsOnMount, lastPlayedRomInfo, lastPlayedCoreId, showNetplayOnMount);
346
+
347
+ // Reset mount flags after launching (only show once)
348
+ showSettingsOnMount = false;
349
+ showNetplayOnMount = false;
350
+
351
+ // Always track the filter for next time
352
+ lastFilter = result.filter;
353
+
354
+ // Check if user triggered a refresh (e.g., after adding ROMs)
355
+ if (result.shouldRefresh) {
356
+ continue; // Re-launch browser with fresh ROM list
357
+ }
358
+
359
+ if (!result.path) {
360
+ // User exited browser - quit the app
361
+ process.exit(0);
362
+ }
363
+
364
+ // Thoroughly reset stdin for emulator to take over
365
+ // Remove all listeners that Ink may have attached
366
+ process.stdin.removeAllListeners();
367
+
368
+ // Drain any pending input that might be buffered
369
+ process.stdin.read();
370
+
371
+ // Reset TTY state - must be done in this order
372
+ if (process.stdin.isTTY) {
373
+ process.stdin.setRawMode(false);
374
+ }
375
+ process.stdin.pause();
376
+
377
+ // Small delay for stdin to fully settle
378
+ await new Promise(resolve => setTimeout(resolve, STDIN_SETTLE_DELAY_MS));
379
+
380
+ // Now pre-configure stdin for the emulator
381
+ // The emulator's setupStdin will set rawMode to true, but we need stdin resumed first
382
+ process.stdin.resume();
383
+
384
+ // Reload config from disk in case settings were changed in the browser
385
+ const { config: freshConfig } = loadConfig(options.configPath);
386
+ options.config = freshConfig;
387
+ updateServices(freshConfig);
388
+
389
+ // Update runtime options from fresh config (for settings changed in browser)
390
+ updateOptionsFromConfig(options, freshConfig);
391
+
392
+ const emulatorResult = await runEmulator(result.path, options, result.resumeGame, result.resumeCoreId, result.netplay);
393
+
394
+ // Check if user wants to exit the app entirely (e.g., CTRL-C on netplay disconnect dialog)
395
+ if (!emulatorResult.shouldContinue) {
396
+ process.exit(0);
397
+ }
398
+
399
+ // Check if netplay failed - show netplay panel instead of settings
400
+ if (emulatorResult.showNetplayOnReturn) {
401
+ showNetplayOnMount = true;
402
+ showSettingsOnMount = false;
403
+ // Still track last played ROM so it's selected when netplay panel opens
404
+ lastPlayedRom = result.path;
405
+ lastPlayedCoreId = emulatorResult.coreId;
406
+ } else if (emulatorResult.gameWasPlayed) {
407
+ // Only update state if game was actually played (not cancelled from dialog)
408
+ // Track for next browser launch
409
+ lastPlayedRom = result.path;
410
+ lastPlayedCoreId = emulatorResult.coreId; // Track which core was used
411
+ // Get RomInfo for the resume game feature
412
+ const validateResult = validateRomFile(result.path);
413
+ if (validateResult.valid) {
414
+ lastPlayedRomInfo = validateResult.rom;
415
+ }
416
+ // Show settings menu when returning from a game
417
+ showSettingsOnMount = true;
418
+
419
+ // Update playlist runtime (RetroArch compatible)
420
+ if (emulatorResult.sessionSeconds !== undefined) {
421
+ const playlistDir = getPlaylistsDirectory(freshConfig);
422
+ updatePlaylistRuntime(result.path, playlistDir, emulatorResult.sessionSeconds);
423
+ }
424
+ }
425
+ }
426
+ };
427
+
428
+ void main();
@@ -0,0 +1,50 @@
1
+ import { Button } from '.';
2
+
3
+ // Default keyboard mappings
4
+ // Layout designed for comfortable two-handed play:
5
+ // Left hand: WASD (D-pad), Q/E (L/R shoulders), Space (Select)
6
+ // Right hand: IJKL cluster (Y/X/A/B face buttons), Enter (Start)
7
+ export const DEFAULT_KEY_MAP: Record<string, Button> = {
8
+ // WASD for D-Pad
9
+ w: Button.Up,
10
+ s: Button.Down,
11
+ a: Button.Left,
12
+ d: Button.Right,
13
+ W: Button.Up,
14
+ S: Button.Down,
15
+ A: Button.Left,
16
+ D: Button.Right,
17
+
18
+ // Arrow keys (escape sequences from terminal)
19
+ '\u001b[A': Button.Up,
20
+ '\u001b[B': Button.Down,
21
+ '\u001b[D': Button.Left,
22
+ '\u001b[C': Button.Right,
23
+
24
+ // Face buttons - IJKL cluster (matches SNES diamond layout)
25
+ // I = top (X), J = left (Y), K = bottom (B), L = right (A)
26
+ i: Button.X,
27
+ I: Button.X,
28
+ j: Button.Y,
29
+ J: Button.Y,
30
+ k: Button.B,
31
+ K: Button.B,
32
+ l: Button.A,
33
+ L: Button.A,
34
+
35
+ // Alternative face buttons (Z/X for B/A like NES emulators)
36
+ z: Button.B,
37
+ Z: Button.B,
38
+ x: Button.A,
39
+ X: Button.A,
40
+
41
+ // Shoulder buttons
42
+ q: Button.L,
43
+ Q: Button.L,
44
+ e: Button.R,
45
+ E: Button.R,
46
+
47
+ // Start/Select
48
+ '\r': Button.Start, // Enter key
49
+ ' ': Button.Select, // Space key
50
+ };
@@ -0,0 +1,81 @@
1
+ import {
2
+ CONTROLLER_BUTTON_COUNT,
3
+ NES_BUTTON_COUNT,
4
+ CONTROLLER_SHIFT_REGISTER_HIGH_BIT,
5
+ } from '..';
6
+
7
+ export * from './consts';
8
+
9
+ export enum Button {
10
+ // NES buttons (0-7)
11
+ A = 0,
12
+ B = 1,
13
+ Select = 2,
14
+ Start = 3,
15
+ Up = 4,
16
+ Down = 5,
17
+ Left = 6,
18
+ Right = 7,
19
+ // SNES additional buttons (8-11)
20
+ X = 8,
21
+ Y = 9,
22
+ L = 10,
23
+ R = 11,
24
+ }
25
+
26
+ export class Controller {
27
+ private buttons: boolean[] = new Array<boolean>(CONTROLLER_BUTTON_COUNT).fill(false);
28
+ private shiftRegister: number = 0;
29
+ private strobe: boolean = false;
30
+
31
+ setButton(button: Button, pressed: boolean): void {
32
+ this.buttons[button] = pressed;
33
+ }
34
+
35
+ getButton(button: Button): boolean {
36
+ return this.buttons[button];
37
+ }
38
+
39
+ // Called when writing to $4016
40
+ write(data: number): void {
41
+ this.strobe = (data & 1) !== 0;
42
+ if (this.strobe) {
43
+ this.reload();
44
+ }
45
+ }
46
+
47
+ // Called when reading from $4016 or $4017
48
+ read(): number {
49
+ if (this.strobe) {
50
+ return this.buttons[Button.A] ? 1 : 0;
51
+ }
52
+
53
+ const value = (this.shiftRegister & 1);
54
+ this.shiftRegister >>= 1;
55
+ this.shiftRegister |= CONTROLLER_SHIFT_REGISTER_HIGH_BIT; // Fill with 1s after all bits read
56
+
57
+ return value;
58
+ }
59
+
60
+ private reload(): void {
61
+ this.shiftRegister = 0;
62
+ for (let i = 0; i < NES_BUTTON_COUNT; i++) {
63
+ if (this.buttons[i]) {
64
+ this.shiftRegister |= (1 << i);
65
+ }
66
+ }
67
+ }
68
+
69
+ // Get string representation of pressed buttons for display
70
+ getPressedButtons(): string {
71
+ const buttonNames = ['A', 'B', 'Sel', 'Sta', '\u2191', '\u2193', '\u2190', '\u2192', 'X', 'Y', 'L', 'R'];
72
+ const pressed: string[] = [];
73
+ for (let i = 0; i < CONTROLLER_BUTTON_COUNT; i++) {
74
+ if (this.buttons[i]) {
75
+ pressed.push(buttonNames[i]);
76
+ }
77
+ }
78
+ return pressed.length > 0 ? pressed.join(' ') : '-';
79
+ }
80
+ }
81
+
@@ -0,0 +1,22 @@
1
+ import { StandardButton } from '../../core/button';
2
+
3
+ /**
4
+ * All StandardButton values we need to track
5
+ */
6
+ export const ALL_STANDARD_BUTTONS: StandardButton[] = [
7
+ StandardButton.A,
8
+ StandardButton.B,
9
+ StandardButton.X,
10
+ StandardButton.Y,
11
+ StandardButton.L,
12
+ StandardButton.R,
13
+ StandardButton.L2,
14
+ StandardButton.R2,
15
+ StandardButton.Start,
16
+ StandardButton.Select,
17
+ StandardButton.Up,
18
+ StandardButton.Down,
19
+ StandardButton.Left,
20
+ StandardButton.Right,
21
+ StandardButton.Guide,
22
+ ];