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.
Files changed (51) hide show
  1. package/README.md +372 -0
  2. package/bin/cli.js +199 -0
  3. package/cores/atari800_libretro.js +2 -0
  4. package/cores/atari800_libretro.wasm +0 -0
  5. package/cores/beetle_pce_fast_libretro.js +2 -0
  6. package/cores/beetle_pce_fast_libretro.wasm +0 -0
  7. package/cores/fceumm_libretro.js +2 -0
  8. package/cores/fceumm_libretro.wasm +0 -0
  9. package/cores/fmsx_libretro.js +2 -0
  10. package/cores/fmsx_libretro.wasm +0 -0
  11. package/cores/fuse_libretro.js +2 -0
  12. package/cores/fuse_libretro.wasm +0 -0
  13. package/cores/gambatte_libretro.js +2 -0
  14. package/cores/gambatte_libretro.wasm +0 -0
  15. package/cores/gearcoleco_libretro.js +2 -0
  16. package/cores/gearcoleco_libretro.wasm +0 -0
  17. package/cores/genesis_plus_gx_libretro.js +2 -0
  18. package/cores/genesis_plus_gx_libretro.wasm +0 -0
  19. package/cores/handy_libretro.js +2 -0
  20. package/cores/handy_libretro.wasm +0 -0
  21. package/cores/mednafen_ngp_libretro.js +2 -0
  22. package/cores/mednafen_ngp_libretro.wasm +0 -0
  23. package/cores/mednafen_wswan_libretro.js +2 -0
  24. package/cores/mednafen_wswan_libretro.wasm +0 -0
  25. package/cores/mgba_libretro.js +2 -0
  26. package/cores/mgba_libretro.wasm +0 -0
  27. package/cores/pcsx_rearmed_libretro.js +2 -0
  28. package/cores/pcsx_rearmed_libretro.wasm +0 -0
  29. package/cores/prosystem_libretro.js +2 -0
  30. package/cores/prosystem_libretro.wasm +0 -0
  31. package/cores/snes9x2010_libretro.js +2 -0
  32. package/cores/snes9x2010_libretro.wasm +0 -0
  33. package/cores/snes9x_libretro.js +2 -0
  34. package/cores/snes9x_libretro.wasm +0 -0
  35. package/cores/stella2014_libretro.js +2 -0
  36. package/cores/stella2014_libretro.wasm +0 -0
  37. package/cores/vecx_libretro.js +2 -0
  38. package/cores/vecx_libretro.wasm +0 -0
  39. package/index.js +8 -0
  40. package/package.json +52 -0
  41. package/src/audio/AudioBridge.js +61 -0
  42. package/src/constants/libretro.js +97 -0
  43. package/src/core/CoreLoader.js +42 -0
  44. package/src/core/LibretroHost.js +542 -0
  45. package/src/core/RomLoader.js +96 -0
  46. package/src/core/SaveManager.js +115 -0
  47. package/src/core/SystemDetector.js +122 -0
  48. package/src/input/InputManager.js +222 -0
  49. package/src/input/InputMap.js +47 -0
  50. package/src/video/VideoOutput.js +207 -0
  51. 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
+ }