retroemu 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/README.md +372 -0
- package/bin/cli.js +199 -0
- package/cores/atari800_libretro.js +2 -0
- package/cores/atari800_libretro.wasm +0 -0
- package/cores/beetle_pce_fast_libretro.js +2 -0
- package/cores/beetle_pce_fast_libretro.wasm +0 -0
- package/cores/fceumm_libretro.js +2 -0
- package/cores/fceumm_libretro.wasm +0 -0
- package/cores/fmsx_libretro.js +2 -0
- package/cores/fmsx_libretro.wasm +0 -0
- package/cores/fuse_libretro.js +2 -0
- package/cores/fuse_libretro.wasm +0 -0
- package/cores/gambatte_libretro.js +2 -0
- package/cores/gambatte_libretro.wasm +0 -0
- package/cores/gearcoleco_libretro.js +2 -0
- package/cores/gearcoleco_libretro.wasm +0 -0
- package/cores/genesis_plus_gx_libretro.js +2 -0
- package/cores/genesis_plus_gx_libretro.wasm +0 -0
- package/cores/handy_libretro.js +2 -0
- package/cores/handy_libretro.wasm +0 -0
- package/cores/mednafen_ngp_libretro.js +2 -0
- package/cores/mednafen_ngp_libretro.wasm +0 -0
- package/cores/mednafen_wswan_libretro.js +2 -0
- package/cores/mednafen_wswan_libretro.wasm +0 -0
- package/cores/mgba_libretro.js +2 -0
- package/cores/mgba_libretro.wasm +0 -0
- package/cores/pcsx_rearmed_libretro.js +2 -0
- package/cores/pcsx_rearmed_libretro.wasm +0 -0
- package/cores/prosystem_libretro.js +2 -0
- package/cores/prosystem_libretro.wasm +0 -0
- package/cores/snes9x2010_libretro.js +2 -0
- package/cores/snes9x2010_libretro.wasm +0 -0
- package/cores/snes9x_libretro.js +2 -0
- package/cores/snes9x_libretro.wasm +0 -0
- package/cores/stella2014_libretro.js +2 -0
- package/cores/stella2014_libretro.wasm +0 -0
- package/cores/vecx_libretro.js +2 -0
- package/cores/vecx_libretro.wasm +0 -0
- package/index.js +8 -0
- package/package.json +52 -0
- package/src/audio/AudioBridge.js +61 -0
- package/src/constants/libretro.js +97 -0
- package/src/core/CoreLoader.js +42 -0
- package/src/core/LibretroHost.js +542 -0
- package/src/core/RomLoader.js +96 -0
- package/src/core/SaveManager.js +115 -0
- package/src/core/SystemDetector.js +122 -0
- package/src/input/InputManager.js +222 -0
- package/src/input/InputMap.js +47 -0
- package/src/video/VideoOutput.js +207 -0
- package/src/video/videoWorker.js +164 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { loadCore } from './CoreLoader.js';
|
|
4
|
+
import { detectSystem } from './SystemDetector.js';
|
|
5
|
+
import {
|
|
6
|
+
RETRO_PIXEL_FORMAT_0RGB1555,
|
|
7
|
+
RETRO_PIXEL_FORMAT_XRGB8888,
|
|
8
|
+
RETRO_PIXEL_FORMAT_RGB565,
|
|
9
|
+
RETRO_ENVIRONMENT_GET_CAN_DUPE,
|
|
10
|
+
RETRO_ENVIRONMENT_SET_MESSAGE,
|
|
11
|
+
RETRO_ENVIRONMENT_SET_PIXEL_FORMAT,
|
|
12
|
+
RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY,
|
|
13
|
+
RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY,
|
|
14
|
+
RETRO_ENVIRONMENT_GET_LOG_INTERFACE,
|
|
15
|
+
RETRO_ENVIRONMENT_GET_VARIABLE,
|
|
16
|
+
RETRO_ENVIRONMENT_SET_VARIABLES,
|
|
17
|
+
RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE,
|
|
18
|
+
RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS,
|
|
19
|
+
RETRO_ENVIRONMENT_SET_CONTROLLER_INFO,
|
|
20
|
+
RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME,
|
|
21
|
+
RETRO_ENVIRONMENT_SET_MEMORY_MAPS,
|
|
22
|
+
RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO,
|
|
23
|
+
RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL,
|
|
24
|
+
RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE,
|
|
25
|
+
RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES,
|
|
26
|
+
RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION,
|
|
27
|
+
RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2,
|
|
28
|
+
RETRO_ENVIRONMENT_GET_INPUT_BITMASKS,
|
|
29
|
+
RETRO_ENVIRONMENT_GET_LANGUAGE,
|
|
30
|
+
RETRO_ENVIRONMENT_GET_USERNAME,
|
|
31
|
+
RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS,
|
|
32
|
+
RETRO_ENVIRONMENT_GET_VFS_INTERFACE,
|
|
33
|
+
RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE,
|
|
34
|
+
RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS,
|
|
35
|
+
RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK,
|
|
36
|
+
RETRO_ENVIRONMENT_SET_GEOMETRY,
|
|
37
|
+
RETRO_ENVIRONMENT_SET_ROTATION,
|
|
38
|
+
RETRO_ENVIRONMENT_GET_OVERSCAN,
|
|
39
|
+
RETRO_ENVIRONMENT_SHUTDOWN,
|
|
40
|
+
RETRO_DEVICE_JOYPAD,
|
|
41
|
+
} from '../constants/libretro.js';
|
|
42
|
+
|
|
43
|
+
export class LibretroHost {
|
|
44
|
+
constructor({ videoOutput, audioBridge, inputManager, saveManager }) {
|
|
45
|
+
this.videoOutput = videoOutput;
|
|
46
|
+
this.audioBridge = audioBridge;
|
|
47
|
+
this.inputManager = inputManager;
|
|
48
|
+
this.saveManager = saveManager;
|
|
49
|
+
this.core = null;
|
|
50
|
+
this.coreName = null;
|
|
51
|
+
this.pixelFormat = RETRO_PIXEL_FORMAT_0RGB1555;
|
|
52
|
+
this.systemAVInfo = null;
|
|
53
|
+
this.running = false;
|
|
54
|
+
this.romPath = null;
|
|
55
|
+
|
|
56
|
+
// Core variables (configuration)
|
|
57
|
+
this.coreVariables = new Map();
|
|
58
|
+
this.variablesUpdated = false;
|
|
59
|
+
|
|
60
|
+
// String pointers allocated in WASM memory (for environment callbacks)
|
|
61
|
+
this._allocatedStrings = [];
|
|
62
|
+
|
|
63
|
+
// Directories
|
|
64
|
+
this.systemDir = '';
|
|
65
|
+
this.saveDir = '';
|
|
66
|
+
|
|
67
|
+
// Frame counter
|
|
68
|
+
this._frameCount = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async loadAndStart(romPath, { systemDir, saveDir, romData } = {}) {
|
|
72
|
+
this.romPath = path.resolve(romPath);
|
|
73
|
+
this.systemDir = systemDir || path.dirname(this.romPath);
|
|
74
|
+
this.saveDir = saveDir || path.dirname(this.romPath);
|
|
75
|
+
|
|
76
|
+
// Ensure save dir exists
|
|
77
|
+
await fs.mkdir(this.saveDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
// Detect system from ROM extension
|
|
80
|
+
const system = detectSystem(this.romPath);
|
|
81
|
+
if (!system) {
|
|
82
|
+
throw new Error(`Unsupported ROM file: ${path.extname(this.romPath)}`);
|
|
83
|
+
}
|
|
84
|
+
this.coreName = system.core;
|
|
85
|
+
|
|
86
|
+
console.log(`System: ${system.systemName}`);
|
|
87
|
+
console.log(`Core: ${system.core}`);
|
|
88
|
+
console.log(`Loading...`);
|
|
89
|
+
|
|
90
|
+
// Load the WASM core
|
|
91
|
+
this.core = await loadCore(system.core);
|
|
92
|
+
|
|
93
|
+
// Register all libretro callbacks
|
|
94
|
+
this._registerCallbacks();
|
|
95
|
+
|
|
96
|
+
// Initialize the core
|
|
97
|
+
this.core._retro_init();
|
|
98
|
+
|
|
99
|
+
// Load the ROM (use provided data or read from file)
|
|
100
|
+
if (!romData) {
|
|
101
|
+
romData = await fs.readFile(this.romPath);
|
|
102
|
+
}
|
|
103
|
+
const loaded = this._loadGame(romData);
|
|
104
|
+
if (!loaded) {
|
|
105
|
+
throw new Error('Core failed to load ROM');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Read AV info (screen dimensions, FPS, audio sample rate)
|
|
109
|
+
this.systemAVInfo = this._getSystemAVInfo();
|
|
110
|
+
const { geometry, timing } = this.systemAVInfo;
|
|
111
|
+
console.log(
|
|
112
|
+
`Video: ${geometry.baseWidth}x${geometry.baseHeight} @ ${timing.fps.toFixed(2)}fps (aspect: ${geometry.aspectRatio.toFixed(3)})`
|
|
113
|
+
);
|
|
114
|
+
console.log(`Audio: ${timing.sampleRate}Hz`);
|
|
115
|
+
|
|
116
|
+
// Set display aspect ratio for correct rendering
|
|
117
|
+
this.videoOutput.setAspectRatio(geometry.aspectRatio);
|
|
118
|
+
|
|
119
|
+
// Initialize audio with the core's sample rate
|
|
120
|
+
await this.audioBridge.init(timing.sampleRate);
|
|
121
|
+
|
|
122
|
+
// Load SRAM if available
|
|
123
|
+
if (this.saveManager) {
|
|
124
|
+
// Debug: check SRAM availability and content after game load
|
|
125
|
+
const RETRO_MEMORY_SAVE_RAM = 0;
|
|
126
|
+
const sramPtr = this.core._retro_get_memory_data(RETRO_MEMORY_SAVE_RAM);
|
|
127
|
+
const sramSize = this.core._retro_get_memory_size(RETRO_MEMORY_SAVE_RAM);
|
|
128
|
+
|
|
129
|
+
// Log to file since terminal uses alternate buffer
|
|
130
|
+
const debugInfo = [];
|
|
131
|
+
debugInfo.push(`SRAM after load: ptr=${sramPtr}, size=${sramSize}`);
|
|
132
|
+
if (sramPtr && sramSize) {
|
|
133
|
+
const sample = [];
|
|
134
|
+
for (let i = 0; i < Math.min(32, sramSize); i++) {
|
|
135
|
+
sample.push(this.core.HEAPU8[sramPtr + i]);
|
|
136
|
+
}
|
|
137
|
+
debugInfo.push(`SRAM content before init: ${sample.map(b => b.toString(16).padStart(2, '0')).join(' ')}`);
|
|
138
|
+
|
|
139
|
+
// Check SRAM at various offsets to find where actual data might be
|
|
140
|
+
debugInfo.push(`Checking SRAM buffer at various offsets:`)
|
|
141
|
+
for (let offset = 0; offset < Math.min(sramSize, 0x8000); offset += 0x1000) {
|
|
142
|
+
const sample = [];
|
|
143
|
+
for (let i = 0; i < 16; i++) {
|
|
144
|
+
sample.push(this.core.HEAPU8[sramPtr + offset + i]);
|
|
145
|
+
}
|
|
146
|
+
debugInfo.push(` +0x${offset.toString(16)}: ${sample.map(b => b.toString(16).padStart(2, '0')).join(' ')}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await fs.appendFile('/tmp/sram-debug.log', debugInfo.join('\n') + '\n');
|
|
150
|
+
|
|
151
|
+
await this.saveManager.loadSRAM(this.core, this.romPath);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Set controller port
|
|
155
|
+
this.core._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
|
|
156
|
+
|
|
157
|
+
// Start the emulation loop
|
|
158
|
+
this.running = true;
|
|
159
|
+
this._runLoop();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
stop() {
|
|
163
|
+
this.running = false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async shutdown() {
|
|
167
|
+
this.stop();
|
|
168
|
+
|
|
169
|
+
// Save SRAM
|
|
170
|
+
if (this.saveManager && this.core) {
|
|
171
|
+
await this.saveManager.saveSRAM(this.core, this.romPath);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.core) {
|
|
175
|
+
this.core._retro_unload_game();
|
|
176
|
+
this.core._retro_deinit();
|
|
177
|
+
|
|
178
|
+
// Free allocated strings
|
|
179
|
+
for (const ptr of this._allocatedStrings) {
|
|
180
|
+
this.core._free(ptr);
|
|
181
|
+
}
|
|
182
|
+
this._allocatedStrings = [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.audioBridge.destroy();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async saveState(slot = 0) {
|
|
189
|
+
if (this.saveManager && this.core) {
|
|
190
|
+
await this.saveManager.saveState(this.core, this.romPath, slot);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async loadState(slot = 0) {
|
|
195
|
+
if (this.saveManager && this.core) {
|
|
196
|
+
await this.saveManager.loadState(this.core, this.romPath, slot);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
reset() {
|
|
201
|
+
if (this.core) {
|
|
202
|
+
this.core._retro_reset();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Private methods ---
|
|
207
|
+
|
|
208
|
+
_registerCallbacks() {
|
|
209
|
+
const mod = this.core;
|
|
210
|
+
|
|
211
|
+
// Environment callback: "iii" → (unsigned cmd, void* data) → bool
|
|
212
|
+
const envCb = mod.addFunction((cmd, dataPtr) => {
|
|
213
|
+
return this._handleEnvironment(cmd, dataPtr) ? 1 : 0;
|
|
214
|
+
}, 'iii');
|
|
215
|
+
mod._retro_set_environment(envCb);
|
|
216
|
+
|
|
217
|
+
// Video refresh: "viiii" → (const void* data, unsigned width, unsigned height, size_t pitch)
|
|
218
|
+
const videoCb = mod.addFunction((dataPtr, width, height, pitch) => {
|
|
219
|
+
if (dataPtr === 0) return; // NULL = duplicate frame
|
|
220
|
+
this.videoOutput.onFrame(mod, dataPtr, width, height, pitch, this.pixelFormat);
|
|
221
|
+
}, 'viiii');
|
|
222
|
+
mod._retro_set_video_refresh(videoCb);
|
|
223
|
+
|
|
224
|
+
// Audio sample batch: "iii" → (const int16_t* data, size_t frames) → size_t
|
|
225
|
+
const audioBatchCb = mod.addFunction((dataPtr, frames) => {
|
|
226
|
+
return this.audioBridge.onAudioBatch(mod, dataPtr, frames);
|
|
227
|
+
}, 'iii');
|
|
228
|
+
mod._retro_set_audio_sample_batch(audioBatchCb);
|
|
229
|
+
|
|
230
|
+
// Audio single sample: "vii" → (int16_t left, int16_t right)
|
|
231
|
+
const audioSampleCb = mod.addFunction((left, right) => {
|
|
232
|
+
this.audioBridge.onAudioSample(left, right);
|
|
233
|
+
}, 'vii');
|
|
234
|
+
mod._retro_set_audio_sample(audioSampleCb);
|
|
235
|
+
|
|
236
|
+
// Input poll: "v" → ()
|
|
237
|
+
const inputPollCb = mod.addFunction(() => {
|
|
238
|
+
this.inputManager.poll();
|
|
239
|
+
}, 'v');
|
|
240
|
+
mod._retro_set_input_poll(inputPollCb);
|
|
241
|
+
|
|
242
|
+
// Input state: "iiiii" → (unsigned port, unsigned device, unsigned index, unsigned id) → int16_t
|
|
243
|
+
const inputStateCb = mod.addFunction((port, device, index, id) => {
|
|
244
|
+
return this.inputManager.getState(port, device, index, id);
|
|
245
|
+
}, 'iiiii');
|
|
246
|
+
mod._retro_set_input_state(inputStateCb);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_handleEnvironment(cmd, dataPtr) {
|
|
250
|
+
const mod = this.core;
|
|
251
|
+
|
|
252
|
+
// Mask out RETRO_ENVIRONMENT_EXPERIMENTAL flag (0x10000)
|
|
253
|
+
const RETRO_ENVIRONMENT_EXPERIMENTAL = 0x10000;
|
|
254
|
+
const baseCmd = cmd & ~RETRO_ENVIRONMENT_EXPERIMENTAL;
|
|
255
|
+
|
|
256
|
+
switch (baseCmd) {
|
|
257
|
+
case RETRO_ENVIRONMENT_GET_CAN_DUPE:
|
|
258
|
+
// We support frame duplication (NULL video frame)
|
|
259
|
+
mod.setValue(dataPtr, 1, 'i8');
|
|
260
|
+
return true;
|
|
261
|
+
|
|
262
|
+
case RETRO_ENVIRONMENT_SET_MESSAGE:
|
|
263
|
+
// Core wants to display a message - accept but we can't display it in terminal easily
|
|
264
|
+
return true;
|
|
265
|
+
|
|
266
|
+
case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: {
|
|
267
|
+
const format = mod.getValue(dataPtr, 'i32');
|
|
268
|
+
if (
|
|
269
|
+
format === RETRO_PIXEL_FORMAT_0RGB1555 ||
|
|
270
|
+
format === RETRO_PIXEL_FORMAT_XRGB8888 ||
|
|
271
|
+
format === RETRO_PIXEL_FORMAT_RGB565
|
|
272
|
+
) {
|
|
273
|
+
this.pixelFormat = format;
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: {
|
|
280
|
+
const strPtr = this._allocString(this.systemDir);
|
|
281
|
+
mod.setValue(dataPtr, strPtr, 'i32');
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: {
|
|
286
|
+
const strPtr = this._allocString(this.saveDir);
|
|
287
|
+
mod.setValue(dataPtr, strPtr, 'i32');
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case RETRO_ENVIRONMENT_GET_LOG_INTERFACE:
|
|
292
|
+
// Log callback is variadic (like printf) which can't be properly handled
|
|
293
|
+
// with addFunction's fixed signatures. Return false - cores handle this
|
|
294
|
+
// gracefully by not logging.
|
|
295
|
+
return false;
|
|
296
|
+
|
|
297
|
+
case RETRO_ENVIRONMENT_SET_VARIABLES: {
|
|
298
|
+
// Core declares its configuration variables
|
|
299
|
+
// struct retro_variable { const char *key; const char *value; }
|
|
300
|
+
// Array terminated by {NULL, NULL}
|
|
301
|
+
let ptr = dataPtr;
|
|
302
|
+
while (true) {
|
|
303
|
+
const keyPtr = mod.getValue(ptr, 'i32');
|
|
304
|
+
const valPtr = mod.getValue(ptr + 4, 'i32');
|
|
305
|
+
if (keyPtr === 0) break;
|
|
306
|
+
const key = mod.UTF8ToString(keyPtr);
|
|
307
|
+
const desc = mod.UTF8ToString(valPtr);
|
|
308
|
+
// Parse "Description; option1|option2|option3" format
|
|
309
|
+
const semiIdx = desc.indexOf('; ');
|
|
310
|
+
if (semiIdx >= 0) {
|
|
311
|
+
const options = desc.substring(semiIdx + 2).split('|');
|
|
312
|
+
this.coreVariables.set(key, {
|
|
313
|
+
description: desc.substring(0, semiIdx),
|
|
314
|
+
options,
|
|
315
|
+
value: options[0], // default to first option
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
ptr += 8;
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case RETRO_ENVIRONMENT_GET_VARIABLE: {
|
|
324
|
+
// struct retro_variable { const char *key; const char *value; }
|
|
325
|
+
const keyPtr = mod.getValue(dataPtr, 'i32');
|
|
326
|
+
if (keyPtr === 0) return false;
|
|
327
|
+
const key = mod.UTF8ToString(keyPtr);
|
|
328
|
+
const variable = this.coreVariables.get(key);
|
|
329
|
+
if (!variable) {
|
|
330
|
+
mod.setValue(dataPtr + 4, 0, 'i32');
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
const valuePtr = this._allocString(variable.value);
|
|
334
|
+
mod.setValue(dataPtr + 4, valuePtr, 'i32');
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE:
|
|
339
|
+
mod.setValue(dataPtr, this.variablesUpdated ? 1 : 0, 'i8');
|
|
340
|
+
this.variablesUpdated = false;
|
|
341
|
+
return true;
|
|
342
|
+
|
|
343
|
+
case RETRO_ENVIRONMENT_GET_OVERSCAN:
|
|
344
|
+
// No overscan cropping
|
|
345
|
+
mod.setValue(dataPtr, 0, 'i8');
|
|
346
|
+
return true;
|
|
347
|
+
|
|
348
|
+
case RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS:
|
|
349
|
+
case RETRO_ENVIRONMENT_SET_CONTROLLER_INFO:
|
|
350
|
+
case RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO:
|
|
351
|
+
case RETRO_ENVIRONMENT_SET_MEMORY_MAPS:
|
|
352
|
+
case RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME:
|
|
353
|
+
case RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL:
|
|
354
|
+
case RETRO_ENVIRONMENT_SET_GEOMETRY:
|
|
355
|
+
case RETRO_ENVIRONMENT_SET_ROTATION:
|
|
356
|
+
case RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS:
|
|
357
|
+
// Accept but ignore these
|
|
358
|
+
return true;
|
|
359
|
+
|
|
360
|
+
case RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE:
|
|
361
|
+
// TODO: wire up rumble via gamepad-node
|
|
362
|
+
return false;
|
|
363
|
+
|
|
364
|
+
case RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES: {
|
|
365
|
+
// Report joypad support: bit 1 = RETRO_DEVICE_JOYPAD
|
|
366
|
+
mod.setValue(dataPtr, (1 << RETRO_DEVICE_JOYPAD), 'i32');
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case RETRO_ENVIRONMENT_GET_INPUT_BITMASKS:
|
|
371
|
+
return true;
|
|
372
|
+
|
|
373
|
+
case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: {
|
|
374
|
+
// Bit 0: Enable audio, Bit 1: Enable video, Bit 2: Fast savestates
|
|
375
|
+
// Return all enabled (0b111 = 7)
|
|
376
|
+
mod.setValue(dataPtr, 7, 'i32');
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case RETRO_ENVIRONMENT_GET_VFS_INTERFACE:
|
|
381
|
+
// We don't support VFS - core should fall back to standard file I/O
|
|
382
|
+
return false;
|
|
383
|
+
|
|
384
|
+
case RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS:
|
|
385
|
+
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK:
|
|
386
|
+
// Accept but ignore these
|
|
387
|
+
return true;
|
|
388
|
+
|
|
389
|
+
case RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION:
|
|
390
|
+
// We support version 0 (basic variables) only
|
|
391
|
+
mod.setValue(dataPtr, 0, 'i32');
|
|
392
|
+
return true;
|
|
393
|
+
|
|
394
|
+
case RETRO_ENVIRONMENT_GET_LANGUAGE:
|
|
395
|
+
// RETRO_LANGUAGE_ENGLISH = 0
|
|
396
|
+
mod.setValue(dataPtr, 0, 'i32');
|
|
397
|
+
return true;
|
|
398
|
+
|
|
399
|
+
case RETRO_ENVIRONMENT_GET_USERNAME: {
|
|
400
|
+
const strPtr = this._allocString('player');
|
|
401
|
+
mod.setValue(dataPtr, strPtr, 'i32');
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
case RETRO_ENVIRONMENT_SHUTDOWN:
|
|
406
|
+
this.stop();
|
|
407
|
+
return true;
|
|
408
|
+
|
|
409
|
+
default:
|
|
410
|
+
// Log unhandled environment calls to a file for debugging (only unique ones)
|
|
411
|
+
if (!this._loggedEnvCalls) this._loggedEnvCalls = new Set();
|
|
412
|
+
if (!this._loggedEnvCalls.has(baseCmd)) {
|
|
413
|
+
this._loggedEnvCalls.add(baseCmd);
|
|
414
|
+
fs.appendFile('/tmp/emu-env.log', `Unhandled env call: ${baseCmd} (raw: ${cmd})\n`).catch(() => {});
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
_loadGame(romData) {
|
|
421
|
+
const mod = this.core;
|
|
422
|
+
|
|
423
|
+
// Write ROM to Emscripten virtual filesystem for cores that need full path access
|
|
424
|
+
const vfsPath = '/rom' + path.extname(this.romPath);
|
|
425
|
+
if (mod.FS) {
|
|
426
|
+
mod.FS.writeFile(vfsPath, romData);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Allocate ROM data in WASM heap
|
|
430
|
+
const romPtr = mod._malloc(romData.length);
|
|
431
|
+
mod.HEAPU8.set(romData, romPtr);
|
|
432
|
+
|
|
433
|
+
// Use virtual filesystem path for cores that need it
|
|
434
|
+
const gamePath = mod.FS ? vfsPath : this.romPath;
|
|
435
|
+
const pathPtr = this._allocString(gamePath);
|
|
436
|
+
|
|
437
|
+
// Build retro_game_info struct:
|
|
438
|
+
// { const char *path (i32), const void *data (i32), size_t size (i32), const char *meta (i32) }
|
|
439
|
+
// Total: 16 bytes
|
|
440
|
+
const gameInfoPtr = mod._malloc(16);
|
|
441
|
+
mod.setValue(gameInfoPtr, pathPtr, 'i32'); // path
|
|
442
|
+
mod.setValue(gameInfoPtr + 4, romPtr, 'i32'); // data
|
|
443
|
+
mod.setValue(gameInfoPtr + 8, romData.length, 'i32'); // size
|
|
444
|
+
mod.setValue(gameInfoPtr + 12, 0, 'i32'); // meta (NULL)
|
|
445
|
+
|
|
446
|
+
const result = mod._retro_load_game(gameInfoPtr);
|
|
447
|
+
|
|
448
|
+
// Free the game_info struct (ROM data stays allocated — core references it)
|
|
449
|
+
mod._free(gameInfoPtr);
|
|
450
|
+
|
|
451
|
+
return result !== 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_getSystemAVInfo() {
|
|
455
|
+
const mod = this.core;
|
|
456
|
+
|
|
457
|
+
// struct retro_system_av_info {
|
|
458
|
+
// struct retro_game_geometry {
|
|
459
|
+
// unsigned base_width; // +0
|
|
460
|
+
// unsigned base_height; // +4
|
|
461
|
+
// unsigned max_width; // +8
|
|
462
|
+
// unsigned max_height; // +12
|
|
463
|
+
// float aspect_ratio; // +16
|
|
464
|
+
// }; // 20 bytes
|
|
465
|
+
// struct retro_system_timing {
|
|
466
|
+
// double fps; // +20 (8 bytes, aligned)
|
|
467
|
+
// double sample_rate; // +28 (8 bytes)
|
|
468
|
+
// };
|
|
469
|
+
// };
|
|
470
|
+
// Total: 36 bytes (but alignment may add padding)
|
|
471
|
+
// With alignment: geometry is 20 bytes, but timing starts at offset 24 (8-byte aligned for double)
|
|
472
|
+
|
|
473
|
+
const avInfoPtr = mod._malloc(48); // extra space for alignment
|
|
474
|
+
mod._retro_get_system_av_info(avInfoPtr);
|
|
475
|
+
|
|
476
|
+
const baseWidth = mod.getValue(avInfoPtr, 'i32');
|
|
477
|
+
const baseHeight = mod.getValue(avInfoPtr + 4, 'i32');
|
|
478
|
+
const maxWidth = mod.getValue(avInfoPtr + 8, 'i32');
|
|
479
|
+
const maxHeight = mod.getValue(avInfoPtr + 12, 'i32');
|
|
480
|
+
const aspectRatio = mod.getValue(avInfoPtr + 16, 'float');
|
|
481
|
+
|
|
482
|
+
// Timing struct is 8-byte aligned after geometry
|
|
483
|
+
// geometry = 20 bytes → next 8-byte boundary = 24
|
|
484
|
+
const timingOffset = 24;
|
|
485
|
+
const fps = mod.getValue(avInfoPtr + timingOffset, 'double');
|
|
486
|
+
const sampleRate = mod.getValue(avInfoPtr + timingOffset + 8, 'double');
|
|
487
|
+
|
|
488
|
+
mod._free(avInfoPtr);
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
geometry: {
|
|
492
|
+
baseWidth,
|
|
493
|
+
baseHeight,
|
|
494
|
+
maxWidth,
|
|
495
|
+
maxHeight,
|
|
496
|
+
aspectRatio: aspectRatio > 0 ? aspectRatio : 4 / 3, // Default to 4:3 for retro consoles
|
|
497
|
+
},
|
|
498
|
+
timing: {
|
|
499
|
+
fps,
|
|
500
|
+
sampleRate,
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
_runLoop() {
|
|
506
|
+
const fps = this.systemAVInfo.timing.fps;
|
|
507
|
+
const frameDurationMs = 1000 / fps;
|
|
508
|
+
let lastFrameTime = performance.now();
|
|
509
|
+
|
|
510
|
+
const tick = () => {
|
|
511
|
+
if (!this.running) return;
|
|
512
|
+
|
|
513
|
+
const now = performance.now();
|
|
514
|
+
const elapsed = now - lastFrameTime;
|
|
515
|
+
|
|
516
|
+
if (elapsed >= frameDurationMs) {
|
|
517
|
+
lastFrameTime = now - (elapsed % frameDurationMs);
|
|
518
|
+
this.core._retro_run();
|
|
519
|
+
this._frameCount++;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Frame pacing: use setTimeout for coarse timing, setImmediate for tight timing
|
|
523
|
+
const remaining = frameDurationMs - (performance.now() - lastFrameTime);
|
|
524
|
+
if (remaining > 2) {
|
|
525
|
+
setTimeout(tick, Math.floor(remaining) - 1);
|
|
526
|
+
} else {
|
|
527
|
+
setImmediate(tick);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
tick();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
_allocString(str) {
|
|
535
|
+
const mod = this.core;
|
|
536
|
+
const len = mod.lengthBytesUTF8(str) + 1;
|
|
537
|
+
const ptr = mod._malloc(len);
|
|
538
|
+
mod.stringToUTF8(str, ptr, len);
|
|
539
|
+
this._allocatedStrings.push(ptr);
|
|
540
|
+
return ptr;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yauzl from 'yauzl';
|
|
4
|
+
import { getSupportedExtensions } from './SystemDetector.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load a ROM file, extracting from ZIP if necessary.
|
|
8
|
+
* Returns { data: Buffer, romPath: string, originalPath: string }
|
|
9
|
+
* - data: the ROM file contents
|
|
10
|
+
* - romPath: the effective ROM path (for extension detection and save naming)
|
|
11
|
+
* - originalPath: the original input path
|
|
12
|
+
*/
|
|
13
|
+
export async function loadRom(inputPath) {
|
|
14
|
+
const ext = path.extname(inputPath).toLowerCase();
|
|
15
|
+
|
|
16
|
+
if (ext === '.zip') {
|
|
17
|
+
return extractRomFromZip(inputPath);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Regular file - read directly
|
|
21
|
+
const data = await fs.readFile(inputPath);
|
|
22
|
+
return {
|
|
23
|
+
data,
|
|
24
|
+
romPath: inputPath,
|
|
25
|
+
originalPath: inputPath,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract the first ROM file from a ZIP archive.
|
|
31
|
+
*/
|
|
32
|
+
async function extractRomFromZip(zipPath) {
|
|
33
|
+
const supportedExtensions = new Set(getSupportedExtensions());
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
|
37
|
+
if (err) {
|
|
38
|
+
reject(new Error(`Failed to open ZIP: ${err.message}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let foundRom = null;
|
|
43
|
+
|
|
44
|
+
zipfile.on('error', reject);
|
|
45
|
+
|
|
46
|
+
zipfile.on('entry', (entry) => {
|
|
47
|
+
const entryExt = path.extname(entry.fileName).toLowerCase();
|
|
48
|
+
|
|
49
|
+
// Skip directories and non-ROM files
|
|
50
|
+
if (entry.fileName.endsWith('/') || !supportedExtensions.has(entryExt)) {
|
|
51
|
+
zipfile.readEntry();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Found a ROM - extract it
|
|
56
|
+
zipfile.openReadStream(entry, (err, readStream) => {
|
|
57
|
+
if (err) {
|
|
58
|
+
reject(err);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const chunks = [];
|
|
63
|
+
readStream.on('data', (chunk) => chunks.push(chunk));
|
|
64
|
+
readStream.on('end', () => {
|
|
65
|
+
foundRom = {
|
|
66
|
+
data: Buffer.concat(chunks),
|
|
67
|
+
// Use the filename inside the ZIP for extension detection
|
|
68
|
+
romPath: path.join(path.dirname(zipPath), entry.fileName),
|
|
69
|
+
originalPath: zipPath,
|
|
70
|
+
zipEntry: entry.fileName,
|
|
71
|
+
};
|
|
72
|
+
zipfile.close();
|
|
73
|
+
});
|
|
74
|
+
readStream.on('error', reject);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
zipfile.on('close', () => {
|
|
79
|
+
if (foundRom) {
|
|
80
|
+
resolve(foundRom);
|
|
81
|
+
} else {
|
|
82
|
+
reject(new Error(`No supported ROM file found in ZIP. Supported: ${[...supportedExtensions].join(', ')}`));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
zipfile.readEntry();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if a file path points to a ZIP archive.
|
|
93
|
+
*/
|
|
94
|
+
export function isZipFile(filePath) {
|
|
95
|
+
return path.extname(filePath).toLowerCase() === '.zip';
|
|
96
|
+
}
|