romdevtools 0.56.1 → 0.71.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 (49) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/README.md +9 -7
  3. package/examples/dreamcast/hello/main.c +24 -0
  4. package/examples/dreamcast/platformer/main.c +31 -0
  5. package/examples/dreamcast/puzzle/main.c +44 -0
  6. package/examples/dreamcast/racing/main.c +39 -0
  7. package/examples/dreamcast/shmup/main.c +50 -0
  8. package/examples/dreamcast/sports/main.c +39 -0
  9. package/package.json +5 -3
  10. package/src/analysis/analyze.js +60 -5
  11. package/src/analysis/decompile.js +3 -0
  12. package/src/analysis/rizin.js +3 -1
  13. package/src/cores/capabilities.js +43 -7
  14. package/src/cores/registry.js +13 -8
  15. package/src/host/LibretroGL.js +26 -23
  16. package/src/host/LibretroHost.js +302 -24
  17. package/src/host/callbacks.js +72 -1
  18. package/src/host/coreLoader.js +17 -4
  19. package/src/host/cpu-state.js +32 -0
  20. package/src/host/dc-aica-state.js +67 -0
  21. package/src/mcp/tools/audio.js +1 -1
  22. package/src/mcp/tools/disasm.js +1 -1
  23. package/src/mcp/tools/index.js +1 -1
  24. package/src/mcp/tools/platform-docs.js +1 -1
  25. package/src/mcp/tools/platform-tools.js +9 -1
  26. package/src/mcp/tools/project.js +16 -0
  27. package/src/mcp/tools/toolchain.js +115 -10
  28. package/src/platforms/dreamcast/MENTAL_MODEL.md +87 -0
  29. package/src/platforms/dreamcast/TROUBLESHOOTING.md +55 -0
  30. package/src/platforms/dreamcast/UPSTREAM_SOURCES.md +57 -0
  31. package/src/platforms/n64/MENTAL_MODEL.md +84 -0
  32. package/src/platforms/n64/TROUBLESHOOTING.md +60 -0
  33. package/src/platforms/n64/UPSTREAM_SOURCES.md +52 -0
  34. package/src/platforms/n64/lib/c/n64.c +181 -80
  35. package/src/platforms/ps1/MENTAL_MODEL.md +85 -0
  36. package/src/platforms/ps1/TROUBLESHOOTING.md +55 -0
  37. package/src/platforms/ps1/UPSTREAM_SOURCES.md +54 -0
  38. package/src/platforms/snes/TROUBLESHOOTING.md +10 -0
  39. package/src/toolchains/asar/asar.js +84 -14
  40. package/src/toolchains/index.js +30 -0
  41. package/src/toolchains/mips-c/mips-c.js +35 -1
  42. package/src/toolchains/sh-c/lib/dc-crt0.s +23 -0
  43. package/src/toolchains/sh-c/lib/dc.h +152 -0
  44. package/src/toolchains/sh-c/lib/dc.ld +11 -0
  45. package/src/toolchains/sh-c/lib/libc.a +0 -0
  46. package/src/toolchains/sh-c/lib/libgcc.a +0 -0
  47. package/src/toolchains/sh-c/lib/libm.a +0 -0
  48. package/src/toolchains/sh-c/sh-c.js +104 -0
  49. package/src/toolchains/sh-elf-gcc/gcc.js +122 -0
