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,937 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LibretroCore - Wrapper for native libretro cores
|
|
3
|
+
*
|
|
4
|
+
* This class implements the Core interface by wrapping a native libretro
|
|
5
|
+
* core (.dylib/.so/.dll) using FFI. It allows emoemu to run games using
|
|
6
|
+
* existing libretro cores like genesis_plus_gx, mGBA, snes9x, etc.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { extname } from "path";
|
|
11
|
+
import koffi from "koffi";
|
|
12
|
+
import type {
|
|
13
|
+
Core,
|
|
14
|
+
SystemInfo,
|
|
15
|
+
AudioConfig,
|
|
16
|
+
ButtonDefinition,
|
|
17
|
+
CoreMessage,
|
|
18
|
+
CoreMessageCallback,
|
|
19
|
+
} from "../../core/core";
|
|
20
|
+
import { LibretroAPI } from "./api";
|
|
21
|
+
import { EnvironmentHandler } from "./environment";
|
|
22
|
+
import { CallbackManager } from "./callbacks";
|
|
23
|
+
import { convertFramebuffer, detectContentBounds, hasFrameContent, type ContentBounds } from "./pixelFormat";
|
|
24
|
+
import {
|
|
25
|
+
RETRO_DEVICE_ID_JOYPAD,
|
|
26
|
+
RETRO_MEMORY,
|
|
27
|
+
RETRO_DEVICE,
|
|
28
|
+
RETRO_MESSAGE_TYPE,
|
|
29
|
+
RETRO_LOG,
|
|
30
|
+
LibretroError,
|
|
31
|
+
} from "./types";
|
|
32
|
+
import type { MessageSeverity } from "../../core/core";
|
|
33
|
+
import { DEFAULT_SAMPLE_RATE, RGB24_BYTES_PER_PIXEL, ASPECT_RATIO_DECIMALS, FPS_DECIMALS, INT16_MAX_POSITIVE, DEBUG_INITIAL_FRAME_LOG_COUNT, ANALOG_NORMALIZED_THRESHOLD } from "./consts";
|
|
34
|
+
import { logger } from "../../utils/logger";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for creating a LibretroCore instance
|
|
38
|
+
*/
|
|
39
|
+
export interface LibretroCoreOptions {
|
|
40
|
+
/** Core options in RetroArch format (e.g., {"mupen64plus-rdp-plugin": "angrylion"}) */
|
|
41
|
+
coreOptions?: Record<string, string>;
|
|
42
|
+
/** System directory path for BIOS files */
|
|
43
|
+
systemDirectory?: string;
|
|
44
|
+
/** Save directory path */
|
|
45
|
+
saveDirectory?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* LibretroCore implements the Core interface for libretro cores
|
|
50
|
+
*/
|
|
51
|
+
export class LibretroCore implements Core {
|
|
52
|
+
private api: LibretroAPI;
|
|
53
|
+
private envHandler: EnvironmentHandler;
|
|
54
|
+
private callbacks: CallbackManager;
|
|
55
|
+
private systemInfo: SystemInfo;
|
|
56
|
+
private romData: Buffer | null = null;
|
|
57
|
+
private audioCallback: ((samples: Float32Array) => void) | null = null;
|
|
58
|
+
private gameLoaded = false;
|
|
59
|
+
// Cached pixel format to avoid method call overhead in hot path
|
|
60
|
+
private cachedPixelFormat: number = 0;
|
|
61
|
+
// Detected content bounds for auto-cropping (null = no cropping needed)
|
|
62
|
+
private contentBounds: ContentBounds | null = null;
|
|
63
|
+
// Original display aspect ratio from AV info (used for cropping to preserve intended aspect)
|
|
64
|
+
private originalDisplayAspect: number = 0;
|
|
65
|
+
// Cached RGB24 framebuffer to avoid double conversion during bounds detection
|
|
66
|
+
// The cache is valid only for the current frame (invalidated on next runFrame)
|
|
67
|
+
private cachedRgb24Framebuffer: Uint8Array | null = null;
|
|
68
|
+
private cachedRgb24FrameId: number = 0; // Unique ID to track frame validity
|
|
69
|
+
private currentFrameId: number = 0; // Incremented each runFrame()
|
|
70
|
+
// Reusable buffer for cropping to avoid allocations
|
|
71
|
+
private cropOutputBuffer: Uint8Array | null = null;
|
|
72
|
+
private cropOutputCapacity: number = 0;
|
|
73
|
+
|
|
74
|
+
constructor(corePath: string, options?: LibretroCoreOptions) {
|
|
75
|
+
this.envHandler = new EnvironmentHandler();
|
|
76
|
+
|
|
77
|
+
// Configure directories before core init
|
|
78
|
+
if (options?.systemDirectory) {
|
|
79
|
+
this.envHandler.setSystemDirectory(options.systemDirectory);
|
|
80
|
+
}
|
|
81
|
+
if (options?.saveDirectory) {
|
|
82
|
+
this.envHandler.setSaveDirectory(options.saveDirectory);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Set core options before init (some cores query options during init)
|
|
86
|
+
if (options?.coreOptions) {
|
|
87
|
+
this.envHandler.setCoreOptions(options.coreOptions);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.api = new LibretroAPI(corePath);
|
|
91
|
+
this.callbacks = new CallbackManager(this.envHandler);
|
|
92
|
+
|
|
93
|
+
// Set up callbacks BEFORE retro_init (required by some cores)
|
|
94
|
+
this.callbacks.createCallbacks(this.api);
|
|
95
|
+
|
|
96
|
+
// Initialize the core
|
|
97
|
+
this.api.retro_init();
|
|
98
|
+
|
|
99
|
+
// Build initial system info from core
|
|
100
|
+
this.systemInfo = this.buildSystemInfo();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build SystemInfo from the libretro core's system info
|
|
105
|
+
*/
|
|
106
|
+
private buildSystemInfo(): SystemInfo {
|
|
107
|
+
const info = this.api.getSystemInfo();
|
|
108
|
+
|
|
109
|
+
// Generate a unique ID from the library name
|
|
110
|
+
// No "libretro-" prefix - core type is identified by path !== "native"
|
|
111
|
+
// Uses underscores to match buildbot naming convention (e.g., mupen64plus_next)
|
|
112
|
+
const id = info.library_name
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
115
|
+
.replace(/^_|_$/g, "");
|
|
116
|
+
|
|
117
|
+
// Parse extensions (format: "md|gen|sms|gg")
|
|
118
|
+
const extensions = info.valid_extensions
|
|
119
|
+
.split("|")
|
|
120
|
+
.map((ext) => `.${ext.toLowerCase()}`);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
name: `${info.library_name} ${info.library_version}`,
|
|
125
|
+
coreName: info.library_name,
|
|
126
|
+
coreVersion: info.library_version,
|
|
127
|
+
extensions,
|
|
128
|
+
// Default values - updated after ROM load
|
|
129
|
+
width: 320,
|
|
130
|
+
height: 240,
|
|
131
|
+
fps: 60,
|
|
132
|
+
sampleRate: DEFAULT_SAMPLE_RATE,
|
|
133
|
+
pixelAspectRatio: 1,
|
|
134
|
+
maxPlayers: 2,
|
|
135
|
+
buttons: this.getDefaultButtons(),
|
|
136
|
+
colorSpace: "rgb24", // We convert all formats to RGB24
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get default button definitions for libretro joypad
|
|
142
|
+
*/
|
|
143
|
+
private getDefaultButtons(): ButtonDefinition[] {
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
id: RETRO_DEVICE_ID_JOYPAD.B,
|
|
147
|
+
name: "B",
|
|
148
|
+
defaultKey: "j",
|
|
149
|
+
defaultGamepad: "B",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: RETRO_DEVICE_ID_JOYPAD.Y,
|
|
153
|
+
name: "Y",
|
|
154
|
+
defaultKey: "u",
|
|
155
|
+
defaultGamepad: "Y",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: RETRO_DEVICE_ID_JOYPAD.SELECT,
|
|
159
|
+
name: "Select",
|
|
160
|
+
defaultKey: " ",
|
|
161
|
+
defaultGamepad: "Back",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: RETRO_DEVICE_ID_JOYPAD.START,
|
|
165
|
+
name: "Start",
|
|
166
|
+
defaultKey: "Enter",
|
|
167
|
+
defaultGamepad: "Start",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: RETRO_DEVICE_ID_JOYPAD.UP,
|
|
171
|
+
name: "Up",
|
|
172
|
+
defaultKey: "w",
|
|
173
|
+
defaultGamepad: "DPadUp",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: RETRO_DEVICE_ID_JOYPAD.DOWN,
|
|
177
|
+
name: "Down",
|
|
178
|
+
defaultKey: "s",
|
|
179
|
+
defaultGamepad: "DPadDown",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: RETRO_DEVICE_ID_JOYPAD.LEFT,
|
|
183
|
+
name: "Left",
|
|
184
|
+
defaultKey: "a",
|
|
185
|
+
defaultGamepad: "DPadLeft",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: RETRO_DEVICE_ID_JOYPAD.RIGHT,
|
|
189
|
+
name: "Right",
|
|
190
|
+
defaultKey: "d",
|
|
191
|
+
defaultGamepad: "DPadRight",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: RETRO_DEVICE_ID_JOYPAD.A,
|
|
195
|
+
name: "A",
|
|
196
|
+
defaultKey: "k",
|
|
197
|
+
defaultGamepad: "A",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: RETRO_DEVICE_ID_JOYPAD.X,
|
|
201
|
+
name: "X",
|
|
202
|
+
defaultKey: "i",
|
|
203
|
+
defaultGamepad: "X",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: RETRO_DEVICE_ID_JOYPAD.L,
|
|
207
|
+
name: "L",
|
|
208
|
+
defaultKey: "q",
|
|
209
|
+
defaultGamepad: "LB",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: RETRO_DEVICE_ID_JOYPAD.R,
|
|
213
|
+
name: "R",
|
|
214
|
+
defaultKey: "e",
|
|
215
|
+
defaultGamepad: "RB",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: RETRO_DEVICE_ID_JOYPAD.L2,
|
|
219
|
+
name: "L2",
|
|
220
|
+
defaultKey: "1",
|
|
221
|
+
defaultGamepad: "LT",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: RETRO_DEVICE_ID_JOYPAD.R2,
|
|
225
|
+
name: "R2",
|
|
226
|
+
defaultKey: "3",
|
|
227
|
+
defaultGamepad: "RT",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: RETRO_DEVICE_ID_JOYPAD.L3,
|
|
231
|
+
name: "L3",
|
|
232
|
+
defaultKey: "z",
|
|
233
|
+
defaultGamepad: "LS",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: RETRO_DEVICE_ID_JOYPAD.R3,
|
|
237
|
+
name: "R3",
|
|
238
|
+
defaultKey: "c",
|
|
239
|
+
defaultGamepad: "RS",
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
//==========================================================================
|
|
245
|
+
// Lifecycle
|
|
246
|
+
//==========================================================================
|
|
247
|
+
|
|
248
|
+
getSystemInfo(): SystemInfo {
|
|
249
|
+
// Start with base system info
|
|
250
|
+
const info = { ...this.systemInfo };
|
|
251
|
+
|
|
252
|
+
// Use actual frame dimensions if we've received frames
|
|
253
|
+
if (this.callbacks.frameWidth > 0 && this.callbacks.frameHeight > 0) {
|
|
254
|
+
info.width = this.callbacks.frameWidth;
|
|
255
|
+
info.height = this.callbacks.frameHeight;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// If we detected content bounds (auto-crop), use those dimensions
|
|
259
|
+
// and adjust PAR to preserve the original display aspect ratio from AV info
|
|
260
|
+
if (this.contentBounds) {
|
|
261
|
+
info.width = this.contentBounds.width;
|
|
262
|
+
info.height = this.contentBounds.height;
|
|
263
|
+
// Use the original display aspect ratio (e.g., 4:3 for N64) from AV info
|
|
264
|
+
// newPAR = originalDisplayAspect * croppedHeight / croppedWidth
|
|
265
|
+
if (this.originalDisplayAspect > 0) {
|
|
266
|
+
info.pixelAspectRatio = (this.originalDisplayAspect * info.height) / info.width;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// If core reported geometry via SET_GEOMETRY, use that aspect ratio
|
|
271
|
+
// This gives us the actual content dimensions (some cores report cropped content)
|
|
272
|
+
const geometry = this.envHandler.getGeometry();
|
|
273
|
+
if (geometry) {
|
|
274
|
+
info.pixelAspectRatio = geometry.aspectRatio / (info.width / info.height);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return info;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Detect content bounds in the current framebuffer for auto-cropping.
|
|
282
|
+
* Call this after running bootstrap frames to detect blank borders.
|
|
283
|
+
* Bounds can only expand, never shrink - this handles cases where early
|
|
284
|
+
* frames (logos, menus) have smaller content than actual gameplay.
|
|
285
|
+
*
|
|
286
|
+
* @returns object with:
|
|
287
|
+
* - hasContent: true if frame had content (not blank)
|
|
288
|
+
* - boundsChanged: true if bounds expanded and renderer needs update
|
|
289
|
+
*/
|
|
290
|
+
detectContentBounds(): { hasContent: boolean; boundsChanged: boolean } {
|
|
291
|
+
const fb = this.callbacks.framebuffer;
|
|
292
|
+
if (!fb || this.callbacks.frameWidth === 0 || this.callbacks.frameHeight === 0) {
|
|
293
|
+
logger.debug(
|
|
294
|
+
`detectContentBounds: no framebuffer (fb=${!!fb}, w=${this.callbacks.frameWidth}, h=${this.callbacks.frameHeight})`,
|
|
295
|
+
'Core'
|
|
296
|
+
);
|
|
297
|
+
return { hasContent: false, boundsChanged: false };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Quick pre-check: sample native framebuffer to detect blank frames
|
|
301
|
+
// Avoids expensive RGB24 conversion during N64 startup blank frames
|
|
302
|
+
if (this.isFramebufferUniform(fb)) {
|
|
303
|
+
logger.debug('detectContentBounds: native framebuffer is uniform, skipping', 'Core');
|
|
304
|
+
return { hasContent: false, boundsChanged: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Convert framebuffer to RGB24 for analysis
|
|
308
|
+
// Cache the result so getFramebuffer() can reuse it (avoids double conversion)
|
|
309
|
+
const rgb24 = convertFramebuffer(
|
|
310
|
+
fb,
|
|
311
|
+
this.callbacks.frameWidth,
|
|
312
|
+
this.callbacks.frameHeight,
|
|
313
|
+
this.callbacks.framePitch,
|
|
314
|
+
this.cachedPixelFormat
|
|
315
|
+
);
|
|
316
|
+
this.cachedRgb24Framebuffer = rgb24;
|
|
317
|
+
this.cachedRgb24FrameId = this.currentFrameId;
|
|
318
|
+
|
|
319
|
+
// Check if frame has actual content (not all black/uniform)
|
|
320
|
+
// N64 games may output many blank frames before video starts
|
|
321
|
+
if (!hasFrameContent(rgb24, this.callbacks.frameWidth, this.callbacks.frameHeight)) {
|
|
322
|
+
logger.debug('detectContentBounds: frame is blank, will retry', 'Core');
|
|
323
|
+
return { hasContent: false, boundsChanged: false };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const newBounds = detectContentBounds(rgb24, this.callbacks.frameWidth, this.callbacks.frameHeight);
|
|
327
|
+
if (!newBounds) {
|
|
328
|
+
// Content fills the entire frame - no cropping needed
|
|
329
|
+
if (this.contentBounds) {
|
|
330
|
+
// We had bounds before, now content fills frame - expand to full
|
|
331
|
+
logger.info(
|
|
332
|
+
`Auto-crop expanded to full frame: ${this.contentBounds.width}x${this.contentBounds.height} -> ` +
|
|
333
|
+
`${this.callbacks.frameWidth}x${this.callbacks.frameHeight}`,
|
|
334
|
+
'Core'
|
|
335
|
+
);
|
|
336
|
+
this.contentBounds = null;
|
|
337
|
+
return { hasContent: true, boundsChanged: true };
|
|
338
|
+
}
|
|
339
|
+
return { hasContent: true, boundsChanged: false };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Expand bounds - only grow, never shrink
|
|
343
|
+
if (!this.contentBounds) {
|
|
344
|
+
// First detection
|
|
345
|
+
this.contentBounds = newBounds;
|
|
346
|
+
logger.info(
|
|
347
|
+
`Auto-crop: ${this.callbacks.frameWidth}x${this.callbacks.frameHeight} -> ` +
|
|
348
|
+
`${newBounds.width}x${newBounds.height} (top=${newBounds.top}, left=${newBounds.left})`,
|
|
349
|
+
'Core'
|
|
350
|
+
);
|
|
351
|
+
return { hasContent: true, boundsChanged: true };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Expand existing bounds (top/left can decrease, bottom/right can increase)
|
|
355
|
+
const expanded: ContentBounds = {
|
|
356
|
+
top: Math.min(this.contentBounds.top, newBounds.top),
|
|
357
|
+
left: Math.min(this.contentBounds.left, newBounds.left),
|
|
358
|
+
bottom: Math.max(this.contentBounds.bottom, newBounds.bottom),
|
|
359
|
+
right: Math.max(this.contentBounds.right, newBounds.right),
|
|
360
|
+
width: 0, // Calculated below
|
|
361
|
+
height: 0, // Calculated below
|
|
362
|
+
};
|
|
363
|
+
expanded.width = expanded.right - expanded.left + 1;
|
|
364
|
+
expanded.height = expanded.bottom - expanded.top + 1;
|
|
365
|
+
|
|
366
|
+
// Check if bounds actually expanded
|
|
367
|
+
if (expanded.width > this.contentBounds.width || expanded.height > this.contentBounds.height) {
|
|
368
|
+
logger.info(
|
|
369
|
+
`Auto-crop expanded: ${this.contentBounds.width}x${this.contentBounds.height} -> ` +
|
|
370
|
+
`${expanded.width}x${expanded.height}`,
|
|
371
|
+
'Core'
|
|
372
|
+
);
|
|
373
|
+
this.contentBounds = expanded;
|
|
374
|
+
return { hasContent: true, boundsChanged: true };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return { hasContent: true, boundsChanged: false };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
loadRom(romPath: string): void {
|
|
381
|
+
// Read the ROM file
|
|
382
|
+
try {
|
|
383
|
+
this.romData = readFileSync(romPath);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
throw new LibretroError('ROM_READ_FAILED', romPath);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Clear recent logs to get fresh messages for this load attempt
|
|
389
|
+
this.envHandler.clearRecentLogs();
|
|
390
|
+
|
|
391
|
+
// Load the game into the core
|
|
392
|
+
const success = this.api.loadGame(romPath, this.romData, null);
|
|
393
|
+
if (!success) {
|
|
394
|
+
// Log diagnostic info to help debug ROM rejection
|
|
395
|
+
const coreInfo = this.api.getSystemInfo();
|
|
396
|
+
const romExt = extname(romPath).toLowerCase();
|
|
397
|
+
const romSize = this.romData.length;
|
|
398
|
+
const systemDir = this.envHandler.getSystemDirectory();
|
|
399
|
+
|
|
400
|
+
// Log diagnostic details (the error message itself will be logged when caught)
|
|
401
|
+
logger.error(`Core: ${coreInfo.library_name} ${coreInfo.library_version}`, 'Core');
|
|
402
|
+
logger.error(`ROM extension: ${romExt}`, 'Core');
|
|
403
|
+
logger.error(`Valid extensions: ${coreInfo.valid_extensions || '(none reported)'}`, 'Core');
|
|
404
|
+
logger.error(`ROM size: ${romSize.toLocaleString()} bytes`, 'Core');
|
|
405
|
+
logger.error(`System directory: ${systemDir}`, 'Core');
|
|
406
|
+
|
|
407
|
+
// Include any log messages from the core (already formatted)
|
|
408
|
+
const coreLogs = this.envHandler.getRecentLogsFormatted();
|
|
409
|
+
for (const log of coreLogs) {
|
|
410
|
+
// These are already formatted as [LEVEL] [Core] message, log raw
|
|
411
|
+
console.error(log);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
throw new LibretroError('ROM_REJECTED', romPath);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
this.gameLoaded = true;
|
|
418
|
+
|
|
419
|
+
// Update system info with actual AV info from the core
|
|
420
|
+
try {
|
|
421
|
+
const avInfo = this.api.getSystemAVInfo();
|
|
422
|
+
this.systemInfo.width = avInfo.geometry.base_width;
|
|
423
|
+
this.systemInfo.height = avInfo.geometry.base_height;
|
|
424
|
+
this.systemInfo.fps = avInfo.timing.fps;
|
|
425
|
+
this.systemInfo.sampleRate = avInfo.timing.sample_rate || DEFAULT_SAMPLE_RATE;
|
|
426
|
+
|
|
427
|
+
// Store original display aspect ratio for use when cropping
|
|
428
|
+
// This is the intended display aspect (e.g., 4:3 for N64) regardless of actual frame dimensions
|
|
429
|
+
if (avInfo.geometry.aspect_ratio > 0) {
|
|
430
|
+
this.originalDisplayAspect = avInfo.geometry.aspect_ratio;
|
|
431
|
+
} else {
|
|
432
|
+
this.originalDisplayAspect = avInfo.geometry.base_width / avInfo.geometry.base_height;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Calculate pixel aspect ratio if provided
|
|
436
|
+
if (avInfo.geometry.aspect_ratio > 0) {
|
|
437
|
+
// aspect_ratio is display aspect ratio (e.g., 4:3)
|
|
438
|
+
// pixelAspectRatio = display_aspect / pixel_aspect
|
|
439
|
+
// pixel_aspect = width / height
|
|
440
|
+
const pixelAspect =
|
|
441
|
+
avInfo.geometry.base_width / avInfo.geometry.base_height;
|
|
442
|
+
this.systemInfo.pixelAspectRatio =
|
|
443
|
+
avInfo.geometry.aspect_ratio / pixelAspect;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Log core geometry info (RetroArch-style)
|
|
447
|
+
const aspectStr = avInfo.geometry.aspect_ratio > 0
|
|
448
|
+
? avInfo.geometry.aspect_ratio.toFixed(ASPECT_RATIO_DECIMALS)
|
|
449
|
+
: (avInfo.geometry.base_width / avInfo.geometry.base_height).toFixed(ASPECT_RATIO_DECIMALS);
|
|
450
|
+
logger.info(
|
|
451
|
+
`Geometry: ${avInfo.geometry.base_width}x${avInfo.geometry.base_height}, ` +
|
|
452
|
+
`Aspect: ${aspectStr}, FPS: ${avInfo.timing.fps.toFixed(FPS_DECIMALS)}, ` +
|
|
453
|
+
`Sample rate: ${this.systemInfo.sampleRate.toFixed(FPS_DECIMALS)} Hz`,
|
|
454
|
+
'Core'
|
|
455
|
+
);
|
|
456
|
+
} catch {
|
|
457
|
+
// Use defaults if AV info fails
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Set up controller ports using the best available controller type from the core
|
|
461
|
+
// Device type selection priority:
|
|
462
|
+
// 1. Device subtypes (id >= 256) - core-specific controllers like N64Pad (257)
|
|
463
|
+
// 2. Standard JOYPAD (id=1) - works for most cores (SNES, Genesis, etc.)
|
|
464
|
+
// 3. First available type - fallback
|
|
465
|
+
// This avoids selecting Mouse (2), Keyboard (3), etc. over JOYPAD
|
|
466
|
+
const DEVICE_SUBTYPE_THRESHOLD = 256;
|
|
467
|
+
for (let port = 0; port < 2; port++) {
|
|
468
|
+
const types = this.envHandler.getControllerTypes(port);
|
|
469
|
+
if (types.length === 0) {
|
|
470
|
+
// No controller info from core, use default JOYPAD
|
|
471
|
+
this.api.retro_set_controller_port_device(port, RETRO_DEVICE.JOYPAD);
|
|
472
|
+
logger.debug(`Controller port ${port} set to device ${RETRO_DEVICE.JOYPAD} (JOYPAD)`, 'Core');
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// Prefer device subtypes (like N64Pad=257), then JOYPAD, then first available
|
|
476
|
+
const subtypeController = types.find(t => t.id >= DEVICE_SUBTYPE_THRESHOLD);
|
|
477
|
+
const joypadController = types.find(t => t.id === RETRO_DEVICE.JOYPAD);
|
|
478
|
+
const selectedType = subtypeController ?? joypadController ?? types[0];
|
|
479
|
+
this.api.retro_set_controller_port_device(port, selectedType.id);
|
|
480
|
+
logger.debug(`Controller port ${port} set to device ${selectedType.id} (${selectedType.desc})`, 'Core');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Cache pixel format (set by core during init/load via SET_PIXEL_FORMAT)
|
|
484
|
+
this.cachedPixelFormat = this.envHandler.getPixelFormat();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
reset(): void {
|
|
488
|
+
if (this.gameLoaded) {
|
|
489
|
+
this.api.retro_reset();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
destroy(): void {
|
|
494
|
+
if (this.gameLoaded) {
|
|
495
|
+
this.api.retro_unload_game();
|
|
496
|
+
this.gameLoaded = false;
|
|
497
|
+
}
|
|
498
|
+
this.api.retro_deinit();
|
|
499
|
+
this.callbacks.destroy();
|
|
500
|
+
this.envHandler.cleanup();
|
|
501
|
+
this.api.destroy();
|
|
502
|
+
this.romData = null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
//==========================================================================
|
|
506
|
+
// Emulation
|
|
507
|
+
//==========================================================================
|
|
508
|
+
|
|
509
|
+
// Debug: track frame count
|
|
510
|
+
private runFrameCount = 0;
|
|
511
|
+
|
|
512
|
+
runFrame(): void {
|
|
513
|
+
if (!this.gameLoaded) {return;}
|
|
514
|
+
|
|
515
|
+
// Increment frame ID to invalidate any cached framebuffer from previous frame
|
|
516
|
+
this.currentFrameId++;
|
|
517
|
+
|
|
518
|
+
// Debug: Log initial frames with timing
|
|
519
|
+
this.runFrameCount++;
|
|
520
|
+
const startTime = performance.now();
|
|
521
|
+
|
|
522
|
+
// Run one frame
|
|
523
|
+
this.api.retro_run();
|
|
524
|
+
|
|
525
|
+
if (this.runFrameCount <= DEBUG_INITIAL_FRAME_LOG_COUNT) {
|
|
526
|
+
const elapsed = performance.now() - startTime;
|
|
527
|
+
logger.debug(`runFrame() frame ${this.runFrameCount}: took ${elapsed.toFixed(2)}ms`, 'Core');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Push audio samples if callback is set
|
|
531
|
+
if (this.audioCallback && this.callbacks.hasAudio()) {
|
|
532
|
+
const samples = this.callbacks.drainAudio();
|
|
533
|
+
this.audioCallback(samples);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
isFrameComplete(): boolean {
|
|
538
|
+
// libretro cores always complete one frame per retro_run()
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
//==========================================================================
|
|
543
|
+
// Video Output
|
|
544
|
+
//==========================================================================
|
|
545
|
+
|
|
546
|
+
getFramebuffer(): Uint8Array {
|
|
547
|
+
const fb = this.callbacks.framebuffer;
|
|
548
|
+
if (!fb) {
|
|
549
|
+
// Return empty framebuffer
|
|
550
|
+
const info = this.getSystemInfo();
|
|
551
|
+
return new Uint8Array(info.width * info.height * RGB24_BYTES_PER_PIXEL);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Check if we have a cached RGB24 framebuffer from bounds detection (same frame)
|
|
555
|
+
// This avoids expensive double conversion during periodic bounds checks
|
|
556
|
+
if (this.cachedRgb24Framebuffer && this.cachedRgb24FrameId === this.currentFrameId) {
|
|
557
|
+
if (!this.contentBounds) {
|
|
558
|
+
// No cropping needed - return cached buffer directly
|
|
559
|
+
return this.cachedRgb24Framebuffer;
|
|
560
|
+
}
|
|
561
|
+
// Cropping needed - extract the region from cached buffer
|
|
562
|
+
return this.cropRgb24Framebuffer(
|
|
563
|
+
this.cachedRgb24Framebuffer,
|
|
564
|
+
this.callbacks.frameWidth,
|
|
565
|
+
this.contentBounds
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// No cache available - convert from native format
|
|
570
|
+
// Apply cropping during conversion (more efficient than converting then cropping)
|
|
571
|
+
const bounds = this.contentBounds ? {
|
|
572
|
+
top: this.contentBounds.top,
|
|
573
|
+
left: this.contentBounds.left,
|
|
574
|
+
width: this.contentBounds.width,
|
|
575
|
+
height: this.contentBounds.height,
|
|
576
|
+
} : undefined;
|
|
577
|
+
|
|
578
|
+
return convertFramebuffer(
|
|
579
|
+
fb,
|
|
580
|
+
this.callbacks.frameWidth,
|
|
581
|
+
this.callbacks.frameHeight,
|
|
582
|
+
this.callbacks.framePitch,
|
|
583
|
+
this.cachedPixelFormat,
|
|
584
|
+
bounds
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Crop an RGB24 framebuffer to the specified bounds.
|
|
590
|
+
* Used to extract content region from cached full-frame buffer.
|
|
591
|
+
* Uses a reusable buffer to avoid per-frame allocations.
|
|
592
|
+
*/
|
|
593
|
+
private cropRgb24Framebuffer(
|
|
594
|
+
source: Uint8Array,
|
|
595
|
+
sourceWidth: number,
|
|
596
|
+
bounds: ContentBounds
|
|
597
|
+
): Uint8Array {
|
|
598
|
+
const { top, left, width, height } = bounds;
|
|
599
|
+
const outputSize = width * height * RGB24_BYTES_PER_PIXEL;
|
|
600
|
+
|
|
601
|
+
// Reuse buffer if possible, otherwise allocate
|
|
602
|
+
if (!this.cropOutputBuffer || this.cropOutputCapacity < outputSize) {
|
|
603
|
+
this.cropOutputCapacity = outputSize;
|
|
604
|
+
this.cropOutputBuffer = new Uint8Array(outputSize);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const output = this.cropOutputBuffer;
|
|
608
|
+
for (let y = 0; y < height; y++) {
|
|
609
|
+
const srcRow = (top + y) * sourceWidth + left;
|
|
610
|
+
const srcOffset = srcRow * RGB24_BYTES_PER_PIXEL;
|
|
611
|
+
const dstOffset = y * width * RGB24_BYTES_PER_PIXEL;
|
|
612
|
+
output.set(
|
|
613
|
+
source.subarray(srcOffset, srcOffset + width * RGB24_BYTES_PER_PIXEL),
|
|
614
|
+
dstOffset
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return output.subarray(0, outputSize);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Quick check if framebuffer appears uniform (all same color).
|
|
623
|
+
* Samples bytes across the buffer to detect blank frames without
|
|
624
|
+
* expensive RGB24 conversion. Used to skip bounds detection on blank frames.
|
|
625
|
+
*/
|
|
626
|
+
private isFramebufferUniform(fb: Uint8Array): boolean {
|
|
627
|
+
// Sample 32 positions across the buffer, comparing 4 bytes at each position
|
|
628
|
+
// (4 bytes covers all pixel formats: XRGB8888=4, RGB565=2, RGB555=2)
|
|
629
|
+
const SAMPLE_COUNT = 32;
|
|
630
|
+
const BYTES_PER_SAMPLE = 4;
|
|
631
|
+
const step = Math.max(1, Math.floor(fb.length / SAMPLE_COUNT));
|
|
632
|
+
|
|
633
|
+
// Compare against first pixel's bytes
|
|
634
|
+
const ref0 = fb[0];
|
|
635
|
+
const ref1 = fb[1];
|
|
636
|
+
const ref2 = fb[2];
|
|
637
|
+
const ref3 = fb[3];
|
|
638
|
+
|
|
639
|
+
for (let i = step; i < fb.length - BYTES_PER_SAMPLE; i += step) {
|
|
640
|
+
// Compare 4 bytes at this position against reference
|
|
641
|
+
const mismatch = fb[i] !== ref0 || fb[i + 1] !== ref1 ||
|
|
642
|
+
fb[i + 2] !== ref2 || fb[i + BYTES_PER_SAMPLE - 1] !== ref3;
|
|
643
|
+
if (mismatch) {
|
|
644
|
+
return false; // Found variation
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return true; // All samples matched - likely blank frame
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
//==========================================================================
|
|
652
|
+
// Audio Output
|
|
653
|
+
//==========================================================================
|
|
654
|
+
|
|
655
|
+
getAudioConfig(): AudioConfig {
|
|
656
|
+
return {
|
|
657
|
+
sampleRate: this.systemInfo.sampleRate,
|
|
658
|
+
channels: 2, // libretro is always stereo
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
setAudioCallback(callback: ((samples: Float32Array) => void) | null): void {
|
|
663
|
+
this.audioCallback = callback;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Set audio enable flag
|
|
668
|
+
* Tells the core whether to generate audio samples via GET_AUDIO_VIDEO_ENABLE
|
|
669
|
+
*/
|
|
670
|
+
setAudioEnabled(enabled: boolean): void {
|
|
671
|
+
this.envHandler.setAudioEnabled(enabled);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Set message callback for core notifications (e.g., "State saved", "Disk inserted")
|
|
676
|
+
*/
|
|
677
|
+
setMessageCallback(callback: CoreMessageCallback | null): void {
|
|
678
|
+
if (!callback) {
|
|
679
|
+
this.envHandler.setMessageCallback(null);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Adapter: convert libretro RetroMessageExt to CoreMessage
|
|
684
|
+
this.envHandler.setMessageCallback((retroMsg) => {
|
|
685
|
+
// Map libretro message type to CoreMessage type
|
|
686
|
+
let type: CoreMessage['type'] = 'notification';
|
|
687
|
+
if (retroMsg.type === RETRO_MESSAGE_TYPE.STATUS) {
|
|
688
|
+
type = 'status';
|
|
689
|
+
} else if (retroMsg.type === RETRO_MESSAGE_TYPE.PROGRESS) {
|
|
690
|
+
type = 'progress';
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Map libretro severity level to MessageSeverity
|
|
694
|
+
let severity: MessageSeverity = 'info';
|
|
695
|
+
switch (retroMsg.level) {
|
|
696
|
+
case RETRO_LOG.DEBUG: severity = 'debug'; break;
|
|
697
|
+
case RETRO_LOG.WARN: severity = 'warn'; break;
|
|
698
|
+
case RETRO_LOG.ERROR: severity = 'error'; break;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const coreMsg: CoreMessage = {
|
|
702
|
+
msg: retroMsg.msg,
|
|
703
|
+
duration: retroMsg.duration,
|
|
704
|
+
priority: retroMsg.priority,
|
|
705
|
+
type,
|
|
706
|
+
progress: retroMsg.progress,
|
|
707
|
+
severity,
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
callback(coreMsg);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
//==========================================================================
|
|
715
|
+
// Input
|
|
716
|
+
//==========================================================================
|
|
717
|
+
|
|
718
|
+
setButtonState(port: number, button: number, pressed: boolean): void {
|
|
719
|
+
this.callbacks.setButtonState(port, button, pressed);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
getButtonState(port: number): Map<number, boolean> {
|
|
723
|
+
return this.callbacks.getButtonState(port);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Set analog stick axis value.
|
|
728
|
+
* @param port - Controller port (0-based)
|
|
729
|
+
* @param index - Analog stick (0=left, 1=right from RETRO_DEVICE_INDEX_ANALOG)
|
|
730
|
+
* @param axis - Axis (0=X, 1=Y from RETRO_DEVICE_ID_ANALOG)
|
|
731
|
+
* @param value - Analog value from -32768 to 32767 (or -1.0 to 1.0 normalized)
|
|
732
|
+
*/
|
|
733
|
+
setAnalogState(port: number, index: number, axis: number, value: number): void {
|
|
734
|
+
// If value is in approximate normalized range, convert to int16
|
|
735
|
+
// We use a threshold > 1.0 to handle floating-point precision issues at boundaries
|
|
736
|
+
// (e.g., -32768/32767 = -1.00003 which is slightly outside -1 to 1)
|
|
737
|
+
const int16Value = Math.abs(value) <= ANALOG_NORMALIZED_THRESHOLD ? Math.round(value * INT16_MAX_POSITIVE) : value;
|
|
738
|
+
this.callbacks.setAnalogState(port, index, axis, int16Value);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Get all analog states for a port.
|
|
743
|
+
* Returns a map of "index.axis" -> value (e.g., "0.0" for left stick X)
|
|
744
|
+
*/
|
|
745
|
+
getAnalogStates(port: number): Map<string, number> {
|
|
746
|
+
return this.callbacks.getAnalogStates(port);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
//==========================================================================
|
|
750
|
+
// State Management
|
|
751
|
+
//==========================================================================
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Get raw binary state data (RetroArch-compatible format).
|
|
755
|
+
*/
|
|
756
|
+
getState(): Buffer | null {
|
|
757
|
+
if (!this.gameLoaded) {
|
|
758
|
+
throw new LibretroError('NO_GAME_LOADED');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const size = this.api.retro_serialize_size();
|
|
762
|
+
if (size === 0) {
|
|
763
|
+
// Core doesn't support save states
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const buffer = Buffer.alloc(size);
|
|
768
|
+
const success = this.api.retro_serialize(buffer, size);
|
|
769
|
+
|
|
770
|
+
return success ? buffer : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Restore state from raw binary data (RetroArch-compatible format).
|
|
775
|
+
*/
|
|
776
|
+
setState(state: Buffer): void {
|
|
777
|
+
if (!this.gameLoaded) {
|
|
778
|
+
throw new LibretroError('NO_GAME_LOADED');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const success = this.api.retro_unserialize(state, state.length);
|
|
782
|
+
|
|
783
|
+
if (!success) {
|
|
784
|
+
throw new LibretroError('STATE_LOAD_FAILED');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
//==========================================================================
|
|
789
|
+
// Battery/SRAM
|
|
790
|
+
//==========================================================================
|
|
791
|
+
|
|
792
|
+
hasBatterySave(): boolean {
|
|
793
|
+
if (!this.gameLoaded) {return false;}
|
|
794
|
+
|
|
795
|
+
// Check standard API first
|
|
796
|
+
const size = this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM);
|
|
797
|
+
if (size > 0) {return true;}
|
|
798
|
+
|
|
799
|
+
// Fall back to memory map SRAM (for cores like bsnes)
|
|
800
|
+
const memMapSram = this.envHandler.getMemoryMapSram();
|
|
801
|
+
return memMapSram !== null && memMapSram.size > 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
getBatteryRam(): Uint8Array | null {
|
|
805
|
+
if (!this.gameLoaded) {return null;}
|
|
806
|
+
|
|
807
|
+
// Try standard API first
|
|
808
|
+
const stdData = this.api.getMemoryData(RETRO_MEMORY.SAVE_RAM);
|
|
809
|
+
if (stdData) {return stdData;}
|
|
810
|
+
|
|
811
|
+
// Fall back to memory map SRAM (for cores like bsnes)
|
|
812
|
+
const memMapSram = this.envHandler.getMemoryMapSram();
|
|
813
|
+
if (!memMapSram || !memMapSram.ptr) {return null;}
|
|
814
|
+
|
|
815
|
+
// Read from memory map pointer using koffi.view
|
|
816
|
+
const arrayBuffer = koffi.view(memMapSram.ptr, memMapSram.size) as ArrayBuffer;
|
|
817
|
+
const view = new Uint8Array(arrayBuffer);
|
|
818
|
+
|
|
819
|
+
// Copy to a new buffer
|
|
820
|
+
const result = new Uint8Array(memMapSram.size);
|
|
821
|
+
result.set(view);
|
|
822
|
+
return result;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
setBatteryRam(data: Uint8Array): void {
|
|
826
|
+
if (!this.gameLoaded) {return;}
|
|
827
|
+
|
|
828
|
+
// Try standard API first
|
|
829
|
+
const size = this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM);
|
|
830
|
+
if (size > 0) {
|
|
831
|
+
this.api.setMemoryData(RETRO_MEMORY.SAVE_RAM, data);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Fall back to memory map SRAM (for cores like bsnes)
|
|
836
|
+
const memMapSram = this.envHandler.getMemoryMapSram();
|
|
837
|
+
if (!memMapSram || !memMapSram.ptr) {return;}
|
|
838
|
+
|
|
839
|
+
// Write to memory map pointer using koffi.view
|
|
840
|
+
const copySize = Math.min(data.length, memMapSram.size);
|
|
841
|
+
const arrayBuffer = koffi.view(memMapSram.ptr, copySize) as ArrayBuffer;
|
|
842
|
+
const target = new Uint8Array(arrayBuffer);
|
|
843
|
+
target.set(data.subarray(0, copySize));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
//==========================================================================
|
|
847
|
+
// Core Options
|
|
848
|
+
//==========================================================================
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Set a core option value at runtime.
|
|
852
|
+
* Uses RetroArch-compatible key format (e.g., "mupen64plus-rdp-plugin").
|
|
853
|
+
* Changes take effect on the next frame.
|
|
854
|
+
*/
|
|
855
|
+
setCoreOption(key: string, value: string): void {
|
|
856
|
+
this.envHandler.setCoreOption(key, value);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Set multiple core options at once.
|
|
861
|
+
* @param options Record of key-value pairs in RetroArch format
|
|
862
|
+
*/
|
|
863
|
+
setCoreOptions(options: Record<string, string>): void {
|
|
864
|
+
this.envHandler.setCoreOptions(options);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Get the current value of a core option.
|
|
869
|
+
* Returns the user-configured value, or the default if not set.
|
|
870
|
+
*/
|
|
871
|
+
getCoreOption(key: string): string | undefined {
|
|
872
|
+
return this.envHandler.getCoreOption(key);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Get all configured core options.
|
|
877
|
+
*/
|
|
878
|
+
getCoreOptions(): Map<string, string> {
|
|
879
|
+
return this.envHandler.getCoreOptions();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Get available core option definitions (reported by the core).
|
|
884
|
+
* Each definition includes the key, description, valid values, and default.
|
|
885
|
+
*/
|
|
886
|
+
getAvailableCoreOptions(): Array<{
|
|
887
|
+
key: string;
|
|
888
|
+
description: string;
|
|
889
|
+
values: string[];
|
|
890
|
+
defaultValue: string;
|
|
891
|
+
currentValue: string | undefined;
|
|
892
|
+
}> {
|
|
893
|
+
const defs = this.envHandler.getCoreOptionDefs();
|
|
894
|
+
return Array.from(defs.values()).map(def => ({
|
|
895
|
+
key: def.key,
|
|
896
|
+
description: def.description,
|
|
897
|
+
values: def.values,
|
|
898
|
+
defaultValue: def.defaultValue,
|
|
899
|
+
currentValue: this.envHandler.getCoreOption(def.key),
|
|
900
|
+
}));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Check if a core option exists.
|
|
905
|
+
*/
|
|
906
|
+
hasCoreOption(key: string): boolean {
|
|
907
|
+
return this.envHandler.hasCoreOption(key);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Clear all user-configured core options (revert to defaults).
|
|
912
|
+
*/
|
|
913
|
+
clearCoreOptions(): void {
|
|
914
|
+
this.envHandler.clearCoreOptions();
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export { LibretroAPI } from "./api";
|
|
919
|
+
export { EnvironmentHandler } from "./environment";
|
|
920
|
+
export { CallbackManager } from "./callbacks";
|
|
921
|
+
export * from "./types";
|
|
922
|
+
export * from "./consts";
|
|
923
|
+
export {
|
|
924
|
+
registerLibretroCore,
|
|
925
|
+
unloadLibretroCore,
|
|
926
|
+
isInUserCoresDirectory,
|
|
927
|
+
} from "./loader";
|
|
928
|
+
export {
|
|
929
|
+
loadCoreOptions,
|
|
930
|
+
saveCoreOptions,
|
|
931
|
+
saveCoreSpecificOptions,
|
|
932
|
+
getDefaultCoreOptionsPath,
|
|
933
|
+
getCoreSpecificOptionsPath,
|
|
934
|
+
getGameSpecificOptionsPath,
|
|
935
|
+
getDefaultCoreOptions,
|
|
936
|
+
DEFAULT_CORE_OPTIONS,
|
|
937
|
+
} from "./coreOptions";
|