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.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- 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
|
+
}
|