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,2496 @@
1
+ import { clamp } from 'remeda';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { basename, extname } from 'path';
4
+ import { Controller, Button } from '../input/Controller';
5
+ import { InputManager } from '../input/InputManager';
6
+ import { InputMapper } from '../input/InputMapper';
7
+ import { GamepadManager } from '../input/GamepadManager';
8
+ import { StandardButton } from '../core/button';
9
+ import { TerminalRenderer } from '../rendering/TerminalRenderer';
10
+ import { KittyRenderer } from '../rendering/KittyRenderer';
11
+ import { NativeRenderer } from '../rendering/NativeRenderer';
12
+ import { isRgb24Buffer, type Core, type SystemInfo, type CoreMessage } from '../core/core';
13
+ import { NetplayServer, createNetplayServer } from '../netplay/NetplayServer';
14
+ import { NetplayClient, createNetplayClient } from '../netplay/NetplayClient';
15
+ import { NetplayError, type NetplayServerOptions, type NetplayClientOptions } from '../netplay';
16
+ import { crc32 } from '../netplay/crc32';
17
+ import { DiscoveryListener } from '../netplay/NetplayDiscovery';
18
+ import { netplayLogger } from '../netplay/netplayLogger';
19
+ import {
20
+ DEFAULT_PORT as NETPLAY_DEFAULT_PORT,
21
+ MAX_CLIENTS as NETPLAY_MAX_CLIENTS,
22
+ ROLLBACK_NOTIFICATION_THRESHOLD,
23
+ DISCOVERY_QUERY_DELAY_MS,
24
+ DISCOVERY_TIMEOUT_MS,
25
+ } from '../netplay';
26
+ import { type Config } from '../frontend/config';
27
+ import { getDefaultCoreOptions } from '../cores/libretro/coreOptions';
28
+ import { SettingsManager, type RenderMode } from '../frontend/SettingsManager';
29
+ import { getRomTitle } from '../frontend/romScanner';
30
+ import { getSystemName } from '../frontend/playlist';
31
+ import {
32
+ notify,
33
+ subscribeToNotifications,
34
+ unsubscribeFromNotifications,
35
+ type AppNotification,
36
+ } from '../frontend/notifications';
37
+ import { showNetplayPauseMenu, type PauseMenuChoice } from '../ui/NetplayPauseMenu';
38
+ import { logger } from '../utils/logger';
39
+ import { getErrorMessage } from '../utils/getErrorMessage';
40
+ import {
41
+ LIBRETRO_BOOTSTRAP_FRAMES,
42
+ MAX_FRAME_SKIP,
43
+ MS_PER_SECOND,
44
+ SAMPLE_RATE_44100,
45
+ SAMPLE_RATE_48000,
46
+ AUDIO_FRAME_DURATION_SEC,
47
+ AUDIO_STEREO_CHANNELS,
48
+ BYTES_PER_INT16_SAMPLE,
49
+ AUDIO_RING_BUFFER_FRAMES,
50
+ INT16_MAX_VALUE,
51
+ MAX_AUDIO_QUEUED_FRAMES,
52
+ BYTES_PER_STEREO_SAMPLE,
53
+ RTAUDIO_RECOVERABLE_ERROR_THRESHOLD,
54
+ AUDIO_RECOVERY_DELAY_MS,
55
+ FLOAT_COMPARE_EPSILON,
56
+ STEREO_NEXT_RIGHT_OFFSET,
57
+ GAMEPAD_DIALOG_POLL_INTERVAL_MS,
58
+ ASPECT_RATIO_DECIMALS,
59
+ } from '../frontend';
60
+ import {
61
+ DEFAULT_BLOOM_THRESHOLD,
62
+ DEFAULT_GAMMA,
63
+ DEFAULT_SATURATION,
64
+ DEFAULT_BRIGHTNESS,
65
+ DEFAULT_CONTRAST,
66
+ DEFAULT_SCANLINES,
67
+ DEFAULT_VIGNETTE,
68
+ DEFAULT_BLOOM,
69
+ DEFAULT_NTSC,
70
+ DEFAULT_CURVATURE,
71
+ DEFAULT_CHROMATIC_ABERRATION,
72
+ DEFAULT_NATIVE_SCALE,
73
+ getDefaultScaleForSystem,
74
+ getDefaultRenderModeForSystem,
75
+ } from '../rendering';
76
+ import { getTerminalDimensions } from '../utils/terminal';
77
+ import pkg from 'audify';
78
+ const { RtAudio, RtAudioFormat } = pkg;
79
+
80
+ // Sub-module imports
81
+ import type { PostProcessingMode, EffectValues, Renderer, EmulatorOptions } from './types';
82
+ import {
83
+ BOUNDS_CHECK_INTERVAL_INITIAL,
84
+ BOUNDS_CHECK_INTERVAL_LATER,
85
+ BOUNDS_CHECK_MAX_COUNT,
86
+ BOUNDS_CHECK_INITIAL_COUNT,
87
+ AUTO_SAVE_INTERVAL_MS,
88
+ STATUS_BAR_UPDATE_INTERVAL,
89
+ DEFAULT_MESSAGE_DURATION_MS,
90
+ } from './consts';
91
+ import { calculateTerminalDimensions } from './terminalDimensions';
92
+ import {
93
+ takeScreenshot as takeScreenshotFn,
94
+ saveThumbnailScreenshot as saveThumbnailScreenshotFn,
95
+ } from './screenshot';
96
+ import {
97
+ getStatePath as getStatePathFn,
98
+ loadBatterySave as loadBatterySaveFn,
99
+ saveBatterySave as saveBatterySaveFn,
100
+ hasSavedState as hasSavedStateFn,
101
+ saveState as saveStateFn,
102
+ loadStateFromFile,
103
+ deleteSavedState as deleteSavedStateFn,
104
+ } from './saveState';
105
+
106
+ // Re-export types and consts from sub-modules
107
+ export * from './types';
108
+ export * from './consts';
109
+
110
+ export class Emulator {
111
+ // Properties assigned in initializeCore()
112
+ private core!: Core;
113
+ private systemInfo!: SystemInfo;
114
+ private targetFrameTime!: number;
115
+
116
+ // Properties assigned in initializeInput()
117
+ private controller1!: Controller;
118
+ private controller2!: Controller;
119
+ private inputManager!: InputManager;
120
+ private inputMapper!: InputMapper;
121
+ private gamepadManager: GamepadManager | null = null;
122
+ private keyboardAnalogActive: boolean = false; // Track keyboard->analog for proper release
123
+
124
+ // Properties assigned in initializeRenderer()
125
+ private renderer!: Renderer;
126
+ private renderMode!: RenderMode;
127
+ private rtAudio: InstanceType<typeof RtAudio> | null = null;
128
+ private audioCallback: ((samples: Float32Array) => void) | null = null;
129
+ private audioEnabled: boolean = true;
130
+ private saveStateEnabled: boolean = true;
131
+ private batterySaveEnabled: boolean = true;
132
+ private autoResize: boolean = false; // Whether to handle terminal resize events
133
+ private showStatusBar: boolean = true;
134
+ private diffRenderingEnabled: boolean = true; // Diff-based rendering for terminal/ascii/emoji modes
135
+ private noRender: boolean = false; // Disable video rendering output (for debugging)
136
+ private frameLimit: number = 0; // Limit rendering to N fps (0=off/unlimited)
137
+ private lastRenderTime: number = 0; // Timestamp of last render for frame limiting
138
+ private renderInterval: number = 0; // Minimum ms between renders (calculated from frameLimit)
139
+ private colorEnabled: boolean = true; // Color mode (false = grayscale)
140
+ private kittyScale: number = 2; // Scale factor for Kitty renderer (0.25-4)
141
+ private nativeScale: number = DEFAULT_NATIVE_SCALE; // Scale factor for native renderer
142
+ private gamma: number = DEFAULT_GAMMA; // Gamma correction for Kitty mode
143
+ private scanlines: number = DEFAULT_SCANLINES; // Scanline intensity for Kitty mode
144
+ private saturation: number = DEFAULT_SATURATION; // Color saturation for Kitty mode
145
+ private brightness: number = DEFAULT_BRIGHTNESS; // Brightness multiplier for Kitty mode
146
+ private contrast: number = DEFAULT_CONTRAST; // Contrast multiplier for Kitty mode
147
+ private vignette: number = DEFAULT_VIGNETTE; // Vignette intensity for Kitty mode
148
+ private bloom: number = DEFAULT_BLOOM; // Bloom/glow intensity for Kitty mode
149
+ private bloomThreshold: number = DEFAULT_BLOOM_THRESHOLD; // Brightness threshold for bloom
150
+ private ntsc: number = DEFAULT_NTSC; // NTSC artifact intensity for Kitty mode
151
+ private curvature: number = DEFAULT_CURVATURE; // CRT curvature for Kitty mode
152
+ private chromaticAberration: number = DEFAULT_CHROMATIC_ABERRATION; // Chromatic aberration for Kitty mode
153
+ private postProcessingMode: PostProcessingMode = 'off'; // Current post-processing mode
154
+ private hasCustomEffects: boolean = false; // Whether user has custom effect values defined
155
+ private customEffectValues: EffectValues | null = null; // User's custom effect values (from config or CLI)
156
+ private romPath: string;
157
+ private config: Config | null = null; // Config for reading values (CRT presets, directories)
158
+ private settingsManager: SettingsManager | null = null; // Centralized settings manager
159
+ private settingsUnsubscribers: (() => void)[] = []; // Cleanup functions for settings listeners
160
+
161
+ private running: boolean = false;
162
+ private frameCount: number = 0;
163
+ private needsContentBoundsDetection: boolean = false; // Detect content bounds on first contentful frame
164
+ private lastBoundsCheckFrame: number = 0; // Frame when bounds were last checked
165
+ private boundsCheckCount: number = 0; // Number of times bounds have been checked
166
+ private lastFrameTime: number = 0;
167
+ private resizeHandler: (() => void) | null = null;
168
+ private inputHandler: ((key: string) => void) | null = null;
169
+ private notificationHandler: ((notification: AppNotification) => void) | null = null;
170
+ private autoSaveInterval: ReturnType<typeof setInterval> | null = null;
171
+
172
+ // Status bar throttling - update every N frames to reduce string building overhead
173
+ private statusBarFrameCounter: number = 0;
174
+
175
+ // FPS tracking - rolling average over 1 second window
176
+ private fpsFrameCount: number = 0;
177
+ private fpsWindowStart: number = 0;
178
+ private currentFps: number = 0;
179
+ // Render FPS tracking (frames actually drawn to terminal)
180
+ private renderFpsFrameCount: number = 0;
181
+ private currentRenderFps: number = 0;
182
+
183
+ // Core message display
184
+ private currentMessage: CoreMessage | null = null;
185
+ private messageExpiry: number = 0; // Timestamp when message should disappear
186
+
187
+ // Netplay
188
+ private netplayServer: NetplayServer | null = null;
189
+ private netplayClient: NetplayClient | null = null;
190
+ private netplayOptions: EmulatorOptions | null = null; // Store for deferred netplay init
191
+ private netplayMergedInput: number[] | null = null; // Input merged from local + remote for current frame
192
+ private netplayCatchUp: boolean = false; // True when client is behind and should disable frame limiter
193
+ private contentCrc: number = 0; // CRC32 of ROM content for netplay validation
194
+ private netplayDisconnected: boolean = false; // Track if netplay disconnect caused stop
195
+ private netplayDisconnectReason: string = ''; // Reason for disconnect
196
+ private netplayHost: string = ''; // Host we connected/attempted to connect to
197
+ private netplayPort: number = 0; // Port we connected/attempted to connect to
198
+ private intentionalDisconnect: boolean = false; // Track if user explicitly chose to disconnect
199
+ private pauseMenuPending: boolean = false; // Track if pause menu is currently showing
200
+
201
+ constructor(options: EmulatorOptions) {
202
+ // Store paths and config
203
+ this.romPath = options.romPath;
204
+ this.config = options.config ?? null;
205
+ this.settingsManager = options.settingsManager ?? null;
206
+
207
+ // Initialize core subsystems
208
+ this.initializeCore(options);
209
+ this.initializeEffects(options);
210
+ this.initializeInput(options);
211
+ this.initializeRenderer(options);
212
+
213
+ // Set up settings change listeners if manager is provided
214
+ if (this.settingsManager) {
215
+ this.initializeSettingsListeners();
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Initialize settings change listeners.
221
+ * These handlers apply setting changes during gameplay (e.g., stop/start audio).
222
+ */
223
+ private initializeSettingsListeners(): void {
224
+ if (!this.settingsManager) {
225
+ return;
226
+ }
227
+
228
+ // Audio mute changes
229
+ this.settingsUnsubscribers.push(
230
+ this.settingsManager.onChange('audioMuted', (muted) => {
231
+ this.applyAudioMuteChange(muted);
232
+ })
233
+ );
234
+
235
+ // Render mode changes
236
+ this.settingsUnsubscribers.push(
237
+ this.settingsManager.onChange('renderMode', (mode) => {
238
+ if (mode) {
239
+ this.applyRenderModeChange(mode);
240
+ }
241
+ })
242
+ );
243
+
244
+ // Post-processing mode changes
245
+ this.settingsUnsubscribers.push(
246
+ this.settingsManager.onChange('postProcessingMode', (mode) => {
247
+ this.applyPostProcessingMode(mode);
248
+ this.recreateRenderer();
249
+ })
250
+ );
251
+
252
+ // Status bar visibility changes
253
+ this.settingsUnsubscribers.push(
254
+ this.settingsManager.onChange('showStatusBar', (show) => {
255
+ this.showStatusBar = show;
256
+ })
257
+ );
258
+
259
+ // Frame limit changes
260
+ this.settingsUnsubscribers.push(
261
+ this.settingsManager.onChange('frameLimit', (limit) => {
262
+ this.frameLimit = limit;
263
+ this.renderInterval = limit > 0 ? MS_PER_SECOND / limit : 0;
264
+ })
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Apply audio mute state change.
270
+ */
271
+ private applyAudioMuteChange(muted: boolean): void {
272
+ this.audioEnabled = !muted;
273
+
274
+ // Notify core of audio enable state (for libretro GET_AUDIO_VIDEO_ENABLE)
275
+ this.core.setAudioEnabled?.(this.audioEnabled);
276
+
277
+ if (muted) {
278
+ // Mute: disconnect callback so core stops generating audio samples
279
+ this.core.setAudioCallback(null);
280
+
281
+ // Stop the audio stream
282
+ if (this.rtAudio) {
283
+ try {
284
+ if (this.rtAudio.isStreamRunning()) {
285
+ this.rtAudio.stop();
286
+ }
287
+ } catch {
288
+ // Ignore errors
289
+ }
290
+ }
291
+ } else {
292
+ // Unmute: reconnect callback so core resumes generating audio
293
+ if (this.audioCallback) {
294
+ this.core.setAudioCallback(this.audioCallback);
295
+ }
296
+
297
+ // Restart the audio stream
298
+ if (this.rtAudio) {
299
+ try {
300
+ if (!this.rtAudio.isStreamRunning()) {
301
+ this.rtAudio.start();
302
+ }
303
+ } catch {
304
+ // Ignore errors
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Apply render mode change.
312
+ */
313
+ private applyRenderModeChange(mode: RenderMode): void {
314
+ // Destroy old renderer first (cleanup native window, etc.)
315
+ this.renderer.destroy?.();
316
+
317
+ // Create new renderer for the mode
318
+ this.renderer = this.createRendererForMode(mode);
319
+ this.autoResize = true;
320
+ this.renderMode = mode;
321
+
322
+ // Clear screen for new renderer
323
+ process.stdout.write(this.renderer.clearScreen());
324
+ }
325
+
326
+ /**
327
+ * Initialize the emulation core, load ROM, and run bootstrap frames.
328
+ */
329
+ private initializeCore(options: EmulatorOptions): void {
330
+ // Get system info from factory to determine core type before creating
331
+ const factoryInfo = options.coreFactory.getSystemInfo();
332
+
333
+ // Get default core options for this core (e.g., software rendering for N64)
334
+ const coreOptions = factoryInfo.coreName
335
+ ? getDefaultCoreOptions(factoryInfo.coreName)
336
+ : undefined;
337
+
338
+ if (coreOptions) {
339
+ logger.info(`Applying default options for ${factoryInfo.coreName}: ${JSON.stringify(coreOptions)}`, 'Core');
340
+ }
341
+
342
+ // Create core and load ROM
343
+ this.core = options.coreFactory.create({ coreOptions });
344
+ this.systemInfo = this.core.getSystemInfo();
345
+
346
+ this.core.loadRom(options.romPath);
347
+
348
+ // Compute content CRC for netplay validation
349
+ try {
350
+ const romData = readFileSync(options.romPath);
351
+ this.contentCrc = crc32(romData);
352
+ } catch {
353
+ // If we can't read the ROM, use 0 (netplay will still work but won't validate)
354
+ this.contentCrc = 0;
355
+ }
356
+
357
+ // Store netplay options for deferred initialization (after run() starts)
358
+ // netplayConnect can be empty string for LAN discovery, so check !== undefined
359
+ if (options.netplayHost || options.netplayConnect !== undefined) {
360
+ this.netplayOptions = options;
361
+ }
362
+
363
+ // For libretro cores, run bootstrap frames to get accurate dimensions
364
+ // (actual frame dimensions from video callback may differ from AV info)
365
+ const preBootstrapInfo = this.core.getSystemInfo();
366
+ if (preBootstrapInfo.colorSpace === 'rgb24') {
367
+ // Run multiple bootstrap frames - some cores need a few frames to stabilize
368
+ for (let i = 0; i < LIBRETRO_BOOTSTRAP_FRAMES; i++) {
369
+ this.core.runFrame();
370
+ }
371
+ // Enable auto-crop bounds detection for configured cores (e.g., N64)
372
+ if (this.shouldEnableAutoCrop(preBootstrapInfo.id)) {
373
+ this.needsContentBoundsDetection = true;
374
+ }
375
+ }
376
+
377
+ // Re-fetch systemInfo after loading ROM (libretro cores update dimensions after game load)
378
+ this.systemInfo = this.core.getSystemInfo();
379
+
380
+ // Log frame dimensions and check for changes after bootstrap
381
+ const dimsChanged = this.systemInfo.width !== preBootstrapInfo.width ||
382
+ this.systemInfo.height !== preBootstrapInfo.height;
383
+ if (dimsChanged) {
384
+ logger.info(
385
+ `Frame size: ${preBootstrapInfo.width}x${preBootstrapInfo.height} -> ` +
386
+ `${this.systemInfo.width}x${this.systemInfo.height}, ` +
387
+ `PAR: ${this.systemInfo.pixelAspectRatio.toFixed(ASPECT_RATIO_DECIMALS)}`,
388
+ 'Core'
389
+ );
390
+ } else {
391
+ logger.info(
392
+ `Frame size: ${this.systemInfo.width}x${this.systemInfo.height}, ` +
393
+ `PAR: ${this.systemInfo.pixelAspectRatio.toFixed(ASPECT_RATIO_DECIMALS)}`,
394
+ 'Core'
395
+ );
396
+ }
397
+
398
+ // Load battery save (.srm) if available
399
+ if (options.enableBatterySave !== false) {
400
+ this.loadBatterySave();
401
+ }
402
+
403
+ // Set target frame time based on FPS limit or core's native FPS
404
+ if (options.fpsLimit === 0) {
405
+ this.targetFrameTime = 0; // Uncapped
406
+ } else if (options.fpsLimit !== undefined) {
407
+ this.targetFrameTime = MS_PER_SECOND / options.fpsLimit;
408
+ } else {
409
+ this.targetFrameTime = MS_PER_SECOND / this.systemInfo.fps;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Initialize effect values and post-processing mode.
415
+ */
416
+ private initializeEffects(options: EmulatorOptions): void {
417
+ // Store basic options - prefer SettingsManager if available
418
+ if (this.settingsManager) {
419
+ this.audioEnabled = !this.settingsManager.get('audioMuted');
420
+ this.showStatusBar = this.settingsManager.get('showStatusBar');
421
+ } else {
422
+ this.audioEnabled = options.enableAudio !== false && !options.startMuted;
423
+ this.showStatusBar = options.showStatusBar !== false;
424
+ }
425
+ this.saveStateEnabled = options.enableSaveState !== false;
426
+ this.batterySaveEnabled = options.enableBatterySave !== false;
427
+ this.diffRenderingEnabled = options.enableDiffRendering !== false;
428
+ this.noRender = options.noRender ?? false;
429
+ this.frameLimit = options.frameLimit ?? 0;
430
+ this.renderInterval = this.frameLimit > 0 ? MS_PER_SECOND / this.frameLimit : 0;
431
+ this.colorEnabled = options.colorEnabled ?? true;
432
+
433
+ // Initialize effect values from options
434
+ this.gamma = options.gamma ?? DEFAULT_GAMMA;
435
+ this.scanlines = options.scanlines ?? DEFAULT_SCANLINES;
436
+ this.saturation = options.saturation ?? DEFAULT_SATURATION;
437
+ this.brightness = options.brightness ?? DEFAULT_BRIGHTNESS;
438
+ this.contrast = options.contrast ?? DEFAULT_CONTRAST;
439
+ this.vignette = options.vignette ?? DEFAULT_VIGNETTE;
440
+ this.bloom = options.bloom ?? DEFAULT_BLOOM;
441
+ this.bloomThreshold = options.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
442
+ this.ntsc = options.ntsc ?? DEFAULT_NTSC;
443
+ this.curvature = options.curvature ?? DEFAULT_CURVATURE;
444
+ this.chromaticAberration = options.chromaticAberration ?? DEFAULT_CHROMATIC_ABERRATION;
445
+
446
+ // Store custom effect values from config (so they persist when switching modes)
447
+ if (this.config) {
448
+ this.customEffectValues = {
449
+ gamma: this.config.video_gamma,
450
+ scanlines: this.config.video_scanlines,
451
+ saturation: this.config.video_saturation,
452
+ brightness: this.config.video_brightness,
453
+ contrast: this.config.video_contrast,
454
+ vignette: this.config.video_vignette,
455
+ bloom: this.config.video_bloom,
456
+ bloomThreshold: this.config.video_bloom_threshold,
457
+ ntsc: this.config.video_ntsc,
458
+ curvature: this.config.video_curvature,
459
+ chromaticAberration: this.config.video_chromatic_aberration,
460
+ };
461
+ }
462
+
463
+ // Check if user has non-default custom effect values
464
+ this.hasCustomEffects = this.customEffectValues !== null && (
465
+ this.customEffectValues.gamma !== 1.0 ||
466
+ this.customEffectValues.scanlines !== 0 ||
467
+ this.customEffectValues.saturation !== 1.0 ||
468
+ this.customEffectValues.brightness !== 1.0 ||
469
+ this.customEffectValues.contrast !== 1.0 ||
470
+ this.customEffectValues.vignette !== 0 ||
471
+ this.customEffectValues.bloom !== 0 ||
472
+ this.customEffectValues.ntsc !== 0 ||
473
+ this.customEffectValues.curvature !== 0 ||
474
+ this.customEffectValues.chromaticAberration !== 0
475
+ );
476
+
477
+ // Determine initial post-processing mode from config
478
+ const configMode = this.config?.video_postprocessing_mode ?? 'off';
479
+ this.postProcessingMode = configMode;
480
+
481
+ // If mode is off, reset all effects to defaults
482
+ if (configMode === 'off') {
483
+ this.gamma = 1.0;
484
+ this.scanlines = 0;
485
+ this.saturation = 1.0;
486
+ this.brightness = 1.0;
487
+ this.contrast = 1.0;
488
+ this.vignette = 0;
489
+ this.bloom = 0;
490
+ this.bloomThreshold = 0.6;
491
+ this.ntsc = 0;
492
+ this.curvature = 0;
493
+ this.chromaticAberration = 0;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Initialize input handling: controllers, input manager, input mapper, and gamepad.
499
+ */
500
+ private initializeInput(options: EmulatorOptions): void {
501
+ // Initialize controllers
502
+ this.controller1 = new Controller();
503
+ this.controller2 = new Controller();
504
+
505
+ // Initialize input manager
506
+ this.inputManager = new InputManager(this.controller1, this.controller2);
507
+
508
+ // Log input driver (RetroArch-style)
509
+ const inputMode = this.inputManager.isKittyMode() ? 'kitty' : 'legacy';
510
+ logger.info(`Found input driver: "${inputMode}"`, 'Input');
511
+
512
+ // Initialize input mapper for gamepad -> core button translation
513
+ this.inputMapper = new InputMapper(this.systemInfo.buttons, this.systemInfo.maxPlayers);
514
+ this.inputMapper.onButtonChange = (port, button, pressed) => {
515
+ this.core.setButtonState(port, button, pressed);
516
+ };
517
+
518
+ // Initialize gamepad manager if enabled
519
+ if (options.enableGamepad !== false) {
520
+ this.gamepadManager = new GamepadManager();
521
+ this.gamepadManager.onButtonChange = (port, button, pressed) => {
522
+ // Guide/Xbox/PS button acts as Escape to exit emulator
523
+ if (button === StandardButton.Guide && pressed) {
524
+ this.stop();
525
+ return;
526
+ }
527
+ this.inputMapper.handleGamepadButton(button, pressed, port);
528
+ };
529
+
530
+ // Connect analog stick input for cores that support it (e.g., libretro)
531
+ this.gamepadManager.onAnalogChange = (port, index, axis, value) => {
532
+ // Forward analog input to InputMapper
533
+ this.inputMapper.handleAnalogAxis(index, axis, value, port);
534
+ };
535
+ }
536
+
537
+ // Connect analog callback from InputMapper to core (only if core supports it)
538
+ this.inputMapper.onAnalogChange = (port, index, axis, value) => {
539
+ if (this.core.setAnalogState) {
540
+ this.core.setAnalogState(port, index, axis, value);
541
+ }
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Initialize renderer based on render mode and options.
547
+ */
548
+ private initializeRenderer(options: EmulatorOptions): void {
549
+ // Get system name for system-specific defaults
550
+ const systemName = getSystemName(this.systemInfo.extensions[0] ?? '');
551
+
552
+ // Prefer SettingsManager for renderMode if available, then options, then system-specific default
553
+ this.renderMode = this.settingsManager?.get('renderMode')
554
+ ?? options.renderMode
555
+ ?? getDefaultRenderModeForSystem(systemName);
556
+
557
+ // Log video driver selection (RetroArch-style)
558
+ logger.info(`Found video driver: "${this.renderMode}"`, 'Video');
559
+
560
+ if (this.renderMode === 'native') {
561
+ // Native window rendering - bypasses terminal I/O for best performance
562
+ // Use explicit scale if provided, otherwise use system-specific default (same as Kitty)
563
+ this.nativeScale = options.scale ?? getDefaultScaleForSystem(systemName);
564
+ this.renderer = new NativeRenderer({
565
+ scale: this.nativeScale,
566
+ sourceWidth: this.systemInfo.width,
567
+ sourceHeight: this.systemInfo.height,
568
+ pixelAspectRatio: this.systemInfo.pixelAspectRatio,
569
+ colorEnabled: this.colorEnabled,
570
+ title: `emoemu - ${getRomTitle(this.romPath) ?? basename(this.romPath, extname(this.romPath))}`,
571
+ gamma: this.gamma,
572
+ scanlines: this.scanlines,
573
+ saturation: this.saturation,
574
+ brightness: this.brightness,
575
+ contrast: this.contrast,
576
+ vignette: this.vignette,
577
+ bloom: this.bloom,
578
+ bloomThreshold: this.bloomThreshold,
579
+ ntsc: this.ntsc,
580
+ curvature: this.curvature,
581
+ chromaticAberration: this.chromaticAberration,
582
+ });
583
+ this.autoResize = false; // Native window handles its own resizing
584
+ const windowWidth = Math.round(this.systemInfo.width * this.nativeScale * this.systemInfo.pixelAspectRatio);
585
+ const windowHeight = this.systemInfo.height * this.nativeScale;
586
+ logger.info(`Set video size to: ${windowWidth}x${windowHeight} (native window, scale: ${this.nativeScale}x)`, 'Video');
587
+
588
+ // Connect native keyboard input to input mapper
589
+ (this.renderer as NativeRenderer).onKeyboard = (key, pressed) => {
590
+ this.inputMapper.handleKey(key, pressed, 0);
591
+ };
592
+ } else if (this.renderMode === 'kitty') {
593
+ // Use explicit scale if provided, otherwise use system-specific default
594
+ this.kittyScale = options.scale ?? getDefaultScaleForSystem(systemName);
595
+ this.renderer = new KittyRenderer({
596
+ scale: this.kittyScale,
597
+ sourceWidth: this.systemInfo.width,
598
+ sourceHeight: this.systemInfo.height,
599
+ colorSpace: this.systemInfo.colorSpace,
600
+ pixelAspectRatio: this.systemInfo.pixelAspectRatio,
601
+ enableDiffRendering: this.diffRenderingEnabled,
602
+ colorEnabled: this.colorEnabled,
603
+ pngCompressionLevel: options.pngCompressionLevel,
604
+ gamma: this.gamma,
605
+ scanlines: this.scanlines,
606
+ saturation: this.saturation,
607
+ brightness: this.brightness,
608
+ contrast: this.contrast,
609
+ vignette: this.vignette,
610
+ bloom: this.bloom,
611
+ bloomThreshold: this.bloomThreshold,
612
+ ntsc: this.ntsc,
613
+ curvature: this.curvature,
614
+ chromaticAberration: this.chromaticAberration,
615
+ });
616
+ this.autoResize = options.scale === undefined;
617
+ const scaledWidth = Math.round(this.systemInfo.width * this.kittyScale);
618
+ const scaledHeight = Math.round(this.systemInfo.height * this.kittyScale);
619
+ logger.info(`Set video size to: ${scaledWidth}x${scaledHeight} (scale: ${this.kittyScale}x)`, 'Video');
620
+ } else {
621
+ // Terminal-based renderers (terminal, ascii, emoji)
622
+ const explicitDims = options.width && options.height;
623
+ const terminalMode = this.renderMode === 'emoji' ? 'emoji' : this.renderMode === 'ascii' ? 'ascii' : 'terminal';
624
+ const dims = explicitDims
625
+ ? { width: options.width!, height: options.height! }
626
+ : calculateTerminalDimensions(terminalMode, this.systemInfo.width, this.systemInfo.height, this.systemInfo.pixelAspectRatio);
627
+
628
+ this.autoResize = !explicitDims;
629
+ this.renderer = new TerminalRenderer({
630
+ width: dims.width,
631
+ height: dims.height,
632
+ colorEnabled: this.colorEnabled,
633
+ emojiMode: this.renderMode === 'emoji',
634
+ asciiMode: this.renderMode === 'ascii',
635
+ sourceWidth: this.systemInfo.width,
636
+ sourceHeight: this.systemInfo.height,
637
+ enableDiffRendering: this.diffRenderingEnabled,
638
+ gamma: this.gamma,
639
+ scanlines: this.scanlines,
640
+ saturation: this.saturation,
641
+ brightness: this.brightness,
642
+ contrast: this.contrast,
643
+ vignette: this.vignette,
644
+ });
645
+ logger.info(`Set video size to: ${dims.width}x${dims.height} (terminal chars)`, 'Video');
646
+ }
647
+ }
648
+
649
+ reset(): void {
650
+ this.core.reset();
651
+ this.frameCount = 0;
652
+ }
653
+
654
+ // Run one complete frame
655
+ // Returns true if frame was executed, false if stalling for netplay
656
+ runFrame(): boolean {
657
+ // Netplay pre-frame: gather local input and get merged input
658
+ if (this.isNetplayActive()) {
659
+ const localInput = this.gatherLocalInput();
660
+
661
+ // Call netplay preFrame with local input
662
+ let preFrameResult: { input: number[]; shouldStall: boolean; shouldCatchUp: boolean } | null = null;
663
+ if (this.netplayServer) {
664
+ preFrameResult = this.netplayServer.preFrame(localInput);
665
+ } else if (this.netplayClient) {
666
+ preFrameResult = this.netplayClient.preFrame(localInput);
667
+ }
668
+
669
+ // Handle stalling (too far ahead of remote)
670
+ if (preFrameResult === null || preFrameResult.shouldStall) {
671
+ this.netplayCatchUp = false;
672
+ return false; // Skip this frame, wait for remote
673
+ }
674
+
675
+ // Store merged input for syncInputToCore
676
+ this.netplayMergedInput = preFrameResult.input;
677
+
678
+ // Track catch-up mode (client behind, should disable frame limiter)
679
+ this.netplayCatchUp = preFrameResult.shouldCatchUp;
680
+ } else {
681
+ this.netplayCatchUp = false;
682
+ }
683
+
684
+ // Sync input state from controllers to core
685
+ this.syncInputToCore();
686
+
687
+ // Run the core for one frame
688
+ this.core.runFrame();
689
+
690
+ // Netplay post-frame: capture state for rollback
691
+ if (this.isNetplayActive()) {
692
+ const serializedState = this.core.getState();
693
+ if (serializedState && Buffer.isBuffer(serializedState)) {
694
+ if (this.netplayServer) {
695
+ this.netplayServer.postFrame(serializedState);
696
+ } else if (this.netplayClient) {
697
+ this.netplayClient.postFrame(serializedState);
698
+ }
699
+ }
700
+ this.netplayMergedInput = null; // Clear for next frame
701
+ }
702
+
703
+ this.frameCount++;
704
+ return true;
705
+ }
706
+
707
+ /**
708
+ * Gather local input from keyboard and gamepad into an array.
709
+ * Format: [joypad_state, analog_left, analog_right]
710
+ */
711
+ private gatherLocalInput(): number[] {
712
+ let joypadState = 0;
713
+
714
+ // Get gamepad state from InputMapper (already translated to core button IDs)
715
+ const gamepadState = this.inputMapper.getButtonState(0);
716
+
717
+ // Map controller buttons to joypad bitmask
718
+ const buttons = this.systemInfo.buttons;
719
+ for (const buttonDef of buttons) {
720
+ // Check keyboard input via controller
721
+ let keyboardPressed = false;
722
+ switch (buttonDef.name.toLowerCase()) {
723
+ case 'a': keyboardPressed = this.controller1.getButton(Button.A); break;
724
+ case 'b': keyboardPressed = this.controller1.getButton(Button.B); break;
725
+ case 'x': keyboardPressed = this.controller1.getButton(Button.X); break;
726
+ case 'y': keyboardPressed = this.controller1.getButton(Button.Y); break;
727
+ case 'l': keyboardPressed = this.controller1.getButton(Button.L); break;
728
+ case 'r': keyboardPressed = this.controller1.getButton(Button.R); break;
729
+ case 'start': keyboardPressed = this.controller1.getButton(Button.Start); break;
730
+ case 'select': keyboardPressed = this.controller1.getButton(Button.Select); break;
731
+ case 'up': keyboardPressed = this.controller1.getButton(Button.Up); break;
732
+ case 'down': keyboardPressed = this.controller1.getButton(Button.Down); break;
733
+ case 'left': keyboardPressed = this.controller1.getButton(Button.Left); break;
734
+ case 'right': keyboardPressed = this.controller1.getButton(Button.Right); break;
735
+ }
736
+
737
+ // Check gamepad input via InputMapper
738
+ const gamepadPressed = gamepadState.get(buttonDef.id) ?? false;
739
+
740
+ // Button is pressed if either keyboard OR gamepad has it pressed
741
+ if (keyboardPressed || gamepadPressed) {
742
+ joypadState |= (1 << buttonDef.id);
743
+ }
744
+ }
745
+
746
+ return [joypadState, 0, 0]; // [joypad, analog_left, analog_right]
747
+ }
748
+
749
+ // Sync controller state to core's input system
750
+ // Combines keyboard input (via controller) with gamepad input (via inputMapper)
751
+ // When netplay is active, uses the merged input from netplay instead
752
+ private syncInputToCore(): void {
753
+ const buttons = this.systemInfo.buttons;
754
+
755
+ // If netplay is active and we have merged input, use that instead
756
+ if (this.netplayMergedInput !== null) {
757
+ // Merged input has 3 values per device: [joypad, analog_left, analog_right]
758
+ // Device 0 (Player 1) = indices 0,1,2
759
+ // Device 1 (Player 2) = indices 3,4,5
760
+ // etc.
761
+ const INPUTS_PER_DEVICE = 3;
762
+ const maxPorts = this.systemInfo.maxPlayers;
763
+
764
+ for (let port = 0; port < maxPorts; port++) {
765
+ const baseIndex = port * INPUTS_PER_DEVICE;
766
+ const joypadState = this.netplayMergedInput[baseIndex] ?? 0;
767
+
768
+ for (const buttonDef of buttons) {
769
+ const pressed = (joypadState & (1 << buttonDef.id)) !== 0;
770
+ this.core.setButtonState(port, buttonDef.id, pressed);
771
+ }
772
+ }
773
+ return;
774
+ }
775
+
776
+ // Normal mode: combine keyboard and gamepad input
777
+ // Get gamepad state from InputMapper (already translated to core button IDs)
778
+ const gamepadState = this.inputMapper.getButtonState(0);
779
+
780
+ // Map controller buttons to core buttons
781
+ for (const buttonDef of buttons) {
782
+ // Check keyboard input via controller
783
+ let keyboardPressed = false;
784
+
785
+ // Map common button names to keyboard controller
786
+ switch (buttonDef.name.toLowerCase()) {
787
+ case 'a':
788
+ keyboardPressed = this.controller1.getButton(Button.A);
789
+ break;
790
+ case 'b':
791
+ keyboardPressed = this.controller1.getButton(Button.B);
792
+ break;
793
+ case 'x':
794
+ keyboardPressed = this.controller1.getButton(Button.X);
795
+ break;
796
+ case 'y':
797
+ keyboardPressed = this.controller1.getButton(Button.Y);
798
+ break;
799
+ case 'l':
800
+ keyboardPressed = this.controller1.getButton(Button.L);
801
+ break;
802
+ case 'r':
803
+ keyboardPressed = this.controller1.getButton(Button.R);
804
+ break;
805
+ case 'start':
806
+ keyboardPressed = this.controller1.getButton(Button.Start);
807
+ break;
808
+ case 'select':
809
+ keyboardPressed = this.controller1.getButton(Button.Select);
810
+ break;
811
+ case 'up':
812
+ keyboardPressed = this.controller1.getButton(Button.Up);
813
+ break;
814
+ case 'down':
815
+ keyboardPressed = this.controller1.getButton(Button.Down);
816
+ break;
817
+ case 'left':
818
+ keyboardPressed = this.controller1.getButton(Button.Left);
819
+ break;
820
+ case 'right':
821
+ keyboardPressed = this.controller1.getButton(Button.Right);
822
+ break;
823
+ }
824
+
825
+ // Check gamepad input via InputMapper (already mapped to core button IDs)
826
+ const gamepadPressed = gamepadState.get(buttonDef.id) ?? false;
827
+
828
+ // Button is pressed if either keyboard OR gamepad has it pressed
829
+ const pressed = keyboardPressed || gamepadPressed;
830
+
831
+ this.core.setButtonState(0, buttonDef.id, pressed);
832
+ }
833
+
834
+ // Also send keyboard arrow keys as analog input for cores that support it (e.g., N64)
835
+ if (this.core.setAnalogState) {
836
+ const keyUp = this.controller1.getButton(Button.Up);
837
+ const keyDown = this.controller1.getButton(Button.Down);
838
+ const keyLeft = this.controller1.getButton(Button.Left);
839
+ const keyRight = this.controller1.getButton(Button.Right);
840
+
841
+ // Calculate analog values from keyboard (-1.0 to 1.0)
842
+ const hasKeyboardDirection = keyUp || keyDown || keyLeft || keyRight;
843
+
844
+ // Send keyboard analog if keys are pressed, OR if releasing (was active, now not)
845
+ // This ensures the analog stick returns to center when keys are released
846
+ if (hasKeyboardDirection || this.keyboardAnalogActive) {
847
+ const analogX = (keyRight ? 1 : 0) - (keyLeft ? 1 : 0);
848
+ const analogY = (keyDown ? 1 : 0) - (keyUp ? 1 : 0);
849
+
850
+ // Send analog values for left stick (index=0)
851
+ // Axis 0 = X, Axis 1 = Y
852
+ this.core.setAnalogState(0, 0, 0, analogX); // port=0, index=0 (left stick), axis=0 (X)
853
+ this.core.setAnalogState(0, 0, 1, analogY); // port=0, index=0 (left stick), axis=1 (Y)
854
+
855
+ // Track whether keyboard is actively controlling analog
856
+ this.keyboardAnalogActive = hasKeyboardDirection;
857
+ }
858
+ }
859
+ }
860
+
861
+ // Render current frame to terminal
862
+ renderFrame(): string {
863
+ let framebuffer = this.core.getFramebuffer();
864
+
865
+ // Content bounds detection for libretro cores (N64 auto-crop)
866
+ // - Initial detection: wait for first contentful frame
867
+ // - Periodic re-detection: bounds can only expand as more of the game loads
868
+ if (framebuffer.length > 0) {
869
+ if ('detectContentBounds' in this.core && typeof this.core.detectContentBounds === 'function') {
870
+ const coreWithCrop = this.core as { detectContentBounds: () => { hasContent: boolean; boundsChanged: boolean } };
871
+ const shouldCheck = this.needsContentBoundsDetection || this.shouldPeriodicBoundsCheck();
872
+
873
+ if (shouldCheck) {
874
+ const result = coreWithCrop.detectContentBounds();
875
+
876
+ if (result.hasContent) {
877
+ // Frame had content - initial detection is complete
878
+ if (this.needsContentBoundsDetection) {
879
+ this.needsContentBoundsDetection = false;
880
+ this.lastBoundsCheckFrame = this.frameCount;
881
+ this.boundsCheckCount = 1;
882
+ } else {
883
+ // Periodic check
884
+ this.lastBoundsCheckFrame = this.frameCount;
885
+ this.boundsCheckCount++;
886
+ }
887
+
888
+ // If bounds changed, recreate renderer with new dimensions
889
+ if (result.boundsChanged) {
890
+ const newInfo = this.core.getSystemInfo();
891
+ logger.info(
892
+ `Content bounds updated: ${this.systemInfo.width}x${this.systemInfo.height} -> ${newInfo.width}x${newInfo.height}`,
893
+ 'Core'
894
+ );
895
+ this.systemInfo = newInfo;
896
+ this.recreateRenderer();
897
+ process.stdout.write(this.renderer.clearScreen());
898
+ // Re-fetch framebuffer with new bounds - the cached version from
899
+ // detectContentBounds() will be used and cropped to new dimensions
900
+ framebuffer = this.core.getFramebuffer();
901
+ }
902
+ }
903
+ // If !hasContent during initial detection, keep trying (needsContentBoundsDetection stays true)
904
+ }
905
+ } else if (this.needsContentBoundsDetection) {
906
+ // Core doesn't support bounds detection
907
+ this.needsContentBoundsDetection = false;
908
+ }
909
+ }
910
+
911
+ // Debug: Log framebuffer info on first frame
912
+ if (this.frameCount === 0 && isRgb24Buffer(this.systemInfo.colorSpace, framebuffer)) {
913
+ const fb = framebuffer;
914
+ const w = this.systemInfo.width;
915
+ const h = this.systemInfo.height;
916
+ const bpp = 3;
917
+ // Sample top-left, center, and bottom-center pixels (offset from edge to avoid overscan)
918
+ const BOTTOM_SAMPLE_OFFSET = 10;
919
+ const topIdx = 0;
920
+ const centerIdx = (Math.floor(h / 2) * w + Math.floor(w / 2)) * bpp;
921
+ const bottomIdx = ((h - BOTTOM_SAMPLE_OFFSET) * w + Math.floor(w / 2)) * bpp;
922
+ logger.debug(
923
+ `Framebuffer ${w}x${h}: top=(${fb[topIdx]},${fb[topIdx+1]},${fb[topIdx+2]}) ` +
924
+ `center=(${fb[centerIdx]},${fb[centerIdx+1]},${fb[centerIdx+2]}) ` +
925
+ `bottom=(${fb[bottomIdx]},${fb[bottomIdx+1]},${fb[bottomIdx+2]})`,
926
+ 'Render'
927
+ );
928
+ }
929
+
930
+ // Convert framebuffer based on color space
931
+ if (isRgb24Buffer(this.systemInfo.colorSpace, framebuffer)) {
932
+ return this.renderer.renderRgb24(framebuffer);
933
+ } else {
934
+ return this.renderer.renderRgb15(framebuffer);
935
+ }
936
+ }
937
+
938
+ // Main emulation loop
939
+ async run(skipReset: boolean = false): Promise<void> {
940
+ this.running = true;
941
+ if (!skipReset) {
942
+ this.reset();
943
+ }
944
+
945
+ // Setup terminal
946
+ process.stdout.write(this.renderer.hideCursor());
947
+ process.stdout.write(this.renderer.clearScreen());
948
+
949
+ // Setup audio output (always initialize infrastructure, even if muted)
950
+ // This ensures rtAudio and audioCallback exist for later unmuting
951
+ this.setupAudio();
952
+
953
+ // Notify core of initial audio enable state (for libretro GET_AUDIO_VIDEO_ENABLE)
954
+ this.core.setAudioEnabled?.(this.audioEnabled);
955
+
956
+ // If starting muted, disconnect audio callback and stop stream
957
+ if (!this.audioEnabled && this.rtAudio) {
958
+ this.core.setAudioCallback(null);
959
+ try {
960
+ if (this.rtAudio.isStreamRunning()) {
961
+ this.rtAudio.stop();
962
+ }
963
+ } catch {
964
+ // Ignore errors
965
+ }
966
+ }
967
+
968
+ // Set up message callback for core notifications (e.g., "State saved", "Disk inserted")
969
+ this.core.setMessageCallback?.(this.handleCoreMessage.bind(this));
970
+
971
+ // Setup stdin first (needed for Kitty detection)
972
+ this.setupStdin();
973
+
974
+ // Detect Kitty protocol and start keyboard listener
975
+ await this.inputManager.start();
976
+
977
+ // Now attach the main input handler
978
+ this.setupInputHandler();
979
+
980
+ // Start gamepad manager if available
981
+ if (this.gamepadManager) {
982
+ this.gamepadManager.start();
983
+ }
984
+
985
+ // Initialize netplay if options were specified
986
+ await this.initializeNetplay();
987
+
988
+ // Subscribe to app-wide notifications for status bar display
989
+ this.notificationHandler = this.handleAppNotification.bind(this);
990
+ subscribeToNotifications(this.notificationHandler);
991
+
992
+ // Set up terminal resize handler
993
+ if (this.autoResize) {
994
+ this.resizeHandler = () => {
995
+ if (this.renderMode === 'kitty') {
996
+ // Kitty renderer recalculates display size internally
997
+ (this.renderer as KittyRenderer).setDimensions();
998
+ } else {
999
+ const mode = this.renderMode === 'emoji' ? 'emoji' : this.renderMode === 'ascii' ? 'ascii' : 'terminal';
1000
+ const dims = calculateTerminalDimensions(mode, this.systemInfo.width, this.systemInfo.height, this.systemInfo.pixelAspectRatio);
1001
+ (this.renderer as TerminalRenderer).setDimensions(dims.width, dims.height);
1002
+ }
1003
+ process.stdout.write(this.renderer.clearScreen());
1004
+ };
1005
+ process.stdout.on('resize', this.resizeHandler);
1006
+ }
1007
+
1008
+ // Set up auto-save for battery-backed games
1009
+ // Saves to .srm file periodically in case of crash
1010
+ if (this.core.hasBatterySave() && this.batterySaveEnabled) {
1011
+ this.autoSaveInterval = setInterval(() => {
1012
+ this.saveBatterySave();
1013
+ }, AUTO_SAVE_INTERVAL_MS);
1014
+ }
1015
+
1016
+ this.lastFrameTime = performance.now();
1017
+ this.fpsWindowStart = this.lastFrameTime;
1018
+ this.fpsFrameCount = 0;
1019
+ this.renderFpsFrameCount = 0;
1020
+
1021
+ // Return a promise that resolves when emulation stops
1022
+ return new Promise<void>((resolve) => {
1023
+ const loop = (): void => {
1024
+ // Check for quit from global keyboard listener
1025
+ if (this.inputManager.shouldQuit()) {
1026
+ // If netplay is active, show pause menu instead of immediately quitting
1027
+ if (this.isNetplayActive() && !this.pauseMenuPending) {
1028
+ this.inputManager.clearQuitRequest();
1029
+ this.showPauseMenu();
1030
+ } else if (!this.pauseMenuPending) {
1031
+ this.stop();
1032
+ }
1033
+ }
1034
+
1035
+ // Check for native window close
1036
+ if (this.renderer.shouldClose?.()) {
1037
+ this.stop();
1038
+ }
1039
+
1040
+ if (!this.running) {
1041
+ void this.cleanup().then(resolve);
1042
+ return;
1043
+ }
1044
+
1045
+ // Skip frame processing while pause menu is showing
1046
+ if (this.pauseMenuPending) {
1047
+ setImmediate(loop);
1048
+ return;
1049
+ }
1050
+
1051
+ const now = performance.now();
1052
+
1053
+ // Handle uncapped mode (targetFrameTime = 0) separately
1054
+ if (this.targetFrameTime === 0) {
1055
+ this.inputManager.update();
1056
+ const frameRan = this.runFrame();
1057
+ if (frameRan) {
1058
+ // Frame limit: only render when enough time has passed (0=no limit)
1059
+ if (this.renderInterval === 0 || now - this.lastRenderTime >= this.renderInterval) {
1060
+ if (!this.noRender) {
1061
+ process.stdout.write(this.renderFrame());
1062
+ this.renderFpsFrameCount++;
1063
+ }
1064
+ this.lastRenderTime = now;
1065
+ }
1066
+ this.fpsFrameCount++;
1067
+ }
1068
+ } else {
1069
+ // Calculate how many frames we should have run by now
1070
+ const framesBehind = Math.floor((now - this.lastFrameTime) / this.targetFrameTime);
1071
+
1072
+ // Run frames if behind schedule OR if netplay catch-up mode is active
1073
+ // Catch-up mode disables frame limiter when client is behind remote
1074
+ if (framesBehind >= 1 || this.netplayCatchUp) {
1075
+ // Update input state once per iteration
1076
+ this.inputManager.update();
1077
+
1078
+ // Determine how many frames to run
1079
+ let framesToRun = Math.max(1, framesBehind);
1080
+
1081
+ // If too far behind OR in netplay catch-up, run multiple frames
1082
+ // But cap to prevent runaway behavior (e.g., after GC pause)
1083
+ if (framesBehind > MAX_FRAME_SKIP) {
1084
+ this.lastFrameTime = now - this.targetFrameTime;
1085
+ framesToRun = 1;
1086
+ } else if (this.netplayCatchUp && framesBehind < 1) {
1087
+ // In catch-up mode but frame timer hasn't triggered yet
1088
+ // Run one frame immediately to catch up faster
1089
+ framesToRun = 1;
1090
+ }
1091
+
1092
+ // Run skipped frames without rendering to catch up
1093
+ // In netplay, we may stall - count only frames that actually ran
1094
+ let framesActuallyRan = 0;
1095
+ for (let i = 1; i < framesToRun; i++) {
1096
+ if (this.runFrame()) {
1097
+ framesActuallyRan++;
1098
+ }
1099
+ }
1100
+
1101
+ // Run final frame and render (subject to frame limit)
1102
+ const finalFrameRan = this.runFrame();
1103
+ if (finalFrameRan) {
1104
+ // Frame limit: only render when enough time has passed (0=no limit)
1105
+ if (this.renderInterval === 0 || now - this.lastRenderTime >= this.renderInterval) {
1106
+ if (!this.noRender) {
1107
+ process.stdout.write(this.renderFrame());
1108
+ this.renderFpsFrameCount++;
1109
+ }
1110
+ this.lastRenderTime = now;
1111
+ }
1112
+ framesActuallyRan++;
1113
+ }
1114
+
1115
+ // Track FPS - count only frames that actually ran
1116
+ this.fpsFrameCount += framesActuallyRan;
1117
+
1118
+ // Advance lastFrameTime by frames we attempted (prevents drift)
1119
+ // In catch-up mode with framesBehind < 1, advance by actual frames run
1120
+ if (framesBehind >= 1) {
1121
+ this.lastFrameTime += framesToRun * this.targetFrameTime;
1122
+ } else if (framesActuallyRan > 0) {
1123
+ // Catch-up mode: advance timing to current time to prevent accumulation
1124
+ this.lastFrameTime = now;
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ // Track FPS with rolling 1-second window
1130
+ const fpsElapsed = now - this.fpsWindowStart;
1131
+ if (fpsElapsed >= MS_PER_SECOND) {
1132
+ this.currentFps = (this.fpsFrameCount * MS_PER_SECOND) / fpsElapsed;
1133
+ this.currentRenderFps = (this.renderFpsFrameCount * MS_PER_SECOND) / fpsElapsed;
1134
+ this.fpsFrameCount = 0;
1135
+ this.renderFpsFrameCount = 0;
1136
+ this.fpsWindowStart = now;
1137
+ }
1138
+
1139
+ // Display status bar if enabled (throttled to reduce string building overhead)
1140
+ if (this.showStatusBar) {
1141
+ this.statusBarFrameCounter++;
1142
+ if (this.statusBarFrameCounter >= STATUS_BAR_UPDATE_INTERVAL) {
1143
+ this.statusBarFrameCounter = 0;
1144
+ const { height: terminalRows } = getTerminalDimensions();
1145
+ process.stdout.write(`\x1b[${terminalRows};1H`);
1146
+ process.stdout.write(this.buildStatusBar(this.currentFps));
1147
+ }
1148
+ }
1149
+
1150
+ // Schedule next iteration
1151
+ setImmediate(loop);
1152
+ };
1153
+
1154
+ loop();
1155
+ });
1156
+ }
1157
+
1158
+ stop(): void {
1159
+ this.running = false;
1160
+ }
1161
+
1162
+ /**
1163
+ * Get the estimated session runtime in seconds based on frame count.
1164
+ */
1165
+ getSessionSeconds(): number {
1166
+ return Math.floor(this.frameCount / this.systemInfo.fps);
1167
+ }
1168
+
1169
+ /**
1170
+ * Check if netplay disconnect caused the emulator to stop.
1171
+ */
1172
+ wasNetplayDisconnected(): boolean {
1173
+ return this.netplayDisconnected;
1174
+ }
1175
+
1176
+ /**
1177
+ * Get info about the netplay disconnect (reason, host, port).
1178
+ */
1179
+ getNetplayDisconnectInfo(): { reason: string; host: string; port: number } {
1180
+ return {
1181
+ reason: this.netplayDisconnectReason,
1182
+ host: this.netplayHost,
1183
+ port: this.netplayPort,
1184
+ };
1185
+ }
1186
+
1187
+ /**
1188
+ * Check if user explicitly chose to disconnect (e.g., from pause menu).
1189
+ * When true, the disconnect dialog should be skipped.
1190
+ */
1191
+ wasIntentionalDisconnect(): boolean {
1192
+ return this.intentionalDisconnect;
1193
+ }
1194
+
1195
+ private setupAudio(): void {
1196
+ const audioConfig = this.core.getAudioConfig();
1197
+ const channels = audioConfig.channels; // 1 = mono, 2 = stereo (interleaved L,R,L,R,...)
1198
+
1199
+ // Track source sample rate for resampling
1200
+ const sourceSampleRate = audioConfig.sampleRate;
1201
+ let outputSampleRate = audioConfig.sampleRate;
1202
+ let resampleRatio = 1.0;
1203
+
1204
+ // Try the core's native sample rate first, then fall back to common rates
1205
+ const ratesToTry = [audioConfig.sampleRate];
1206
+ if (audioConfig.sampleRate !== SAMPLE_RATE_44100) {ratesToTry.push(SAMPLE_RATE_44100);}
1207
+ if (audioConfig.sampleRate !== SAMPLE_RATE_48000) {ratesToTry.push(SAMPLE_RATE_48000);}
1208
+
1209
+ let sampleRate = audioConfig.sampleRate;
1210
+ // Frame size for audio buffer (~10ms at sample rate for low latency)
1211
+ let frameSize = Math.floor(sampleRate * AUDIO_FRAME_DURATION_SEC);
1212
+ // Buffer size in bytes (16-bit stereo output = 4 bytes per sample frame)
1213
+ let frameBytes = frameSize * AUDIO_STEREO_CHANNELS * BYTES_PER_INT16_SAMPLE; // frameSize * 2 output channels * 2 bytes
1214
+
1215
+ // Fixed-size ring buffer for sample accumulation (prevents unbounded growth)
1216
+ // Size: enough for ~100ms of audio (10 frames worth at 10ms each)
1217
+ // For stereo input, we need 2x the samples (L and R interleaved)
1218
+ let samplesPerFrame = frameSize * channels;
1219
+ let ringBufferSize = samplesPerFrame * AUDIO_RING_BUFFER_FRAMES;
1220
+ let ringBuffer = new Float32Array(ringBufferSize);
1221
+ let ringWritePos = 0;
1222
+ let ringReadPos = 0;
1223
+ let ringCount = 0; // Number of samples in buffer
1224
+
1225
+ // Pre-allocated output buffer for RtAudio (exact frame size required)
1226
+ let outputBuffer = Buffer.alloc(frameBytes);
1227
+
1228
+ // Flow control using frameOutputCallback
1229
+ let framesWritten = 0;
1230
+ let framesPlayed = 0;
1231
+ const maxQueuedFrames = MAX_AUDIO_QUEUED_FRAMES; // Maximum frames to buffer ahead
1232
+
1233
+ // Helper to write a single frame to RtAudio from ring buffer
1234
+ const writeFrame = (): boolean => {
1235
+ if (!this.rtAudio || ringCount < samplesPerFrame) {return false;}
1236
+
1237
+ // Flow control: don't queue too many frames ahead
1238
+ const queuedFrames = framesWritten - framesPlayed;
1239
+ if (queuedFrames >= maxQueuedFrames) {
1240
+ return false; // Wait for playback to catch up
1241
+ }
1242
+
1243
+ // Convert float samples to int16 stereo in output buffer
1244
+ if (channels === 1) {
1245
+ // Mono input: duplicate each sample to both L and R channels
1246
+ for (let i = 0; i < frameSize; i++) {
1247
+ const sample = clamp(ringBuffer[ringReadPos], { min: -1, max: 1 });
1248
+ const int16 = (sample * INT16_MAX_VALUE) | 0;
1249
+ const offset = i * BYTES_PER_STEREO_SAMPLE; // 4 bytes per stereo output (2 channels * 2 bytes)
1250
+ outputBuffer.writeInt16LE(int16, offset); // Left channel
1251
+ outputBuffer.writeInt16LE(int16, offset + BYTES_PER_INT16_SAMPLE); // Right channel
1252
+ ringReadPos = (ringReadPos + 1) % ringBufferSize;
1253
+ }
1254
+ } else {
1255
+ // Stereo input: samples are interleaved L,R,L,R,...
1256
+ for (let i = 0; i < frameSize; i++) {
1257
+ const sampleL = clamp(ringBuffer[ringReadPos], { min: -1, max: 1 });
1258
+ ringReadPos = (ringReadPos + 1) % ringBufferSize;
1259
+ const sampleR = clamp(ringBuffer[ringReadPos], { min: -1, max: 1 });
1260
+ ringReadPos = (ringReadPos + 1) % ringBufferSize;
1261
+ const int16L = (sampleL * INT16_MAX_VALUE) | 0;
1262
+ const int16R = (sampleR * INT16_MAX_VALUE) | 0;
1263
+ const offset = i * BYTES_PER_STEREO_SAMPLE; // 4 bytes per stereo output (2 channels * 2 bytes)
1264
+ outputBuffer.writeInt16LE(int16L, offset); // Left channel
1265
+ outputBuffer.writeInt16LE(int16R, offset + BYTES_PER_INT16_SAMPLE); // Right channel
1266
+ }
1267
+ }
1268
+ ringCount -= samplesPerFrame;
1269
+
1270
+ this.rtAudio.write(outputBuffer);
1271
+ framesWritten++;
1272
+ return true;
1273
+ };
1274
+
1275
+ // Try to write all available frames to RtAudio's queue
1276
+ const tryWriteFrames = () => {
1277
+ while (ringCount >= samplesPerFrame && writeFrame()) {
1278
+ // Keep writing until buffer is drained or queue is full
1279
+ }
1280
+ };
1281
+
1282
+ // Frame output callback - called when a frame finishes playing
1283
+ // Leverages RtAudio's queue by reactively writing when space becomes available
1284
+ const onFramePlayed = () => {
1285
+ framesPlayed++;
1286
+ // Opportunistically write more frames when playback creates room
1287
+ tryWriteFrames();
1288
+ };
1289
+
1290
+ // Track if we're currently recovering to prevent recursive recovery
1291
+ let isRecovering = false;
1292
+
1293
+ // Error callback for graceful error recovery
1294
+ const onAudioError = (type: number, msg: string) => {
1295
+ // Don't process errors if we're shutting down
1296
+ if (!this.running) {return;}
1297
+
1298
+ // Log error for debugging (type codes from RtAudioErrorType enum)
1299
+ const errorTypes = ['WARNING', 'DEBUG_WARNING', 'UNSPECIFIED', 'NO_DEVICES_FOUND',
1300
+ 'INVALID_DEVICE', 'MEMORY_ERROR', 'INVALID_PARAMETER', 'INVALID_USE',
1301
+ 'DRIVER_ERROR', 'SYSTEM_ERROR', 'THREAD_ERROR'];
1302
+ const typeName = errorTypes[type] || `UNKNOWN(${type})`;
1303
+ logger.error(`Audio error [${typeName}]: ${msg}`, 'Audio');
1304
+
1305
+ // Attempt recovery for recoverable errors (not during recovery or shutdown)
1306
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- running can change asynchronously
1307
+ if (!isRecovering && this.running && type >= RTAUDIO_RECOVERABLE_ERROR_THRESHOLD) { // Errors more severe than warnings
1308
+ isRecovering = true;
1309
+ setTimeout(() => {
1310
+ // Double-check we're still running before recovery
1311
+ if (this.running) {
1312
+ try {
1313
+ createAudio(sampleRate);
1314
+ } catch {
1315
+ // If recreation fails, disable audio
1316
+ this.audioEnabled = false;
1317
+ }
1318
+ }
1319
+ isRecovering = false;
1320
+ }, AUDIO_RECOVERY_DELAY_MS);
1321
+ }
1322
+ };
1323
+
1324
+ // Function to create/recreate RtAudio with a specific sample rate
1325
+ const createAudio = (rate: number) => {
1326
+ if (this.rtAudio) {
1327
+ try {
1328
+ this.rtAudio.closeStream();
1329
+ } catch {
1330
+ // Ignore cleanup errors
1331
+ }
1332
+ this.rtAudio = null;
1333
+ }
1334
+
1335
+ // Update audio parameters for new sample rate
1336
+ sampleRate = rate;
1337
+ outputSampleRate = rate;
1338
+ resampleRatio = sourceSampleRate / outputSampleRate;
1339
+ frameSize = Math.floor(rate * AUDIO_FRAME_DURATION_SEC);
1340
+ frameBytes = frameSize * AUDIO_STEREO_CHANNELS * BYTES_PER_INT16_SAMPLE;
1341
+ samplesPerFrame = frameSize * channels;
1342
+ ringBufferSize = samplesPerFrame * AUDIO_RING_BUFFER_FRAMES;
1343
+ ringBuffer = new Float32Array(ringBufferSize);
1344
+ outputBuffer = Buffer.alloc(frameBytes);
1345
+
1346
+ this.rtAudio = new RtAudio();
1347
+
1348
+ // Open output-only stream (stereo for proper speaker output)
1349
+ this.rtAudio.openStream(
1350
+ {
1351
+ deviceId: this.rtAudio.getDefaultOutputDevice(),
1352
+ nChannels: 2, // Stereo output
1353
+ firstChannel: 0,
1354
+ },
1355
+ null, // No input
1356
+ RtAudioFormat.RTAUDIO_SINT16,
1357
+ sampleRate,
1358
+ frameSize,
1359
+ 'emoemu',
1360
+ null, // No input callback
1361
+ onFramePlayed, // Frame output callback for flow control
1362
+ 0 as unknown as undefined, // Default flags - runtime expects number, types expect undefined
1363
+ onAudioError // Error callback for graceful recovery
1364
+ );
1365
+
1366
+ this.rtAudio.start();
1367
+ // Reset state on audio recreation
1368
+ ringWritePos = 0;
1369
+ ringReadPos = 0;
1370
+ ringCount = 0;
1371
+ framesWritten = 0;
1372
+ framesPlayed = 0;
1373
+ };
1374
+
1375
+ // Try each sample rate until one works
1376
+ let audioInitialized = false;
1377
+ let lastError: unknown;
1378
+ for (const rate of ratesToTry) {
1379
+ try {
1380
+ createAudio(rate);
1381
+ audioInitialized = true;
1382
+ // Log successful audio init (RetroArch-style)
1383
+ logger.info(`Set audio input rate to: ${rate.toFixed(2)} Hz`, 'Audio');
1384
+ logger.debug(`Audio buffer: ${frameSize} frames (${(frameSize / rate * MS_PER_SECOND).toFixed(1)} ms latency)`, 'Audio');
1385
+ break;
1386
+ } catch (err) {
1387
+ lastError = err;
1388
+ logger.debug(`Failed to init audio at ${rate} Hz, trying next rate`, 'Audio');
1389
+ // Try next sample rate
1390
+ }
1391
+ }
1392
+
1393
+ if (!audioInitialized) {
1394
+ logger.error(`Audio initialization failed for all sample rates. Continuing without audio. ${getErrorMessage(lastError)}`, 'Audio');
1395
+ this.audioEnabled = false;
1396
+ return;
1397
+ }
1398
+
1399
+ // Helper to add a sample to ring buffer
1400
+ const addSampleToRingBuffer = (sample: number) => {
1401
+ if (ringCount >= ringBufferSize) {
1402
+ ringReadPos = (ringReadPos + 1) % ringBufferSize;
1403
+ ringCount--;
1404
+ }
1405
+ ringBuffer[ringWritePos] = sample;
1406
+ ringWritePos = (ringWritePos + 1) % ringBufferSize;
1407
+ ringCount++;
1408
+ };
1409
+
1410
+ // Create and store the audio callback so we can disconnect/reconnect it
1411
+ this.audioCallback = (samples: Float32Array) => {
1412
+ if (!this.rtAudio) {return;}
1413
+
1414
+ // If no resampling needed, add directly to ring buffer
1415
+ if (Math.abs(resampleRatio - 1.0) < FLOAT_COMPARE_EPSILON) {
1416
+ for (let i = 0; i < samples.length; i++) {
1417
+ addSampleToRingBuffer(samples[i]);
1418
+ }
1419
+ } else {
1420
+ // Resample using linear interpolation (stereo)
1421
+ const numFrames = samples.length / 2;
1422
+ let srcPos = 0;
1423
+
1424
+ while (srcPos < numFrames - 1) {
1425
+ const srcIdx = Math.floor(srcPos) * 2;
1426
+ const frac = srcPos - Math.floor(srcPos);
1427
+
1428
+ // Get current and next stereo samples
1429
+ const l0 = samples[srcIdx];
1430
+ const r0 = samples[srcIdx + 1];
1431
+ const l1 = samples[srcIdx + AUDIO_STEREO_CHANNELS] ?? l0;
1432
+ const r1 = samples[srcIdx + STEREO_NEXT_RIGHT_OFFSET] ?? r0;
1433
+
1434
+ // Linear interpolation
1435
+ const outL = l0 + (l1 - l0) * frac;
1436
+ const outR = r0 + (r1 - r0) * frac;
1437
+
1438
+ addSampleToRingBuffer(outL);
1439
+ addSampleToRingBuffer(outR);
1440
+
1441
+ // Advance source position by resample ratio
1442
+ srcPos += resampleRatio;
1443
+ }
1444
+ }
1445
+
1446
+ // Write complete frames to RtAudio's queue
1447
+ tryWriteFrames();
1448
+ };
1449
+
1450
+ // Connect core's audio output to RtAudio
1451
+ this.core.setAudioCallback(this.audioCallback);
1452
+ }
1453
+
1454
+ private setupStdin(): void {
1455
+ if (process.stdin.isTTY) {
1456
+ process.stdin.setRawMode(true);
1457
+ }
1458
+ process.stdin.resume();
1459
+ process.stdin.setEncoding('utf8');
1460
+ }
1461
+
1462
+ private setupInputHandler(): void {
1463
+ this.inputHandler = (key: string) => {
1464
+ // Process input through InputManager
1465
+ const result = this.inputManager.processInput(key);
1466
+
1467
+ if (result.quit) {
1468
+ this.stop();
1469
+ }
1470
+
1471
+ if (result.cycleRenderMode) {
1472
+ this.cycleRenderMode();
1473
+ }
1474
+
1475
+ if (result.toggleAudio) {
1476
+ this.toggleAudio();
1477
+ }
1478
+
1479
+ if (result.togglePostProcessing) {
1480
+ this.togglePostProcessing();
1481
+ }
1482
+
1483
+ if (result.takeScreenshot) {
1484
+ this.takeScreenshot();
1485
+ }
1486
+
1487
+ if (result.testNotification) {
1488
+ this.triggerTestNotification();
1489
+ }
1490
+ };
1491
+ process.stdin.on('data', this.inputHandler);
1492
+ }
1493
+
1494
+ // Toggle audio on/off
1495
+ private toggleAudio(): void {
1496
+ // Use SettingsManager if available (handles persistence and notifies listeners)
1497
+ if (this.settingsManager) {
1498
+ this.settingsManager.toggle('audioMuted');
1499
+ return;
1500
+ }
1501
+
1502
+ // Fallback: direct toggle without SettingsManager
1503
+ const nowMuted = this.audioEnabled; // If was enabled, now muting
1504
+ this.applyAudioMuteChange(nowMuted);
1505
+ }
1506
+
1507
+ // Cycle through render modes: kitty -> terminal -> ascii -> emoji -> kitty
1508
+ // (Moved here to be near toggleAudio for consistency)
1509
+ private cycleRenderMode(): void {
1510
+ const modes: RenderMode[] = ['kitty', 'terminal', 'ascii', 'emoji'];
1511
+
1512
+ // Use SettingsManager if available
1513
+ if (this.settingsManager) {
1514
+ this.settingsManager.cycle('renderMode', modes);
1515
+ return;
1516
+ }
1517
+
1518
+ // Fallback: direct cycle without SettingsManager
1519
+ const currentIndex = modes.indexOf(this.renderMode);
1520
+ const nextIndex = (currentIndex + 1) % modes.length;
1521
+ this.applyRenderModeChange(modes[nextIndex]);
1522
+ }
1523
+
1524
+ // Cycle post-processing mode: Off -> Custom (if defined) -> CRT -> Off
1525
+ private togglePostProcessing(): void {
1526
+ // Determine available modes
1527
+ const modes: PostProcessingMode[] = this.hasCustomEffects
1528
+ ? ['off', 'custom', 'crt']
1529
+ : ['off', 'crt'];
1530
+
1531
+ // Use SettingsManager if available
1532
+ if (this.settingsManager) {
1533
+ this.settingsManager.cycle('postProcessingMode', modes);
1534
+ return;
1535
+ }
1536
+
1537
+ // Fallback: direct cycle without SettingsManager
1538
+ const currentIndex = modes.indexOf(this.postProcessingMode);
1539
+ const nextIndex = (currentIndex + 1) % modes.length;
1540
+ this.applyPostProcessingMode(modes[nextIndex]);
1541
+ this.recreateRenderer();
1542
+ }
1543
+
1544
+ // Handle messages from the core (e.g., "State saved", "Disk inserted")
1545
+ // These come from libretro cores via setMessageCallback
1546
+ // Routes through the unified notification system for both OS and status bar display
1547
+ private handleCoreMessage(message: CoreMessage): void {
1548
+ const duration = message.duration > 0
1549
+ ? message.duration
1550
+ : DEFAULT_MESSAGE_DURATION_MS;
1551
+
1552
+ // Send through unified notification system (will reach OS and status bar via listener)
1553
+ notify({
1554
+ message: message.msg,
1555
+ duration,
1556
+ severity: message.severity,
1557
+ });
1558
+ }
1559
+
1560
+ // Handle app-wide notifications (gamepad, screenshots, etc.)
1561
+ // These come from the unified notification system via subscribeToNotifications
1562
+ private handleAppNotification(notification: AppNotification): void {
1563
+ // Convert to CoreMessage format for status bar display
1564
+ const message: CoreMessage = {
1565
+ msg: notification.title
1566
+ ? `${notification.title}: ${notification.message}`
1567
+ : notification.message,
1568
+ duration: notification.duration ?? DEFAULT_MESSAGE_DURATION_MS,
1569
+ priority: 0,
1570
+ type: 'notification',
1571
+ progress: -1,
1572
+ severity: notification.severity ?? 'info',
1573
+ };
1574
+
1575
+ // Store for status bar display
1576
+ this.currentMessage = message;
1577
+ this.messageExpiry = performance.now() + message.duration;
1578
+ }
1579
+
1580
+ // Trigger a test notification (for testing the notification system)
1581
+ private triggerTestNotification(): void {
1582
+ this.handleCoreMessage({
1583
+ msg: 'Test notification from emoemu',
1584
+ duration: DEFAULT_MESSAGE_DURATION_MS,
1585
+ priority: 0,
1586
+ type: 'notification',
1587
+ progress: -1,
1588
+ severity: 'info',
1589
+ });
1590
+ }
1591
+
1592
+ // Apply a specific post-processing mode
1593
+ private applyPostProcessingMode(mode: PostProcessingMode): void {
1594
+ this.postProcessingMode = mode;
1595
+
1596
+ switch (mode) {
1597
+ case 'off':
1598
+ // Set all effects to neutral/off values
1599
+ this.gamma = 1.0;
1600
+ this.scanlines = 0;
1601
+ this.saturation = 1.0;
1602
+ this.brightness = 1.0;
1603
+ this.contrast = 1.0;
1604
+ this.vignette = 0;
1605
+ this.bloom = 0;
1606
+ this.bloomThreshold = 0.6;
1607
+ this.ntsc = 0;
1608
+ this.curvature = 0;
1609
+ this.chromaticAberration = 0;
1610
+ break;
1611
+
1612
+ case 'custom':
1613
+ // Apply user's custom effect values
1614
+ if (this.customEffectValues) {
1615
+ this.gamma = this.customEffectValues.gamma;
1616
+ this.scanlines = this.customEffectValues.scanlines;
1617
+ this.saturation = this.customEffectValues.saturation;
1618
+ this.brightness = this.customEffectValues.brightness;
1619
+ this.contrast = this.customEffectValues.contrast;
1620
+ this.vignette = this.customEffectValues.vignette;
1621
+ this.bloom = this.customEffectValues.bloom;
1622
+ this.bloomThreshold = this.customEffectValues.bloomThreshold;
1623
+ this.ntsc = this.customEffectValues.ntsc;
1624
+ this.curvature = this.customEffectValues.curvature;
1625
+ this.chromaticAberration = this.customEffectValues.chromaticAberration;
1626
+ }
1627
+ break;
1628
+
1629
+ case 'crt':
1630
+ // Apply CRT preset values from config
1631
+ if (this.config) {
1632
+ this.gamma = this.config.crt_gamma;
1633
+ this.scanlines = this.config.crt_scanlines;
1634
+ this.saturation = this.config.crt_saturation;
1635
+ this.brightness = 1.0; // CRT doesn't override brightness
1636
+ this.contrast = 1.0; // CRT doesn't override contrast
1637
+ this.vignette = this.config.crt_vignette;
1638
+ this.bloom = 0; // CRT doesn't use bloom
1639
+ this.bloomThreshold = 0.6;
1640
+ this.ntsc = this.config.crt_ntsc;
1641
+ this.curvature = this.config.crt_curvature;
1642
+ this.chromaticAberration = this.config.crt_chromatic_aberration;
1643
+ } else {
1644
+ // Fallback to defaults if no config
1645
+ this.gamma = 1.3;
1646
+ this.scanlines = 0.1;
1647
+ this.saturation = 1.0;
1648
+ this.brightness = 1.0;
1649
+ this.contrast = 1.0;
1650
+ this.vignette = 0.5;
1651
+ this.bloom = 0;
1652
+ this.bloomThreshold = 0.6;
1653
+ this.ntsc = 1.0;
1654
+ this.curvature = 0.1;
1655
+ this.chromaticAberration = 0;
1656
+ }
1657
+ break;
1658
+ }
1659
+ }
1660
+
1661
+ /**
1662
+ * Create a renderer for the specified mode.
1663
+ * Uses current emulator effect values and system info.
1664
+ */
1665
+ private createRendererForMode(mode: RenderMode): Renderer {
1666
+ if (mode === 'native') {
1667
+ return new NativeRenderer({
1668
+ scale: this.nativeScale,
1669
+ sourceWidth: this.systemInfo.width,
1670
+ sourceHeight: this.systemInfo.height,
1671
+ pixelAspectRatio: this.systemInfo.pixelAspectRatio,
1672
+ colorEnabled: this.colorEnabled,
1673
+ title: `emoemu - ${getRomTitle(this.romPath) ?? basename(this.romPath, extname(this.romPath))}`,
1674
+ gamma: this.gamma,
1675
+ scanlines: this.scanlines,
1676
+ saturation: this.saturation,
1677
+ brightness: this.brightness,
1678
+ contrast: this.contrast,
1679
+ vignette: this.vignette,
1680
+ bloom: this.bloom,
1681
+ bloomThreshold: this.bloomThreshold,
1682
+ ntsc: this.ntsc,
1683
+ curvature: this.curvature,
1684
+ chromaticAberration: this.chromaticAberration,
1685
+ });
1686
+ }
1687
+
1688
+ if (mode === 'kitty') {
1689
+ return new KittyRenderer({
1690
+ scale: this.kittyScale,
1691
+ sourceWidth: this.systemInfo.width,
1692
+ sourceHeight: this.systemInfo.height,
1693
+ colorSpace: this.systemInfo.colorSpace,
1694
+ pixelAspectRatio: this.systemInfo.pixelAspectRatio,
1695
+ enableDiffRendering: this.diffRenderingEnabled,
1696
+ colorEnabled: this.colorEnabled,
1697
+ gamma: this.gamma,
1698
+ scanlines: this.scanlines,
1699
+ saturation: this.saturation,
1700
+ brightness: this.brightness,
1701
+ contrast: this.contrast,
1702
+ vignette: this.vignette,
1703
+ bloom: this.bloom,
1704
+ bloomThreshold: this.bloomThreshold,
1705
+ ntsc: this.ntsc,
1706
+ curvature: this.curvature,
1707
+ chromaticAberration: this.chromaticAberration,
1708
+ });
1709
+ }
1710
+
1711
+ // Terminal-based renderers share common options
1712
+ const terminalMode = mode === 'emoji' ? 'emoji' : mode === 'ascii' ? 'ascii' : 'terminal';
1713
+ const dims = calculateTerminalDimensions(terminalMode, this.systemInfo.width, this.systemInfo.height, this.systemInfo.pixelAspectRatio);
1714
+
1715
+ return new TerminalRenderer({
1716
+ width: dims.width,
1717
+ height: dims.height,
1718
+ colorEnabled: this.colorEnabled,
1719
+ emojiMode: mode === 'emoji',
1720
+ asciiMode: mode === 'ascii',
1721
+ sourceWidth: this.systemInfo.width,
1722
+ sourceHeight: this.systemInfo.height,
1723
+ enableDiffRendering: this.diffRenderingEnabled,
1724
+ gamma: this.gamma,
1725
+ scanlines: this.scanlines,
1726
+ saturation: this.saturation,
1727
+ brightness: this.brightness,
1728
+ contrast: this.contrast,
1729
+ vignette: this.vignette,
1730
+ });
1731
+ }
1732
+
1733
+ // Recreate the current renderer with updated effect values
1734
+ private recreateRenderer(): void {
1735
+ // Destroy old renderer first (cleanup native window, etc.)
1736
+ this.renderer.destroy?.();
1737
+
1738
+ this.renderer = this.createRendererForMode(this.renderMode);
1739
+ }
1740
+
1741
+ // Check if we should do a periodic bounds re-detection
1742
+ // More frequent at start (every ~1 sec), then less often (every ~5 sec)
1743
+ private shouldPeriodicBoundsCheck(): boolean {
1744
+ // Stop checking after max count reached
1745
+ if (this.boundsCheckCount >= BOUNDS_CHECK_MAX_COUNT) {
1746
+ return false;
1747
+ }
1748
+
1749
+ // Haven't done initial detection yet
1750
+ if (this.boundsCheckCount === 0) {
1751
+ return false;
1752
+ }
1753
+
1754
+ const framesSinceLastCheck = this.frameCount - this.lastBoundsCheckFrame;
1755
+ const interval = this.boundsCheckCount < BOUNDS_CHECK_INITIAL_COUNT
1756
+ ? BOUNDS_CHECK_INTERVAL_INITIAL
1757
+ : BOUNDS_CHECK_INTERVAL_LATER;
1758
+
1759
+ return framesSinceLastCheck >= interval;
1760
+ }
1761
+
1762
+ // Check if auto-crop should be enabled for the given core ID
1763
+ private shouldEnableAutoCrop(coreId: string): boolean {
1764
+ if (!this.config) {
1765
+ return false;
1766
+ }
1767
+
1768
+ const configuredCores = this.config.video_auto_crop_cores;
1769
+ if (!configuredCores) {
1770
+ return false;
1771
+ }
1772
+
1773
+ // Parse comma-separated list and check for exact match
1774
+ const coreIds = configuredCores.split(',').map(id => id.trim().toLowerCase());
1775
+ return coreIds.includes(coreId.toLowerCase());
1776
+ }
1777
+
1778
+ private buildStatusBar(fps: number): string {
1779
+ // Check for active notification - if present, show only the notification
1780
+ const now = performance.now();
1781
+ if (this.currentMessage && now < this.messageExpiry) {
1782
+ let msgText = this.currentMessage.msg;
1783
+ if (this.currentMessage.type === 'progress' && this.currentMessage.progress >= 0) {
1784
+ msgText += ` (${this.currentMessage.progress}%)`;
1785
+ }
1786
+ // Color based on severity: debug=dim, info=yellow, warn=bright yellow, error=red
1787
+ let colorCode: string;
1788
+ switch (this.currentMessage.severity) {
1789
+ case 'debug': colorCode = '\x1b[2m'; break; // Dim
1790
+ case 'warn': colorCode = '\x1b[93m'; break; // Bright yellow
1791
+ case 'error': colorCode = '\x1b[91m'; break; // Bright red
1792
+ default: colorCode = '\x1b[33m'; break; // Yellow (info)
1793
+ }
1794
+ return `${colorCode}${msgText}\x1b[0m\x1b[K`;
1795
+ } else if (this.currentMessage && now >= this.messageExpiry) {
1796
+ // Clear expired message
1797
+ this.currentMessage = null;
1798
+ }
1799
+
1800
+ // Normal status bar content
1801
+ const parts: string[] = [];
1802
+
1803
+ // FPS - show both emulation and render FPS
1804
+ parts.push(`Emu: ${fps.toFixed(0)} | Render: ${this.currentRenderFps.toFixed(0)}`)
1805
+
1806
+ // Netplay status (if active)
1807
+ if (this.netplayServer) {
1808
+ const clientCount = this.netplayServer.getClientCount();
1809
+ const lanStatus = this.netplayServer.isDiscoveryActive() ? ' \x1b[36m📡LAN\x1b[0m' : '';
1810
+ parts.push(`\x1b[32mHost\x1b[0m (${clientCount}p)${lanStatus}`);
1811
+ } else if (this.netplayClient) {
1812
+ const status = this.netplayClient.connected ? '\x1b[32mOnline\x1b[0m' : '\x1b[33mConnecting\x1b[0m';
1813
+ const player = this.netplayClient.isPlaying ? `P${this.netplayClient.playerNumber + 1}` : 'Spectate';
1814
+ parts.push(`${status} ${player}`);
1815
+ }
1816
+
1817
+ // Render mode
1818
+ parts.push(`Render: ${this.noRender ? 'Disabled' : this.renderMode}`);
1819
+
1820
+ // Audio status
1821
+ parts.push(`Audio: ${this.audioEnabled ? 'on' : 'off'}`);
1822
+
1823
+ // Input mode
1824
+ const gamepadStatus = this.gamepadManager?.getPlayer1Status();
1825
+ const inputMode = gamepadStatus ?? (this.inputManager.isKittyMode() ? 'kitty' : 'legacy');
1826
+ parts.push(`Input: ${inputMode}`);
1827
+
1828
+ // Pressed buttons
1829
+ const pressedButtons = this.inputMapper.getPressedButtons(0);
1830
+ parts.push(`Buttons: ${pressedButtons || '-'}`);
1831
+
1832
+ // Build the status line and clear to end of line
1833
+ return parts.join(' | ') + '\x1b[K';
1834
+ }
1835
+
1836
+ private async cleanup(): Promise<void> {
1837
+ // Unsubscribe from settings listeners
1838
+ for (const unsubscribe of this.settingsUnsubscribers) {
1839
+ unsubscribe();
1840
+ }
1841
+ this.settingsUnsubscribers = [];
1842
+
1843
+ // Disconnect netplay
1844
+ this.disconnectNetplay();
1845
+
1846
+ // Clear auto-save interval
1847
+ if (this.autoSaveInterval) {
1848
+ clearInterval(this.autoSaveInterval);
1849
+ this.autoSaveInterval = null;
1850
+ }
1851
+
1852
+ // Save battery RAM to .srm file (must happen before destroy)
1853
+ if (this.batterySaveEnabled) {
1854
+ this.saveBatterySave();
1855
+ }
1856
+
1857
+ // Save state on exit (must happen before destroy)
1858
+ if (this.saveStateEnabled) {
1859
+ await this.saveState();
1860
+ }
1861
+
1862
+ // Destroy core
1863
+ this.core.destroy();
1864
+
1865
+ // Remove resize handler
1866
+ if (this.resizeHandler) {
1867
+ process.stdout.off('resize', this.resizeHandler);
1868
+ this.resizeHandler = null;
1869
+ }
1870
+
1871
+ // Stop gamepad manager
1872
+ if (this.gamepadManager) {
1873
+ this.gamepadManager.stop();
1874
+ }
1875
+
1876
+ // Unsubscribe from app-wide notifications
1877
+ if (this.notificationHandler) {
1878
+ unsubscribeFromNotifications(this.notificationHandler);
1879
+ this.notificationHandler = null;
1880
+ }
1881
+
1882
+ // Stop audio
1883
+ if (this.rtAudio) {
1884
+ this.core.setAudioCallback(null);
1885
+ try {
1886
+ // Stop the stream first, then close it
1887
+ if (this.rtAudio.isStreamRunning()) {
1888
+ this.rtAudio.stop();
1889
+ }
1890
+ if (this.rtAudio.isStreamOpen()) {
1891
+ this.rtAudio.closeStream();
1892
+ }
1893
+ } catch {
1894
+ // Ignore cleanup errors
1895
+ }
1896
+ this.rtAudio = null;
1897
+ }
1898
+
1899
+ // Remove stdin data listener
1900
+ if (this.inputHandler) {
1901
+ process.stdin.off('data', this.inputHandler);
1902
+ this.inputHandler = null;
1903
+ }
1904
+
1905
+ // Stop global keyboard listener
1906
+ this.inputManager.stop();
1907
+
1908
+ // Clear input state
1909
+ this.inputManager.clear();
1910
+
1911
+ // Clear graphics if using image-based renderer
1912
+ if (this.renderMode === 'kitty') {
1913
+ process.stdout.write(this.renderer.clearScreen());
1914
+ }
1915
+
1916
+ process.stdout.write(this.renderer.showCursor());
1917
+ process.stdout.write('\n');
1918
+
1919
+ // Destroy renderer (cleanup native window, etc.)
1920
+ this.renderer.destroy?.();
1921
+
1922
+ // Thoroughly reset stdin for the next consumer (e.g., ROM browser)
1923
+ // Remove ALL listeners to ensure a clean state
1924
+ process.stdin.removeAllListeners();
1925
+
1926
+ // Reset TTY state
1927
+ if (process.stdin.isTTY) {
1928
+ process.stdin.setRawMode(false);
1929
+ }
1930
+
1931
+ // Pause stdin so the next consumer can set it up fresh
1932
+ process.stdin.pause();
1933
+
1934
+ // Drain any buffered input
1935
+ process.stdin.read();
1936
+ }
1937
+
1938
+ // Expose controller for external input handling
1939
+ getController(port: 1 | 2): Controller {
1940
+ return port === 1 ? this.controller1 : this.controller2;
1941
+ }
1942
+
1943
+ // Screenshot methods - delegate to ./screenshot sub-module
1944
+
1945
+ private takeScreenshot(): void {
1946
+ takeScreenshotFn(this.core, this.systemInfo, this.romPath, this.config);
1947
+ }
1948
+
1949
+ private async saveThumbnailScreenshot(): Promise<void> {
1950
+ return saveThumbnailScreenshotFn(this.core, this.systemInfo, this.romPath);
1951
+ }
1952
+
1953
+ // Save state methods - delegate to ./saveState sub-module
1954
+
1955
+ private getStatePath(): string {
1956
+ return getStatePathFn(this.config, this.romPath);
1957
+ }
1958
+
1959
+ private loadBatterySave(): void {
1960
+ loadBatterySaveFn(this.core, this.config, this.romPath);
1961
+ }
1962
+
1963
+ private saveBatterySave(): void {
1964
+ saveBatterySaveFn(this.core, this.config, this.romPath);
1965
+ }
1966
+
1967
+ hasSavedState(): boolean {
1968
+ return hasSavedStateFn(this.config, this.romPath);
1969
+ }
1970
+
1971
+ async saveState(): Promise<void> {
1972
+ saveStateFn(this.core, this.config, this.romPath);
1973
+ await this.saveThumbnailScreenshot();
1974
+ }
1975
+
1976
+ deleteSavedState(): void {
1977
+ deleteSavedStateFn(this.config, this.romPath);
1978
+ }
1979
+
1980
+ /**
1981
+ * Load state from a save state file.
1982
+ * If statePathToLoad is provided, loads from that file (used for legacy format migration).
1983
+ * Otherwise, looks for a .state.auto file.
1984
+ */
1985
+ async loadState(statePathToLoad?: string): Promise<boolean> {
1986
+ const statePath = statePathToLoad ?? this.getStatePath();
1987
+ const loaded = loadStateFromFile(this.core, statePath);
1988
+ if (!loaded && existsSync(statePath)) {
1989
+ // File existed but load failed
1990
+ const continueAnyway = await this.promptConfirmation('Continue without saved state?', true);
1991
+ if (!continueAnyway) {
1992
+ throw new Error('User cancelled due to save state load failure');
1993
+ }
1994
+ }
1995
+ return loaded;
1996
+ }
1997
+
1998
+ // Netplay methods
1999
+
2000
+ /**
2001
+ * Check if netplay is currently active (either as server or client).
2002
+ */
2003
+ isNetplayActive(): boolean {
2004
+ return this.netplayServer !== null || this.netplayClient !== null;
2005
+ }
2006
+
2007
+ /**
2008
+ * Show the pause menu during netplay.
2009
+ * Pauses emulation and shows a menu to resume or disconnect.
2010
+ */
2011
+ private showPauseMenu(): void {
2012
+ if (this.pauseMenuPending) {
2013
+ return;
2014
+ }
2015
+
2016
+ this.pauseMenuPending = true;
2017
+
2018
+ // Pause audio to prevent buffer issues
2019
+ if (this.rtAudio?.isStreamRunning()) {
2020
+ try {
2021
+ this.rtAudio.stop();
2022
+ } catch {
2023
+ // Ignore errors
2024
+ }
2025
+ }
2026
+
2027
+ // Clean up terminal state for menu display
2028
+ process.stdout.write(this.renderer.clearScreen());
2029
+ process.stdout.write(this.renderer.showCursor());
2030
+
2031
+ // Clean up stdin for the menu
2032
+ process.stdin.removeAllListeners();
2033
+ if (process.stdin.isTTY) {
2034
+ process.stdin.setRawMode(false);
2035
+ }
2036
+ process.stdin.pause();
2037
+
2038
+ // Get game name for display
2039
+ const gameName = getRomTitle(this.romPath);
2040
+
2041
+ // Show the pause menu asynchronously
2042
+ void showNetplayPauseMenu({
2043
+ gameName,
2044
+ isConnecting: this.netplayClient !== null && !this.netplayClient.isPlaying,
2045
+ nativeMode: this.renderMode === 'native',
2046
+ scaleFactor: this.config?.menu_scale_factor,
2047
+ }).then((choice: PauseMenuChoice) => {
2048
+ this.handlePauseMenuChoice(choice);
2049
+ });
2050
+ }
2051
+
2052
+ /**
2053
+ * Handle the user's choice from the pause menu.
2054
+ */
2055
+ private handlePauseMenuChoice(choice: PauseMenuChoice): void {
2056
+ if (choice === 'disconnect') {
2057
+ // User explicitly chose to disconnect - set intentional flag
2058
+ this.intentionalDisconnect = true;
2059
+ this.pauseMenuPending = false;
2060
+ this.stop();
2061
+ } else {
2062
+ // User chose to resume - restore emulator state
2063
+ this.resumeFromPauseMenu();
2064
+ }
2065
+ }
2066
+
2067
+ /**
2068
+ * Resume emulation after closing the pause menu.
2069
+ */
2070
+ private resumeFromPauseMenu(): void {
2071
+ // Restore terminal state
2072
+ process.stdout.write(this.renderer.hideCursor());
2073
+ process.stdout.write(this.renderer.clearScreen());
2074
+
2075
+ // Re-setup stdin
2076
+ this.setupStdin();
2077
+
2078
+ // Re-start the input manager (it may have been paused)
2079
+ void this.inputManager.start().then(() => {
2080
+ this.setupInputHandler();
2081
+ });
2082
+
2083
+ // Restart gamepad manager if available
2084
+ if (this.gamepadManager) {
2085
+ this.gamepadManager.start();
2086
+ }
2087
+
2088
+ // Resume audio if it was enabled
2089
+ if (this.audioEnabled && this.rtAudio && !this.rtAudio.isStreamRunning()) {
2090
+ try {
2091
+ this.rtAudio.start();
2092
+ } catch {
2093
+ // Ignore errors
2094
+ }
2095
+ }
2096
+
2097
+ // Reset timing to prevent frame catch-up
2098
+ this.lastFrameTime = performance.now();
2099
+ this.fpsWindowStart = this.lastFrameTime;
2100
+ this.fpsFrameCount = 0;
2101
+ this.renderFpsFrameCount = 0;
2102
+
2103
+ // Clear the pending flag to resume the loop
2104
+ this.pauseMenuPending = false;
2105
+ }
2106
+
2107
+ /**
2108
+ * Start a netplay server (host mode).
2109
+ */
2110
+ async startNetplayServer(options: Partial<NetplayServerOptions> = {}): Promise<void> {
2111
+ if (this.isNetplayActive()) {
2112
+ throw new NetplayError('ALREADY_ACTIVE');
2113
+ }
2114
+
2115
+
2116
+
2117
+ this.netplayServer = createNetplayServer({
2118
+ port: options.port,
2119
+ password: options.password,
2120
+ requirePassword: !!options.password,
2121
+ maxClients: options.maxClients ?? NETPLAY_MAX_CLIENTS,
2122
+ inputDelayFrames: options.inputDelayFrames ?? 0,
2123
+ nickname: options.nickname ?? 'Host',
2124
+ });
2125
+
2126
+ // Set up event handlers
2127
+ this.netplayServer.on('client-connected', (client) => {
2128
+ const message = `${client.nickname} connected`;
2129
+ netplayLogger.info('SERVER', message, { nickname: client.nickname });
2130
+ notify({ title: 'Netplay', message, severity: 'info' });
2131
+ });
2132
+
2133
+ this.netplayServer.on('client-disconnected', (client, reason) => {
2134
+ const message = `${client.nickname} disconnected: ${reason}`;
2135
+ netplayLogger.info('SERVER', message, { nickname: client.nickname, reason });
2136
+ notify({ title: 'Netplay', message, severity: 'info' });
2137
+ });
2138
+
2139
+ this.netplayServer.on('desync', (_clientId, frameNumber) => {
2140
+ const message = `Desync at frame ${frameNumber}, recovering...`;
2141
+ netplayLogger.warn('SERVER', message, { frameNumber });
2142
+ notify({ title: 'Netplay', message, severity: 'warn' });
2143
+ });
2144
+
2145
+ // Set core info for compatibility checking and LAN discovery
2146
+ const contentName = basename(this.romPath, extname(this.romPath));
2147
+ this.netplayServer.setCoreInfo(
2148
+ this.systemInfo.coreName ?? this.systemInfo.name,
2149
+ this.systemInfo.coreVersion ?? '',
2150
+ this.contentCrc,
2151
+ contentName
2152
+ );
2153
+
2154
+ await this.netplayServer.start();
2155
+
2156
+ const port = options.port ?? NETPLAY_DEFAULT_PORT;
2157
+ const message = `Hosting on port ${port}`;
2158
+ netplayLogger.info('SERVER', message, { port });
2159
+ notify({ title: 'Netplay', message, severity: 'info' });
2160
+ }
2161
+
2162
+ /**
2163
+ * Discover netplay hosts on the LAN.
2164
+ * Returns the first host found, or null if none found within timeout.
2165
+ */
2166
+ private async discoverLanHost(
2167
+ _port: number,
2168
+ timeoutMs: number
2169
+ ): Promise<{ address: string; port: number; nickname: string; contentName: string } | null> {
2170
+ return new Promise((resolve) => {
2171
+ const listener = new DiscoveryListener();
2172
+ let resolved = false;
2173
+
2174
+ const cleanup = () => {
2175
+ if (!resolved) {
2176
+ resolved = true;
2177
+ listener.stop();
2178
+ }
2179
+ };
2180
+
2181
+ // Set up callback for when a host is found
2182
+ listener.start((host) => {
2183
+ if (!resolved) {
2184
+ cleanup();
2185
+ resolve({
2186
+ address: host.address,
2187
+ port: host.port,
2188
+ nickname: host.nickname,
2189
+ contentName: host.contentName,
2190
+ });
2191
+ }
2192
+ });
2193
+
2194
+ // Send discovery query to trigger immediate responses
2195
+ // Small delay to ensure listener is ready
2196
+ setTimeout(() => {
2197
+ if (!resolved) {
2198
+ listener.sendQuery();
2199
+ }
2200
+ }, DISCOVERY_QUERY_DELAY_MS);
2201
+
2202
+ // Timeout if no host found
2203
+ setTimeout(() => {
2204
+ if (!resolved) {
2205
+ cleanup();
2206
+ resolve(null);
2207
+ }
2208
+ }, timeoutMs);
2209
+ });
2210
+ }
2211
+
2212
+ /**
2213
+ * Connect to a netplay server (client mode).
2214
+ */
2215
+ async connectToNetplay(options: Partial<NetplayClientOptions> = {}): Promise<void> {
2216
+ if (this.isNetplayActive()) {
2217
+ throw new NetplayError('ALREADY_ACTIVE');
2218
+ }
2219
+
2220
+
2221
+
2222
+ // Parse host:port from connect string
2223
+ let host = options.host ?? '';
2224
+ let port = options.port ?? NETPLAY_DEFAULT_PORT;
2225
+
2226
+ // If no host specified, use LAN discovery to find one
2227
+ if (!host) {
2228
+ const searchMsg = 'Searching for LAN hosts...';
2229
+ netplayLogger.info('CLIENT', searchMsg);
2230
+ notify({ title: 'Netplay', message: searchMsg, severity: 'info' });
2231
+
2232
+ const discovered = await this.discoverLanHost(port, DISCOVERY_TIMEOUT_MS);
2233
+
2234
+ if (!discovered) {
2235
+ netplayLogger.error('CLIENT', 'No netplay hosts found on LAN');
2236
+ throw new NetplayError('NO_HOSTS_FOUND');
2237
+ }
2238
+
2239
+ host = discovered.address;
2240
+ port = discovered.port;
2241
+
2242
+ const foundMsg = `Found: ${discovered.nickname} (${discovered.contentName})`;
2243
+ netplayLogger.info('CLIENT', foundMsg, {
2244
+ address: discovered.address,
2245
+ port: discovered.port,
2246
+ nickname: discovered.nickname,
2247
+ contentName: discovered.contentName,
2248
+ });
2249
+ notify({ title: 'Netplay', message: foundMsg, severity: 'info' });
2250
+ }
2251
+
2252
+ // Parse host:port if combined
2253
+ if (host.includes(':')) {
2254
+ const parts = host.split(':');
2255
+ host = parts[0];
2256
+ port = parseInt(parts[1], 10) || port;
2257
+ }
2258
+
2259
+ // Store connection info for potential reconnection
2260
+ this.netplayHost = host;
2261
+ this.netplayPort = port;
2262
+
2263
+ this.netplayClient = createNetplayClient({
2264
+ host,
2265
+ port,
2266
+ password: options.password ?? '',
2267
+ nickname: options.nickname ?? 'Player',
2268
+ inputDelayFrames: options.inputDelayFrames ?? 0,
2269
+ spectate: options.spectate ?? false,
2270
+ });
2271
+
2272
+ // Set up event handlers
2273
+ this.netplayClient.on('connected', () => {
2274
+ const message = `Connected to ${host}:${port}`;
2275
+ netplayLogger.info('CLIENT', message, { host, port });
2276
+ notify({ title: 'Netplay', message, severity: 'info' });
2277
+ });
2278
+
2279
+ this.netplayClient.on('disconnected', (reason) => {
2280
+ const message = `Disconnected: ${reason}`;
2281
+ netplayLogger.warn('CLIENT', message, { reason });
2282
+ notify({ title: 'Netplay', message, severity: 'warn' });
2283
+ this.netplayClient = null;
2284
+ // Mark netplay disconnect with reason and stop the emulator
2285
+ this.netplayDisconnected = true;
2286
+ this.netplayDisconnectReason = reason;
2287
+ this.stop();
2288
+ });
2289
+
2290
+ this.netplayClient.on('synced', (frameNumber) => {
2291
+ const message = `Synced at frame ${frameNumber}`;
2292
+ netplayLogger.info('CLIENT', message, { frameNumber });
2293
+ notify({ title: 'Netplay', message, severity: 'info' });
2294
+ });
2295
+
2296
+ this.netplayClient.on('desync', (frameNumber, localCrc, remoteCrc) => {
2297
+ const message = `Desync at frame ${frameNumber}, requesting recovery...`;
2298
+ netplayLogger.warn('CLIENT', message, { frameNumber, localCrc, remoteCrc });
2299
+ notify({ title: 'Netplay', message, severity: 'warn' });
2300
+ });
2301
+
2302
+ this.netplayClient.on('rollback', (frames) => {
2303
+ // Only notify on significant rollbacks, but always log
2304
+ const message = `Rollback: ${frames} frames`;
2305
+ netplayLogger.debug('CLIENT', message, { frames });
2306
+ if (frames >= ROLLBACK_NOTIFICATION_THRESHOLD) {
2307
+ notify({ title: 'Netplay', message, severity: 'debug' });
2308
+ }
2309
+ });
2310
+
2311
+ this.netplayClient.on('paused', (by) => {
2312
+ const message = `Paused by ${by}`;
2313
+ netplayLogger.info('CLIENT', message, { by });
2314
+ notify({ title: 'Netplay', message, severity: 'info' });
2315
+ });
2316
+
2317
+ this.netplayClient.on('resumed', () => {
2318
+ const message = 'Resumed';
2319
+ netplayLogger.info('CLIENT', message);
2320
+ notify({ title: 'Netplay', message, severity: 'info' });
2321
+ });
2322
+
2323
+ this.netplayClient.on('chat', (from, chatMessage) => {
2324
+ netplayLogger.info('CLIENT', `Chat from ${from}: ${chatMessage}`, { from, chatMessage });
2325
+ notify({ title: from, message: chatMessage, severity: 'info' });
2326
+ });
2327
+
2328
+ this.netplayClient.on('state-load', (frameNumber, state) => {
2329
+ // Load the state from server into the core
2330
+ try {
2331
+ this.core.setState(state);
2332
+ const message = `State loaded at frame ${frameNumber}`;
2333
+ netplayLogger.info('CLIENT', message, { frameNumber, stateSize: state.length });
2334
+ notify({ title: 'Netplay', message, severity: 'info' });
2335
+ } catch (err) {
2336
+ const errorMessage = `Failed to load state: ${getErrorMessage(err)}`;
2337
+ netplayLogger.error('CLIENT', errorMessage, { frameNumber, error: getErrorMessage(err) });
2338
+ notify({ title: 'Netplay Error', message: errorMessage, severity: 'error' });
2339
+ }
2340
+ });
2341
+
2342
+ this.netplayClient.on('error', (error) => {
2343
+ netplayLogger.error('CLIENT', error.message, { error: error.message });
2344
+ notify({ title: 'Netplay Error', message: error.message, severity: 'error' });
2345
+ });
2346
+
2347
+ // Set core info for compatibility checking
2348
+ this.netplayClient.setCoreInfo(
2349
+ this.systemInfo.coreName ?? this.systemInfo.name,
2350
+ this.systemInfo.coreVersion ?? '',
2351
+ this.contentCrc
2352
+ );
2353
+
2354
+ await this.netplayClient.connect();
2355
+ }
2356
+
2357
+ /**
2358
+ * Disconnect from netplay (both server and client modes).
2359
+ */
2360
+ disconnectNetplay(): void {
2361
+ if (this.netplayServer) {
2362
+ this.netplayServer.stop();
2363
+ this.netplayServer = null;
2364
+ }
2365
+ if (this.netplayClient) {
2366
+ this.netplayClient.disconnect();
2367
+ this.netplayClient = null;
2368
+ }
2369
+ }
2370
+
2371
+ /**
2372
+ * Initialize netplay from stored options (called from run()).
2373
+ */
2374
+ private async initializeNetplay(): Promise<void> {
2375
+ if (!this.netplayOptions) {
2376
+ return;
2377
+ }
2378
+
2379
+ const opts = this.netplayOptions;
2380
+ this.netplayOptions = null; // Clear so we don't re-init
2381
+
2382
+ try {
2383
+ if (opts.netplayHost) {
2384
+ await this.startNetplayServer({
2385
+ port: opts.netplayPort,
2386
+ password: opts.netplayPassword,
2387
+ inputDelayFrames: opts.netplayInputDelay,
2388
+ nickname: opts.netplayNickname,
2389
+ });
2390
+ } else if (opts.netplayConnect !== undefined) {
2391
+ // netplayConnect can be empty string for LAN discovery
2392
+ await this.connectToNetplay({
2393
+ host: opts.netplayConnect,
2394
+ port: opts.netplayPort,
2395
+ password: opts.netplayPassword,
2396
+ inputDelayFrames: opts.netplayInputDelay,
2397
+ nickname: opts.netplayNickname,
2398
+ spectate: opts.netplaySpectate,
2399
+ });
2400
+ }
2401
+ } catch (err) {
2402
+ const error = err instanceof Error ? err : new Error(String(err));
2403
+ netplayLogger.error('CLIENT', `Setup failed: ${error.message}`, { error: error.message });
2404
+ notify({ title: 'Netplay Error', message: error.message, severity: 'error' });
2405
+ // Re-throw to prevent game from starting without netplay
2406
+ throw error;
2407
+ }
2408
+ }
2409
+
2410
+ // Get current frame buffer for external rendering
2411
+ getFrameBuffer(): Uint8Array | Uint16Array {
2412
+ return this.core.getFramebuffer();
2413
+ }
2414
+
2415
+ /**
2416
+ * Prompt user for confirmation with keyboard and gamepad (A=yes, B=no) support
2417
+ * @param message The question to ask
2418
+ * @param defaultYes If true, default is Y. If false, default is N.
2419
+ * @returns Promise that resolves to true if user confirms, false otherwise
2420
+ */
2421
+ private promptConfirmation(message: string, defaultYes: boolean = false): Promise<boolean> {
2422
+ return new Promise((resolve) => {
2423
+ // Check if gamepad is available
2424
+ const hasGamepad = this.gamepadManager !== null;
2425
+
2426
+ // Build prompt with appropriate default and gamepad hint
2427
+ const defaultHint = defaultYes ? '[Y/n]' : '[y/N]';
2428
+ const gamepadHint = hasGamepad ? ', A/B' : '';
2429
+ process.stdout.write(`${message} (${defaultHint}${gamepadHint}): `);
2430
+
2431
+ // Set up keyboard input
2432
+ const wasRaw = process.stdin.isRaw;
2433
+ process.stdin.setRawMode(true);
2434
+ process.stdin.resume();
2435
+
2436
+ let resolved = false;
2437
+ let gamepadInterval: ReturnType<typeof setInterval> | null = null;
2438
+
2439
+ const cleanup = () => {
2440
+ if (resolved) {return;}
2441
+ resolved = true;
2442
+ process.stdin.setRawMode(wasRaw);
2443
+ process.stdin.pause();
2444
+ process.stdin.removeListener('data', onKeyPress);
2445
+ if (gamepadInterval) {
2446
+ clearInterval(gamepadInterval);
2447
+ }
2448
+ logger.info(''); // New line after prompt
2449
+ };
2450
+
2451
+ const onKeyPress = (data: Buffer) => {
2452
+ const key = data.toString().toLowerCase();
2453
+ if (key === 'y') {
2454
+ cleanup();
2455
+ resolve(true);
2456
+ } else if (key === 'n') {
2457
+ cleanup();
2458
+ resolve(false);
2459
+ } else if (key === '\r' || key === '\n') {
2460
+ // Enter = use default
2461
+ cleanup();
2462
+ resolve(defaultYes);
2463
+ } else if (key === '\x1b') {
2464
+ // Escape = no
2465
+ cleanup();
2466
+ resolve(false);
2467
+ }
2468
+ };
2469
+
2470
+ process.stdin.on('data', onKeyPress);
2471
+
2472
+ // Set up gamepad input if available
2473
+ if (hasGamepad) {
2474
+ gamepadInterval = setInterval(() => {
2475
+ if (resolved) {
2476
+ return;
2477
+ }
2478
+ // Check if A or Start is pressed (confirm)
2479
+ const aPressed = this.controller1.getButton(Button.A);
2480
+ const startPressed = this.controller1.getButton(Button.Start);
2481
+ if (aPressed || startPressed) {
2482
+ logger.info(aPressed ? 'A' : 'Start'); // Echo the selection
2483
+ cleanup();
2484
+ resolve(true);
2485
+ }
2486
+ // Check if B is pressed (cancel)
2487
+ if (this.controller1.getButton(Button.B)) {
2488
+ logger.info('B'); // Echo the selection
2489
+ cleanup();
2490
+ resolve(false);
2491
+ }
2492
+ }, GAMEPAD_DIALOG_POLL_INTERVAL_MS);
2493
+ }
2494
+ });
2495
+ }
2496
+ }