@@ -36,6 +36,17 @@ import {
36
36
  * to function headlessly (no menu = no user pick). Empty today — all
37
37
  * shipped cores load with their defaults.
38
38
  */
39
+ // Core filename stem → platform, for the cores whose renderer (or other boot-time
40
+ // behavior) is chosen from an option at retro_init. loadCore uses this to pre-seed
41
+ // PLATFORM_CORE_OPTIONS before the core registers its variables, so the override
42
+ // wins over the core's default (e.g. glide64-GL over angrylion-software for N64).
43
+ const CORE_STEM_TO_PLATFORM = {
44
+ parallel_n64: "n64",
45
+ pcsx_rearmed: "ps1",
46
+ flycast: "dreamcast",
47
+ beetle_psx_hw: "ps1",
48
+ };
49
+
39
50
  const PLATFORM_CORE_OPTIONS = {
40
51
  // blueMSX defaults its machine to "SEGA - SC-3000" (an SG-1000 clone, wrong for
41
52
  // MSX carts). Force the open MSX2+ C-BIOS machine — a superset that also runs
@@ -55,6 +66,24 @@ const PLATFORM_CORE_OPTIONS = {
55
66
  // beetle_psx_hw defaults to the software renderer; force hardware_gl so it
56
67
  // renders through the GL context the host set up (otherwise no FBO frames).
57
68
  ps1: { beetle_psx_hw_renderer: "hardware_gl" },
69
+ // Flycast defaults to THREADED rendering — the render thread + the main thread's
70
+ // wait on it makes retro_run unwind under emscripten pthreads (yields to the event
71
+ // loop, incompatible with our synchronous frame-step). Disable it so render() runs
72
+ // synchronously on the calling thread (Emulator::render → run(), no cross-thread
73
+ // wait). Also keep per-frame sync (no auto frame-skip) so one retro_run = one frame.
74
+ // hle_bios (reios) MUST be on: it's flycast's HLE BIOS, and only the reios boot
75
+ // path loads a raw homebrew .elf (reios_loadElf copies PT_LOAD segments to their
76
+ // vaddr + jumps). With it off (the default), flycast wants a real dc_boot.bin we
77
+ // don't ship → the .elf never loads (RAM stays empty, CPU never runs our code).
78
+ // emulate_framebuffer scans out the DC framebuffer directly on every VBlank (the 2D
79
+ // path, no TA list) — so simple homebrew that writes RGB565 pixels to VRAM presents
80
+ // reliably without building a full PowerVR2 tile list. (Threaded rendering is also
81
+ // force-disabled in the core itself; the option here is belt-and-suspenders.)
82
+ dreamcast: {
83
+ flycast_threaded_rendering: "disabled",
84
+ flycast_hle_bios: "enabled",
85
+ flycast_emulate_framebuffer: "enabled",
86
+ },
58
87
  // VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
59
88
  // `READY.` prompt — the agent would see a blue boot screen, not the game. Force
60
89
  // autostart so a disk/tape image runs the first program automatically (same as
@@ -135,6 +164,29 @@ function mirrorDirToFS(FS, hostDir, fsDir) {
135
164
  }
136
165
  }
137
166
 
167
+ /** Mirror a host dir into a PROXIED core's APP-THREAD MEMFS (a per-thread JS heap, invisible to
168
+ * the main-thread FS). Each file's bytes go through shared WASM memory; romdev_app_fs_write does
169
+ * the FS.writeFile on the app thread, where the core's fopen runs. */
170
+ function mirrorDirToAppFS(mod, hostDir, fsDir) {
171
+ for (const name of readdirSync(hostDir)) {
172
+ const hostPath = path.join(hostDir, name);
173
+ const fsPath = fsDir + "/" + name;
174
+ const st = statSync(hostPath);
175
+ if (st.isDirectory()) {
176
+ mirrorDirToAppFS(mod, hostPath, fsPath);
177
+ } else if (st.isFile()) {
178
+ const bytes = readFileSync(hostPath);
179
+ const dataPtr = mod._malloc(bytes.length || 1);
180
+ mod.HEAPU8.set(bytes, dataPtr);
181
+ const pb = Buffer.from(fsPath + "\0", "utf-8");
182
+ const pathPtr = mod._malloc(pb.length);
183
+ mod.HEAPU8.set(pb, pathPtr);
184
+ try { mod._romdev_app_fs_write(pathPtr, dataPtr, bytes.length); } catch { /* skip */ }
185
+ mod._free(dataPtr); mod._free(pathPtr);
186
+ }
187
+ }
188
+ }
189
+
138
190
  /**
139
191
  * When loadMedia is called with `bytes:` and no `virtualName`, this is
140
192
  * the extension we tack onto "/rom" so the core knows which platform
@@ -252,11 +304,29 @@ export class LibretroHost {
252
304
  * optional native GL stack and creates a headless context BEFORE the core boots
253
305
  * (GL calls happen during init/load). The 14 software cores omit this.
254
306
  */
307
+ /** Infer the platform from a core's js filename stem (e.g.
308
+ * ".../parallel_n64_libretro.js" → "n64"), so loadCore can pre-seed that
309
+ * platform's option overrides without the caller threading the platform
310
+ * through. Covers the cores whose boot-time renderer choice depends on an
311
+ * option; returns null otherwise (the overrides are then a no-op anyway). */
312
+ _platformForCore(jsPath) {
313
+ const stem = (jsPath.split("/").pop() || "").replace(/_libretro\.js$/, "");
314
+ return CORE_STEM_TO_PLATFORM[stem] ?? null;
315
+ }
316
+
255
317
  async loadCore(jsPath, wasmPath, opts = {}) {
256
318
  if (this.mod) throw new Error("core already loaded; create a new host");
257
319
 
320
+ // Proxied (multi-threaded) cores — e.g. PPSSPP/PSP — run on a dedicated "app thread" so the
321
+ // JS main thread never blocks while the core's worker threads proxy back to it (which would
322
+ // deadlock the synchronous frame-stepping). They own the ENTIRE GL stack on the app thread
323
+ // (native-gles + webgl-node loaded + context created + readback all there). The registry
324
+ // marks them; the single-threaded GL cores keep the main-thread GL path below.
325
+ this._proxied = !!opts.proxied;
326
+ this.state.proxied = this._proxied;
327
+
258
328
  let glCanvas = null;
259
- if (opts.hwRender) {
329
+ if (opts.hwRender && !this._proxied) {
260
330
  // Set up the HW-render path: LibretroGL owns the FBO/readback; a webgl-node
261
331
  // mock canvas backs the minified core's GLctx. Both pull the OPTIONAL native
262
332
  // GL deps lazily (clear install hint if absent).
@@ -278,16 +348,83 @@ export class LibretroHost {
278
348
  const mod = await loadLibretroCore({ jsPath, wasmPath, glCanvas });
279
349
  this.mod = mod;
280
350
 
281
- // Canvas HW-render cores: initialize the Emscripten GL context so `GLctx` is
351
+ if (this._proxied) {
352
+ // Spawn the app thread, then build the whole GL stack on it (native-gles + webgl-node +
353
+ // the Emscripten GL context). 480x272 is PSP native; the readback crops per-frame.
354
+ if (typeof mod._romdev_proxy_init !== "function") {
355
+ throw new Error("core marked proxied but missing _romdev_proxy_init export");
356
+ }
357
+ mod._romdev_proxy_init();
358
+ // Confirm the app thread's event loop is actually SERVICING its proxy queue before driving
359
+ // it — app_ready is set by a proxied ping (ping_on_app), which only runs once the queue is
360
+ // live. Setting it eagerly would race: a proxied call issued before the loop pumps would sit
361
+ // unprocessed and a proxy_sync would hang main. Pump + yield so the async ping is delivered.
362
+ mod._romdev_app_ping();
363
+ while (mod._romdev_app_ready() !== 1) {
364
+ mod._romdev_pump_main_queue();
365
+ await new Promise((r) => setTimeout(r, 0));
366
+ }
367
+ if (opts.hwRender) {
368
+ // Async GL setup: the app thread imports native-gles/webgl-node + creates the context,
369
+ // which needs the main JS event loop. So kick it async + poll while yielding (a blocking
370
+ // proxy_sync would freeze the event loop the import() depends on → deadlock).
371
+ mod._romdev_proxied_gl_setup_start(480, 272);
372
+ while (mod._romdev_gl_setup_phase() !== 2) {
373
+ await new Promise((r) => setTimeout(r, 0));
374
+ }
375
+ if (mod._romdev_gl_setup_result() !== 1) {
376
+ throw new Error("proxied GL setup failed on the app thread");
377
+ }
378
+ }
379
+ }
380
+
381
+ // Canvas HW-render cores (non-proxied): initialize the Emscripten GL context so `GLctx` is
282
382
  // set BEFORE any GL call (the core's context_reset / first getParameter). Must
283
383
  // run before _retro_init.
284
384
  if (glCanvas && mod.GL) {
285
- const handle = mod.GL.createContext(glCanvas, { majorVersion: 2 });
286
- if (handle > 0) mod.GL.makeContextCurrent(handle);
385
+ {
386
+ const handle = mod.GL.createContext(glCanvas, { majorVersion: 2 });
387
+ if (handle > 0) mod.GL.makeContextCurrent(handle);
388
+ }
389
+ }
390
+
391
+ if (this._proxied) {
392
+ // Proxied cores: install the JS callback impls on Module (the C trampolines proxy each
393
+ // callback from the app thread back to main to run these). Register the trampolines on
394
+ // MAIN — they're plain C function pointers the app thread can call, and retro_init/
395
+ // pthread_create must run on main (Worker allocation proxies to main; it'd deadlock if
396
+ // main were blocked proxying init to the app thread). Only load_game/run proxy to app.
397
+ const { registerProxiedCallbacks } = await import("./callbacks.js");
398
+ registerProxiedCallbacks({ mod, state: this.state, log: this.log });
399
+ mod._romdev_register_callbacks();
400
+ } else {
401
+ registerCallbacks({ mod, state: this.state, log: this.log });
402
+ }
403
+
404
+ // Pre-seed per-platform core-option overrides BEFORE _retro_init. The core
405
+ // registers its variables (SET_VARIABLES) during retro_init and decides its
406
+ // renderer THEN — e.g. parallel_n64 reads `parallel-n64-gfxplugin` to pick
407
+ // glide64 (GL/HW-render) vs angrylion (software). Seeding the override here
408
+ // (not in loadMedia, which is too late) makes the SET_VARIABLES handler keep
409
+ // our value instead of the core's default, so HW render engages from boot.
410
+ const platform = opts.platform ?? this._platformForCore(jsPath);
411
+ if (platform) {
412
+ const overrides = PLATFORM_CORE_OPTIONS[platform];
413
+ if (overrides) {
414
+ for (const [key, value] of Object.entries(overrides)) {
415
+ this.state.coreVariables.set(key, { value });
416
+ }
417
+ this.state.variablesUpdated = true;
418
+ }
287
419
  }
288
420
 
289
- registerCallbacks({ mod, state: this.state, log: this.log });
290
- mod._retro_init();
421
+ // retro_init runs on MAIN even for proxied cores (it spawns the threadManager workers via
422
+ // pthread_create, which needs the main thread free — not blocked proxying init to app).
423
+ // EXCEPT proxied cores defer it to loadMedia: PPSSPP's retro_init touches the system dir
424
+ // (fonts/flash0), which for proxied cores is only mirrored into the app-thread FS during
425
+ // loadMedia — calling retro_init before the mount makes the loader hang on missing assets.
426
+ if (!this._proxied) mod._retro_init();
427
+ else this._retroInitPending = true;
291
428
  this.status.corePath = jsPath;
292
429
  }
293
430
 
@@ -307,6 +444,10 @@ export class LibretroHost {
307
444
  async loadMedia(args) {
308
445
  const mod = this._needMod();
309
446
  const { platform } = args;
447
+ // Allow the systemDir to be supplied per-load (not just via the constructor) — proxied cores
448
+ // (PSP) need it to mirror BIOS/font assets into the app-thread FS, and callers commonly pass
449
+ // it to loadMedia rather than at construction.
450
+ if (args.systemDir && !this.systemDir) this.systemDir = args.systemDir;
310
451
  // Derive the kind from the file/virtual extension when the caller didn't say
311
452
  // — so a C64 .d64 reports mediaKind:"disk" (writable save target) vs a .prg
312
453
  // "program". For an in-memory load, the virtualName carries the ext.
@@ -340,7 +481,13 @@ export class LibretroHost {
340
481
  if (this.systemDir && !this._systemDirMounted && mod.FS) {
341
482
  try {
342
483
  const FS_SYS = "/system";
343
- mirrorDirToFS(mod.FS, this.systemDir, FS_SYS);
484
+ // Proxied cores read the system dir on the app thread (separate per-thread MEMFS); mirror
485
+ // ONLY there. Non-proxied cores read main's FS. (Mirroring to both is wasted work.)
486
+ if (this._proxied) {
487
+ mirrorDirToAppFS(mod, this.systemDir, FS_SYS);
488
+ } else {
489
+ mirrorDirToFS(mod.FS, this.systemDir, FS_SYS);
490
+ }
344
491
  // Redirect the core's reported system dir to the in-FS copy.
345
492
  if (this.state) this.state.systemDir = FS_SYS;
346
493
  this._systemDirMounted = true;
@@ -373,7 +520,10 @@ export class LibretroHost {
373
520
  throw new Error("loadMedia requires either `path` or `bytes`");
374
521
  }
375
522
  const vfsPath = "/rom" + ext;
376
- if (mod.FS) {
523
+ // For proxied cores the core fopen's on the app thread, so the ROM goes to the app-thread
524
+ // MEMFS below (romdev_app_fs_write) — writing it to the main-thread FS too is wasted work
525
+ // (and for a 60MB ISO, a needless copy). Non-proxied cores read main's FS directly.
526
+ if (mod.FS && !this._proxied) {
377
527
  try {
378
528
  mod.FS.writeFile(vfsPath, data);
379
529
  } catch (e) {
@@ -391,6 +541,14 @@ export class LibretroHost {
391
541
  const pathPtr = mod._malloc(pathBytes.length);
392
542
  mod.HEAPU8.set(pathBytes, pathPtr);
393
543
 
544
+ // Proxied cores run on the app thread, whose MEMFS is a SEPARATE (per-thread) JS heap — the
545
+ // main-thread FS.writeFile above is invisible there. PPSSPP fopen's the path on the app
546
+ // thread, so write the ROM into the app thread's MEMFS too (the bytes are already in shared
547
+ // WASM memory at dataPtr).
548
+ if (this._proxied) {
549
+ mod._romdev_app_fs_write(pathPtr, dataPtr, data.length);
550
+ }
551
+
394
552
  // retro_game_info struct (16 bytes on wasm32).
395
553
  const infoPtr = mod._malloc(16);
396
554
  mod.setValue(infoPtr + 0, pathPtr, "i32");
@@ -398,7 +556,38 @@ export class LibretroHost {
398
556
  mod.setValue(infoPtr + 8, data.length, "i32");
399
557
  mod.setValue(infoPtr + 12, 0, "i32"); // meta = null
400
558
 
401
- const ok = mod._retro_load_game(infoPtr);
559
+ // Proxied cores: run retro_load_game on the app thread ASYNCHRONOUSLY (it creates the GL
560
+ // context + spawns the core's worker threads there). We kick it async then poll the done
561
+ // flag while YIELDING to the JS event loop — the event loop must keep turning so emscripten
562
+ // can service the app-thread's pooled-Worker grabs / postMessage wakeups. A blocking call
563
+ // would freeze the event loop → the core's threads can't be scheduled → deadlock.
564
+ let ok;
565
+ if (this._proxied) {
566
+ // Deferred retro_init (see loadCore): now that the system dir is mirrored into the app-thread
567
+ // FS, the core can init (fonts/flash0 present) without hanging.
568
+ if (this._retroInitPending) {
569
+ mod._retro_init();
570
+ this._retroInitPending = false;
571
+ }
572
+ mod._romdev_proxied_load_game_start(infoPtr);
573
+ while (mod._romdev_proxied_load_state() === 0) {
574
+ mod._romdev_pump_main_queue(); // run callbacks the app thread proxied to main
575
+ await new Promise((r) => setTimeout(r, 0)); // yield → emscripten services worker ops
576
+ }
577
+ ok = mod._romdev_proxied_load_state() === 1;
578
+ // NOTE: PPSSPP's ContextReset (creates DrawContext + GPU) must run for rendering — without it
579
+ // gpu/draw are null and EmuFrame renders nothing (the blank-frame root cause). Firing it here
580
+ // currently heap-corrupts inside the GL render-manager construction (under investigation), so
581
+ // it's disabled for now; the core loads + runs stably without it (CPU/audio work, no video).
582
+ // ContextReset (creates GPU/DrawContext) survives intermittently after the webgl-node
583
+ // MAX_UNIFORM_BLOCK_SIZE fix but still hits a non-deterministic native-heap corruption in
584
+ // PPSSPP's GL init — disabled until that's pinned (ASAN). Core loads + runs without it.
585
+ if (ok && this.state.proxiedContextResetPtr) {
586
+ mod._romdev_proxied_fire_context_reset(this.state.proxiedContextResetPtr);
587
+ }
588
+ } else {
589
+ ok = mod._retro_load_game(infoPtr);
590
+ }
402
591
 
403
592
  // Free the struct itself. Don't free pathPtr or dataPtr — the core may
404
593
  // retain pointers into them for the life of the loaded game.
@@ -455,7 +644,10 @@ export class LibretroHost {
455
644
  // };
456
645
  // };
457
646
  const avInfoPtr = mod._malloc(64);
458
- mod._retro_get_system_av_info(avInfoPtr);
647
+ // Proxied cores keep core state on the app thread; retro_get_system_av_info reads it, so it
648
+ // must run there (a direct main-thread call reads garbage / can wedge). Proxy it.
649
+ if (this._proxied) mod._romdev_proxied_av_info(avInfoPtr);
650
+ else mod._retro_get_system_av_info(avInfoPtr);
459
651
  this.status.fbWidth = mod.getValue(avInfoPtr + 0, "i32");
460
652
  this.status.fbHeight = mod.getValue(avInfoPtr + 4, "i32");
461
653
  // aspect_ratio (+16) is the intended DISPLAY aspect (what a CRT would
@@ -483,11 +675,17 @@ export class LibretroHost {
483
675
  this.hwRender.createContext(maxW, maxH, this._glContextCreated);
484
676
  }
485
677
 
486
- // Configure controller port 0 as joypad (some cores default to NONE).
487
- mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
488
- // Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
489
- // only reads RetroPad port 1 when it's registered as a joypad device.
490
- mod._retro_set_controller_port_device(1, RETRO_DEVICE_JOYPAD);
678
+ // Configure controller port 0 as joypad (some cores default to NONE). Proxied cores touch
679
+ // core state on the app thread, so proxy it there.
680
+ if (this._proxied) {
681
+ mod._romdev_proxied_set_controller(0, RETRO_DEVICE_JOYPAD);
682
+ mod._romdev_proxied_set_controller(1, RETRO_DEVICE_JOYPAD);
683
+ } else {
684
+ mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
685
+ // Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
686
+ // only reads RetroPad port 1 when it's registered as a joypad device.
687
+ mod._retro_set_controller_port_device(1, RETRO_DEVICE_JOYPAD);
688
+ }
491
689
 
492
690
  // ---- Settle the framebuffer to the ROM's chosen geometry ----
493
691
  //
@@ -510,7 +708,10 @@ export class LibretroHost {
510
708
  // VDP in 1-3 frames; SNES in 1; NES in 1-2. The cost is ~5-30 ms
511
709
  // on loadMedia, paid once. Skip when loaded paused (caller is
512
710
  // already in control).
513
- if (!this.status.paused) {
711
+ // Proxied cores (PSP) drive frames through the async run path (run_start + poll); the
712
+ // synchronous settle loop below uses the blocking sync run, so skip it. PSP geometry is the
713
+ // fixed 480×272 av_info already read, so there's no pre-init default to settle past.
714
+ if (!this.status.paused && !this._proxied) {
514
715
  // Settle frames are core warm-up, not agent-visible gameplay
515
716
  // frames — don't increment frameCount. From the agent's POV the
516
717
  // first stepFrames(N) should advance the count by exactly N.
@@ -530,7 +731,7 @@ export class LibretroHost {
530
731
  let stepped = 0;
531
732
  let firstRefreshAt = -1;
532
733
  while (stepped < MAX_SETTLE) {
533
- mod._retro_run();
734
+ this._runCore();
534
735
  this._afterRun();
535
736
  stepped++;
536
737
  if (firstRefreshAt < 0 && this.state.lastFrame && this.state.lastFrame !== beforeRefreshSnapshot) {
@@ -567,11 +768,11 @@ export class LibretroHost {
567
768
  * @returns {number} frames actually run
568
769
  */
569
770
  stepFrames(n) {
570
- const mod = this._needMod();
771
+ this._needMod();
571
772
  this._needMedia();
572
773
  if (this.status.paused) return 0;
573
774
  for (let i = 0; i < n; i++) {
574
- mod._retro_run();
775
+ this._runCore();
575
776
  this._afterRun();
576
777
  this.status.frameCount++;
577
778
  }
@@ -582,13 +783,49 @@ export class LibretroHost {
582
783
  return n;
583
784
  }
584
785
 
786
+ /** Run one frame. Proxied cores route retro_run onto the app thread (so the JS main thread
787
+ * stays free to service the core's worker-thread proxy calls); others call directly. */
788
+ _runCore() {
789
+ if (this._proxied) this.mod._romdev_proxied_run();
790
+ else this.mod._retro_run();
791
+ }
792
+
585
793
  /** Post-_retro_run hook: HW-render readback. The video callback set
586
794
  * state.hwFramePending during run; now (GL state stable) read back the FBO into
587
795
  * state.lastFrame as an RGBA frame. No-op for the 14 software cores. */
588
796
  _afterRun() {
797
+ if (this._proxied) {
798
+ // Proxied cores own GL on the app thread; read the frame back THERE into a WASM buffer,
799
+ // then copy it out here. The core reports its size via video_refresh (hwFrameW/H).
800
+ if (this.state.hwFramePending) {
801
+ this.state.hwFramePending = false;
802
+ const w = this.state.hwFrameW || 480, h = this.state.hwFrameH || 272;
803
+ const mod = this.mod;
804
+ const n = w * h * 4;
805
+ if (!this._pxBuf || this._pxBufN !== n) {
806
+ if (this._pxBuf) mod._free(this._pxBuf);
807
+ this._pxBuf = mod._malloc(n); this._pxBufN = n;
808
+ }
809
+ mod._romdev_proxied_readback(w, h, this._pxBuf);
810
+ // native-gles is bottom-left origin → flip vertically into lastFrame.
811
+ const src = mod.HEAPU8.subarray(this._pxBuf, this._pxBuf + n);
812
+ const pixels = new Uint8Array(n);
813
+ const rowBytes = w * 4;
814
+ for (let y = 0; y < h; y++) {
815
+ pixels.set(src.subarray((h - 1 - y) * rowBytes, (h - y) * rowBytes), y * rowBytes);
816
+ }
817
+ this.state.lastFrame = {
818
+ width: w, height: h, pitch: rowBytes,
819
+ format: ROMDEV_PIXEL_FORMAT_RGBA8888, pixels, rgba: true,
820
+ };
821
+ }
822
+ return;
823
+ }
589
824
  if (this.state.hwFramePending && this.hwRender?.active) {
590
825
  this.state.hwFramePending = false;
591
- const frame = this.hwRender.readbackFrame();
826
+ // Crop the GL FBO to the core's reported active resolution (e.g. DC 640x480 in an
827
+ // 853x853 FBO) so screenshots don't carry the dead viewport border.
828
+ const frame = this.hwRender.readbackFrame(this.state.hwFrameW, this.state.hwFrameH);
592
829
  if (frame) {
593
830
  this.state.lastFrame = {
594
831
  width: frame.width, height: frame.height,
@@ -604,9 +841,9 @@ export class LibretroHost {
604
841
  * playtest loop can't race). Advances the (monotonic) frame counter by 1.
605
842
  * Returns the frame count after. */
606
843
  renderOneFrame() {
607
- const mod = this._needMod();
844
+ this._needMod();
608
845
  this._needMedia();
609
- mod._retro_run();
846
+ this._runCore();
610
847
  this._afterRun();
611
848
  this.status.frameCount++;
612
849
  if (this.state.lastFrame) {
@@ -1484,6 +1721,47 @@ export class LibretroHost {
1484
1721
  }
1485
1722
  }
1486
1723
 
1724
+ /** True when the Dreamcast core (flycast) exposes the live SH-4 register block. */
1725
+ sh4RegsSupported() {
1726
+ return !!(this.mod && typeof this.mod._romdev_sh4_regs_get === "function");
1727
+ }
1728
+
1729
+ /** Read the live SH-4 register block as a Uint32Array(24):
1730
+ * [0..15]=r0..r15, 16=pc, 17=pr, 18=gbr, 19=vbr, 20=sr, 21=mac.l, 22=mac.h, 23=fpul.
1731
+ * null if the core doesn't expose it. */
1732
+ getSh4Regs() {
1733
+ const mod = this.mod;
1734
+ if (!mod || typeof mod._romdev_sh4_regs_get !== "function") return null;
1735
+ const ptr = mod._malloc(24 * 4);
1736
+ try {
1737
+ mod._romdev_sh4_regs_get(ptr);
1738
+ return new Uint32Array(mod.HEAPU8.buffer, ptr, 24).slice();
1739
+ } finally {
1740
+ mod._free(ptr);
1741
+ }
1742
+ }
1743
+
1744
+ /** True when the Dreamcast core exposes the AICA register file
1745
+ * (getAudioState chip:'aica'). */
1746
+ aicaRegsSupported() {
1747
+ return !!(this.mod && typeof this.mod._romdev_aica_get === "function");
1748
+ }
1749
+
1750
+ /** Read the AICA register window (channels + CommonData) as a Uint8Array.
1751
+ * Default 0x3000 bytes (64 SGC channel blocks @ 0x80 each + CommonData @ 0x2800).
1752
+ * null if the core doesn't expose it. */
1753
+ getAicaRegs(bytes = 0x3000) {
1754
+ const mod = this.mod;
1755
+ if (!mod || typeof mod._romdev_aica_get !== "function") return null;
1756
+ const ptr = mod._malloc(bytes);
1757
+ try {
1758
+ mod._romdev_aica_get(ptr, bytes);
1759
+ return new Uint8Array(mod.HEAPU8.buffer, ptr, bytes).slice();
1760
+ } finally {
1761
+ mod._free(ptr);
1762
+ }
1763
+ }
1764
+
1487
1765
  /** True when the PS1 core exposes the SPU register block (getAudioState chip:'spu'). */
1488
1766
  spuRegsSupported() {
1489
1767
  return !!(this.mod && typeof this.mod._romdev_spu_get === "function");
@@ -1711,7 +1989,7 @@ export class LibretroHost {
1711
1989
  * @returns {number} frames actually run
1712
1990
  */
1713
1991
  _runFramesExclusive(body, maxFrames) {
1714
- const mod = this._needMod();
1992
+ this._needMod();
1715
1993
  // Suspend ONLY the playtest window's render-tick stepping for the duration —
1716
1994
  // not `status.paused` (the agent's pause is a separate concept, and the core
1717
1995
  // run must be identical whether or not the user paused). The playtest tick
@@ -1722,7 +2000,7 @@ export class LibretroHost {
1722
2000
  let framesRun = 0;
1723
2001
  try {
1724
2002
  for (let i = 0; i < maxFrames; i++) {
1725
- mod._retro_run();
2003
+ this._runCore();
1726
2004
  this.status.frameCount++;
1727
2005
  framesRun++;
1728
2006
  if (body(i)) break;
@@ -170,8 +170,12 @@ export function registerCallbacks(args) {
170
170
  // GL state is only stable AFTER _retro_run returns. Flag it; stepFrames does the
171
171
  // glReadPixels post-run.
172
172
  const uPtr = dataPtr >>> 0;
173
- if (uPtr === RETRO_HW_FRAME_BUFFER_VALID && state.hwRender?.active) {
173
+ if (uPtr === RETRO_HW_FRAME_BUFFER_VALID && (state.hwRender?.active || state.proxied)) {
174
174
  state.hwFramePending = true;
175
+ // Remember the core's reported active resolution (e.g. DC 640x480) so the
176
+ // readback can crop the (often larger, e.g. 853x853) GL FBO to just the
177
+ // rendered region instead of returning the whole viewport with dead borders.
178
+ if (w > 0 && h > 0) { state.hwFrameW = w; state.hwFrameH = h; }
175
179
  return;
176
180
  }
177
181
  if (dataPtr === 0 || uPtr === RETRO_HW_FRAME_BUFFER_VALID) return;
@@ -224,6 +228,49 @@ export function registerCallbacks(args) {
224
228
  mod._retro_set_input_state(inputStateCb);
225
229
  }
226
230
 
231
+ /**
232
+ * Proxied (app-thread) cores: the C shim registers C trampolines with the core that proxy each
233
+ * callback back to the MAIN thread to run these JS impls. So instead of addFunction (which binds
234
+ * a closure to the calling thread and can't be invoked from the app thread), we install the JS
235
+ * impls as Module fns the trampolines call via EM_ASM. Same bodies as registerCallbacks, minus
236
+ * the addFunction/retro_set_* (the shim does those on the app thread).
237
+ */
238
+ export function registerProxiedCallbacks(args) {
239
+ const { mod, state, log } = args;
240
+
241
+ mod.romdev_envCb = (cmd, dataPtr) => (handleEnv(mod, state, cmd, dataPtr, log) ? 1 : 0);
242
+
243
+ mod.romdev_videoCb = (dataPtr, w, h, pitch) => {
244
+ const uPtr = dataPtr >>> 0;
245
+ if (uPtr === RETRO_HW_FRAME_BUFFER_VALID && (state.hwRender?.active || state.proxied)) {
246
+ state.hwFramePending = true;
247
+ if (w > 0 && h > 0) { state.hwFrameW = w; state.hwFrameH = h; }
248
+ return;
249
+ }
250
+ if (dataPtr === 0 || uPtr === RETRO_HW_FRAME_BUFFER_VALID) return;
251
+ const bytes = h * pitch;
252
+ const view = mod.HEAPU8.subarray(dataPtr, dataPtr + bytes);
253
+ state.lastFrame = { width: w, height: h, pitch, format: state.pixelFormat, pixels: new Uint8Array(view) };
254
+ };
255
+
256
+ mod.romdev_audioBatchCb = (dataPtr, frames) => {
257
+ if (dataPtr === 0 || frames === 0) return frames;
258
+ const byteLen = frames * 2 * 2;
259
+ const samples = new Int16Array(mod.HEAPU8.buffer, dataPtr, byteLen / 2).slice();
260
+ state.audioRing.push(samples);
261
+ return frames;
262
+ };
263
+
264
+ mod.romdev_inputCb = (port, device, _idx, id) => {
265
+ if (device === 3) return state.keysDown.has(id) ? 1 : 0;
266
+ const portBits = state.inputPorts[port];
267
+ if (!portBits) return 0;
268
+ const bits = portBits[0];
269
+ if (id === 256) return bits;
270
+ return bits & (1 << id) ? 1 : 0;
271
+ };
272
+ }
273
+
227
274
  function allocString(mod, state, str) {
228
275
  const bytes = Buffer.from(str + "\0", "utf-8");
229
276
  const ptr = mod._malloc(bytes.length);
@@ -296,6 +343,19 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
296
343
  // already a useful diagnostic — most cores pass a fully-formed message
297
344
  // (no varargs substitution needed) or a message + N args we can't decode.
298
345
  // Read the fmt as-is so log output is actually readable.
346
+ // Proxied cores call the log callback from the app thread, so it must be a C function
347
+ // pointer (a main-thread addFunction is a bad table index there). The shim's romdev_log_cb
348
+ // routes to Module.romdev_logCb (installed below).
349
+ if (state.proxied) {
350
+ mod.romdev_logCb = (level, fmtPtr) => {
351
+ if (!log) return;
352
+ let msg = "<unreadable>";
353
+ try { msg = mod.UTF8ToString(fmtPtr); } catch { /* */ }
354
+ log(level, msg);
355
+ };
356
+ mod.setValue(dataPtr, mod._romdev_log_cb_ptr(), "i32");
357
+ return true;
358
+ }
299
359
  if (!state._logCbPtr) {
300
360
  state._logCbPtr = mod.addFunction((level, fmtPtr, _vaPtr) => {
301
361
  if (!log) return;
@@ -444,6 +504,17 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
444
504
  // software cores, or no GL stack), reject so the core falls back to software.
445
505
  case E.SET_HW_RENDER:
446
506
  if (state.hwRender) return state.hwRender.setup(mod, dataPtr);
507
+ // Proxied cores own GL on the app thread. Fill the retro_hw_render_callback struct with
508
+ // C function pointers (valid on the app thread, unlike main-thread addFunction): the core
509
+ // reads context_reset (+4) itself; we write get_current_framebuffer (+8) + get_proc_address
510
+ // (+12) from the shim. bottom_left_origin (+18) is true for GL.
511
+ if (state.proxied) {
512
+ state.proxiedContextResetPtr = mod.getValue(dataPtr + 4, "i32");
513
+ mod.setValue(dataPtr + 8, mod._romdev_hw_get_fb_ptr(), "i32");
514
+ mod.setValue(dataPtr + 12, mod._romdev_hw_get_proc_ptr(), "i32");
515
+ state.proxiedBottomLeft = !!mod.HEAPU8[dataPtr + 18];
516
+ return true;
517
+ }
447
518
  return false;
448
519
 
449
520
  // Things we can't or don't want to support — reject so core falls back.
@@ -37,10 +37,15 @@ export async function loadLibretroCore(args) {
37
37
 
38
38
  /** @type {Record<string, unknown>} */
39
39
  const opts = {
40
- noInitialRun: true,
41
- print: () => {},
42
- printErr: () => {},
40
+ print: process.env.ROMDEV_CORE_LOG ? (s) => console.error("[core]", s) : () => {},
41
+ printErr: process.env.ROMDEV_CORE_LOG ? (s) => console.error("[core:err]", s) : () => {},
43
42
  };
43
+ // The cores are linked with INVOKE_RUN=0, so main() never auto-runs regardless.
44
+ // Do NOT set noInitialRun on GL (canvas) cores: it suppresses the Emscripten GL
45
+ // runtime init, so `Module.GL` (and thus the WebGL2/native-gles context the core's
46
+ // SET_HW_RENDER path needs) is never created. For non-GL cores it's harmless either
47
+ // way; we set it only there to keep their startup identical to before.
48
+ if (!glCanvas) opts.noInitialRun = true;
44
49
 
45
50
  // HW-render cores: minified glue uses GLctx = canvas.getContext('webgl2'), so we
46
51
  // hand it a webgl-node mock canvas + install the WebGL2 globals Emscripten probes.
@@ -84,8 +89,16 @@ export async function loadLibretroCore(args) {
84
89
  });
85
90
  return {};
86
91
  };
87
- } else if (wasmPath) {
92
+ } else if (wasmPath && !glCanvas) {
88
93
  opts.wasmBinary = await readFile(wasmPath);
94
+ } else if (wasmPath && glCanvas) {
95
+ // GL (canvas) cores: do NOT hand Emscripten a pre-read wasmBinary. Passing it
96
+ // routes instantiation through a path that skips the GL runtime init, so
97
+ // `Module.GL` (the WebGL2/native-gles context the SET_HW_RENDER path needs) is
98
+ // never created. Let Emscripten locate `<name>.wasm` next to the `.js` itself
99
+ // (via locateFile) — the same way retroemu loads parallel_n64/flycast.
100
+ const wasmDir = wasmPath.slice(0, wasmPath.lastIndexOf("/") + 1);
101
+ opts.locateFile = (file) => wasmDir + file;
89
102
  }
90
103
 
91
104
  const mod = await factory(opts);
@@ -163,6 +163,35 @@ function decodeMips(regs) {
163
163
  };
164
164
  }
165
165
 
166
+ /** Decode the SH-4 register block (Dreamcast) from getSh4Regs():
167
+ * [0..15]=r0..r15, 16=pc, 17=pr, 18=gbr, 19=vbr, 20=sr, 21=mac.l, 22=mac.h, 23=fpul. */
168
+ function decodeSh4(regs) {
169
+ if (!regs || regs.length < 24) return null;
170
+ const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase().padStart(8, "0");
171
+ const named = {};
172
+ for (let i = 0; i < 16; i++) named["r" + i] = hx(regs[i]);
173
+ named.pr = hx(regs[17]);
174
+ named.gbr = hx(regs[18]);
175
+ named.vbr = hx(regs[19]);
176
+ named.sr = hx(regs[20]);
177
+ named.macl = hx(regs[21]);
178
+ named.mach = hx(regs[22]);
179
+ named.fpul = hx(regs[23]);
180
+ // SR flags: T(0) S(1) Q(8) M(9) BL(28) RB(29) MD(30)
181
+ const sr = regs[20] >>> 0;
182
+ return {
183
+ pc: regs[16] >>> 0,
184
+ pcHex: hx(regs[16]),
185
+ registers: named,
186
+ flags: {
187
+ T: !!(sr & 0x1), S: !!(sr & 0x2),
188
+ Q: !!(sr & 0x100), M: !!(sr & 0x200),
189
+ BL: !!(sr & 0x10000000), RB: !!(sr & 0x20000000), MD: !!(sr & 0x40000000),
190
+ raw: hx(sr),
191
+ },
192
+ };
193
+ }
194
+
166
195
  function decode6502(bytes) {
167
196
  if (!bytes || bytes.length < 25) return null;
168
197
  const u16le = (o) => bytes[o] | (bytes[o + 1] << 8);
@@ -327,6 +356,9 @@ export function getCPUState(host, platform, cpu = "main") {
327
356
  if (platform === "n64" || platform === "ps1") {
328
357
  return decodeMips(host.getMipsRegs?.());
329
358
  }
359
+ if (platform === "dreamcast") {
360
+ return decodeSh4(host.getSh4Regs?.());
361
+ }
330
362
  if (platform === "nes") {
331
363
  const bytes = host.readMemory("nes_cpu_regs", 0, 28);
332
364
  return decode6502(bytes);