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