romdevtools 0.70.0 → 0.71.1

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 (35) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/examples/dreamcast/platformer/main.c +31 -0
  3. package/examples/dreamcast/puzzle/main.c +44 -0
  4. package/examples/dreamcast/racing/main.c +39 -0
  5. package/examples/dreamcast/shmup/main.c +50 -0
  6. package/examples/dreamcast/sports/main.c +39 -0
  7. package/package.json +3 -3
  8. package/src/cores/capabilities.js +13 -9
  9. package/src/host/LibretroHost.js +242 -23
  10. package/src/host/callbacks.js +68 -1
  11. package/src/host/cpu-state.js +32 -0
  12. package/src/host/dc-aica-state.js +67 -0
  13. package/src/mcp/tools/audio.js +1 -1
  14. package/src/mcp/tools/disasm.js +1 -1
  15. package/src/mcp/tools/index.js +1 -1
  16. package/src/mcp/tools/platform-docs.js +1 -1
  17. package/src/mcp/tools/platform-tools.js +9 -1
  18. package/src/mcp/tools/platforms.js +46 -10
  19. package/src/mcp/tools/toolchain.js +140 -10
  20. package/src/platforms/dreamcast/MENTAL_MODEL.md +87 -0
  21. package/src/platforms/dreamcast/TROUBLESHOOTING.md +55 -0
  22. package/src/platforms/dreamcast/UPSTREAM_SOURCES.md +57 -0
  23. package/src/platforms/gba/MENTAL_MODEL.md +22 -1
  24. package/src/platforms/n64/MENTAL_MODEL.md +84 -0
  25. package/src/platforms/n64/TROUBLESHOOTING.md +60 -0
  26. package/src/platforms/n64/UPSTREAM_SOURCES.md +52 -0
  27. package/src/platforms/n64/lib/c/n64.c +181 -80
  28. package/src/platforms/ps1/MENTAL_MODEL.md +85 -0
  29. package/src/platforms/ps1/TROUBLESHOOTING.md +55 -0
  30. package/src/platforms/ps1/UPSTREAM_SOURCES.md +54 -0
  31. package/src/platforms/snes/TROUBLESHOOTING.md +10 -0
  32. package/src/toolchains/asar/asar.js +84 -14
  33. package/src/toolchains/mips-c/mips-c.js +35 -1
  34. package/src/toolchains/sh-c/lib/dc.h +65 -15
  35. package/src/toolchains/sh-c/sh-c.js +6 -3
@@ -164,6 +164,29 @@ function mirrorDirToFS(FS, hostDir, fsDir) {
164
164
  }
165
165
  }
166
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
+
167
190
  /**
168
191
  * When loadMedia is called with `bytes:` and no `virtualName`, this is
169
192
  * the extension we tack onto "/rom" so the core knows which platform
@@ -294,8 +317,16 @@ export class LibretroHost {
294
317
  async loadCore(jsPath, wasmPath, opts = {}) {
295
318
  if (this.mod) throw new Error("core already loaded; create a new host");
296
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
+
297
328
  let glCanvas = null;
298
- if (opts.hwRender) {
329
+ if (opts.hwRender && !this._proxied) {
299
330
  // Set up the HW-render path: LibretroGL owns the FBO/readback; a webgl-node
300
331
  // mock canvas backs the minified core's GLctx. Both pull the OPTIONAL native
301
332
  // GL deps lazily (clear install hint if absent).
@@ -317,15 +348,58 @@ export class LibretroHost {
317
348
  const mod = await loadLibretroCore({ jsPath, wasmPath, glCanvas });
318
349
  this.mod = mod;
319
350
 
320
- // 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
321
382
  // set BEFORE any GL call (the core's context_reset / first getParameter). Must
322
383
  // run before _retro_init.
323
384
  if (glCanvas && mod.GL) {
324
- const handle = mod.GL.createContext(glCanvas, { majorVersion: 2 });
325
- 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
+ }
326
389
  }
327
390
 
328
- registerCallbacks({ mod, state: this.state, log: this.log });
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
+ }
329
403
 
330
404
  // Pre-seed per-platform core-option overrides BEFORE _retro_init. The core
331
405
  // registers its variables (SET_VARIABLES) during retro_init and decides its
@@ -344,7 +418,13 @@ export class LibretroHost {
344
418
  }
345
419
  }
346
420
 
347
- 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;
348
428
  this.status.corePath = jsPath;
349
429
  }
350
430
 
@@ -364,6 +444,10 @@ export class LibretroHost {
364
444
  async loadMedia(args) {
365
445
  const mod = this._needMod();
366
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;
367
451
  // Derive the kind from the file/virtual extension when the caller didn't say
368
452
  // — so a C64 .d64 reports mediaKind:"disk" (writable save target) vs a .prg
369
453
  // "program". For an in-memory load, the virtualName carries the ext.
@@ -397,7 +481,13 @@ export class LibretroHost {
397
481
  if (this.systemDir && !this._systemDirMounted && mod.FS) {
398
482
  try {
399
483
  const FS_SYS = "/system";
400
- 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
+ }
401
491
  // Redirect the core's reported system dir to the in-FS copy.
402
492
  if (this.state) this.state.systemDir = FS_SYS;
403
493
  this._systemDirMounted = true;
@@ -430,7 +520,10 @@ export class LibretroHost {
430
520
  throw new Error("loadMedia requires either `path` or `bytes`");
431
521
  }
432
522
  const vfsPath = "/rom" + ext;
433
- 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) {
434
527
  try {
435
528
  mod.FS.writeFile(vfsPath, data);
436
529
  } catch (e) {
@@ -448,6 +541,14 @@ export class LibretroHost {
448
541
  const pathPtr = mod._malloc(pathBytes.length);
449
542
  mod.HEAPU8.set(pathBytes, pathPtr);
450
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
+
451
552
  // retro_game_info struct (16 bytes on wasm32).
452
553
  const infoPtr = mod._malloc(16);
453
554
  mod.setValue(infoPtr + 0, pathPtr, "i32");
@@ -455,7 +556,38 @@ export class LibretroHost {
455
556
  mod.setValue(infoPtr + 8, data.length, "i32");
456
557
  mod.setValue(infoPtr + 12, 0, "i32"); // meta = null
457
558
 
458
- 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
+ }
459
591
 
460
592
  // Free the struct itself. Don't free pathPtr or dataPtr — the core may
461
593
  // retain pointers into them for the life of the loaded game.
@@ -512,7 +644,10 @@ export class LibretroHost {
512
644
  // };
513
645
  // };
514
646
  const avInfoPtr = mod._malloc(64);
515
- 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);
516
651
  this.status.fbWidth = mod.getValue(avInfoPtr + 0, "i32");
517
652
  this.status.fbHeight = mod.getValue(avInfoPtr + 4, "i32");
518
653
  // aspect_ratio (+16) is the intended DISPLAY aspect (what a CRT would
@@ -540,11 +675,17 @@ export class LibretroHost {
540
675
  this.hwRender.createContext(maxW, maxH, this._glContextCreated);
541
676
  }
542
677
 
543
- // Configure controller port 0 as joypad (some cores default to NONE).
544
- mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
545
- // Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
546
- // only reads RetroPad port 1 when it's registered as a joypad device.
547
- 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
+ }
548
689
 
549
690
  // ---- Settle the framebuffer to the ROM's chosen geometry ----
550
691
  //
@@ -567,7 +708,10 @@ export class LibretroHost {
567
708
  // VDP in 1-3 frames; SNES in 1; NES in 1-2. The cost is ~5-30 ms
568
709
  // on loadMedia, paid once. Skip when loaded paused (caller is
569
710
  // already in control).
570
- 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) {
571
715
  // Settle frames are core warm-up, not agent-visible gameplay
572
716
  // frames — don't increment frameCount. From the agent's POV the
573
717
  // first stepFrames(N) should advance the count by exactly N.
@@ -587,7 +731,7 @@ export class LibretroHost {
587
731
  let stepped = 0;
588
732
  let firstRefreshAt = -1;
589
733
  while (stepped < MAX_SETTLE) {
590
- mod._retro_run();
734
+ this._runCore();
591
735
  this._afterRun();
592
736
  stepped++;
593
737
  if (firstRefreshAt < 0 && this.state.lastFrame && this.state.lastFrame !== beforeRefreshSnapshot) {
@@ -624,11 +768,11 @@ export class LibretroHost {
624
768
  * @returns {number} frames actually run
625
769
  */
626
770
  stepFrames(n) {
627
- const mod = this._needMod();
771
+ this._needMod();
628
772
  this._needMedia();
629
773
  if (this.status.paused) return 0;
630
774
  for (let i = 0; i < n; i++) {
631
- mod._retro_run();
775
+ this._runCore();
632
776
  this._afterRun();
633
777
  this.status.frameCount++;
634
778
  }
@@ -639,10 +783,44 @@ export class LibretroHost {
639
783
  return n;
640
784
  }
641
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
+
642
793
  /** Post-_retro_run hook: HW-render readback. The video callback set
643
794
  * state.hwFramePending during run; now (GL state stable) read back the FBO into
644
795
  * state.lastFrame as an RGBA frame. No-op for the 14 software cores. */
645
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
+ }
646
824
  if (this.state.hwFramePending && this.hwRender?.active) {
647
825
  this.state.hwFramePending = false;
648
826
  // Crop the GL FBO to the core's reported active resolution (e.g. DC 640x480 in an
@@ -663,9 +841,9 @@ export class LibretroHost {
663
841
  * playtest loop can't race). Advances the (monotonic) frame counter by 1.
664
842
  * Returns the frame count after. */
665
843
  renderOneFrame() {
666
- const mod = this._needMod();
844
+ this._needMod();
667
845
  this._needMedia();
668
- mod._retro_run();
846
+ this._runCore();
669
847
  this._afterRun();
670
848
  this.status.frameCount++;
671
849
  if (this.state.lastFrame) {
@@ -1543,6 +1721,47 @@ export class LibretroHost {
1543
1721
  }
1544
1722
  }
1545
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
+
1546
1765
  /** True when the PS1 core exposes the SPU register block (getAudioState chip:'spu'). */
1547
1766
  spuRegsSupported() {
1548
1767
  return !!(this.mod && typeof this.mod._romdev_spu_get === "function");
@@ -1770,7 +1989,7 @@ export class LibretroHost {
1770
1989
  * @returns {number} frames actually run
1771
1990
  */
1772
1991
  _runFramesExclusive(body, maxFrames) {
1773
- const mod = this._needMod();
1992
+ this._needMod();
1774
1993
  // Suspend ONLY the playtest window's render-tick stepping for the duration —
1775
1994
  // not `status.paused` (the agent's pause is a separate concept, and the core
1776
1995
  // run must be identical whether or not the user paused). The playtest tick
@@ -1781,7 +2000,7 @@ export class LibretroHost {
1781
2000
  let framesRun = 0;
1782
2001
  try {
1783
2002
  for (let i = 0; i < maxFrames; i++) {
1784
- mod._retro_run();
2003
+ this._runCore();
1785
2004
  this.status.frameCount++;
1786
2005
  framesRun++;
1787
2006
  if (body(i)) break;
@@ -170,7 +170,7 @@ 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
175
  // Remember the core's reported active resolution (e.g. DC 640x480) so the
176
176
  // readback can crop the (often larger, e.g. 853x853) GL FBO to just the
@@ -228,6 +228,49 @@ export function registerCallbacks(args) {
228
228
  mod._retro_set_input_state(inputStateCb);
229
229
  }
230
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
+
231
274
  function allocString(mod, state, str) {
232
275
  const bytes = Buffer.from(str + "\0", "utf-8");
233
276
  const ptr = mod._malloc(bytes.length);
@@ -300,6 +343,19 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
300
343
  // already a useful diagnostic — most cores pass a fully-formed message
301
344
  // (no varargs substitution needed) or a message + N args we can't decode.
302
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
+ }
303
359
  if (!state._logCbPtr) {
304
360
  state._logCbPtr = mod.addFunction((level, fmtPtr, _vaPtr) => {
305
361
  if (!log) return;
@@ -448,6 +504,17 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
448
504
  // software cores, or no GL stack), reject so the core falls back to software.
449
505
  case E.SET_HW_RENDER:
450
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
+ }
451
518
  return false;
452
519
 
453
520
  // Things we can't or don't want to support — reject so core falls back.
@@ -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);
@@ -0,0 +1,67 @@
1
+ // Dreamcast AICA sound-chip decode — the "what is each of the 64 PCM channels
2
+ // doing this frame?" view that getAudioState gives the other platforms' chips.
3
+ // Input is the raw AICA register window (Uint8Array) from host.getAicaRegs():
4
+ // 64 channels × 0x80 bytes starting at 0x000, plus CommonData at 0x2800.
5
+ //
6
+ // The AICA is a 64-channel PCM/ADPCM sampler. Canonical per-channel register map
7
+ // (offset within the channel's 0x80 block), little-endian 16-bit registers:
8
+ // 0x00 : KYONEX(15) KYONB(14) ... LPCTL(9) PCMS(8..7) SA-high(6..0)
9
+ // 0x04 : SA-low (sample start address, low 16)
10
+ // 0x08 : LSA (loop start) 0x0C : LEA (loop end)
11
+ // 0x10 : D2R/D1R/AR (attack/decay) 0x14 : RR/DL/KRS/LS (release/sustain)
12
+ // 0x18 : LPSLNK / OCT / FNS (pitch: OCT bits 14..11 signed, FNS bits 9..0)
13
+ // 0x24 : DISDL (direct send level, bits 11..8) / DIPAN (pan, bits 4..0)
14
+ // CommonData (0x2800): 0x00 MVOL (master volume, bits 3..0) + MONO/MEM8MB/etc.
15
+
16
+ const CH_STRIDE = 0x80;
17
+ const NUM_CH = 64;
18
+ const COMMON = 0x2800;
19
+
20
+ /**
21
+ * @param {Uint8Array|null} reg the AICA register window from getAicaRegs()
22
+ * @returns {{ chip:"aica", masterVolume:number, voices:Array<object>,
23
+ * activeVoices:number } | null}
24
+ */
25
+ export function decodeAica(reg) {
26
+ if (!reg || reg.length < COMMON + 2) return null;
27
+ const u16 = (o) => (reg[o] | (reg[o + 1] << 8)) & 0xffff;
28
+
29
+ const voices = [];
30
+ let active = 0;
31
+ for (let c = 0; c < NUM_CH; c++) {
32
+ const base = c * CH_STRIDE;
33
+ if (base + 0x28 > reg.length) break;
34
+ const play = u16(base + 0x00);
35
+ const keyOn = !!(play & 0x4000); // KYONB
36
+ const loop = !!(play & 0x0200); // LPCTL
37
+ const pcms = (play >> 7) & 0x3; // 0/1 = 16/8-bit PCM, 2 = ADPCM
38
+ const saHi = play & 0x7f;
39
+ const saLo = u16(base + 0x04);
40
+ const sampleAddr = ((saHi << 16) | saLo) >>> 0;
41
+ const lsa = u16(base + 0x08);
42
+ const lea = u16(base + 0x0c);
43
+ const pitch = u16(base + 0x18);
44
+ const oct = (pitch >> 11) & 0xf; // signed 4-bit
45
+ const fns = pitch & 0x3ff;
46
+ const env = u16(base + 0x24);
47
+ const disdl = (env >> 8) & 0xf; // direct send level (volume)
48
+ const dipan = env & 0x1f; // pan
49
+ if (keyOn && disdl > 0) active++;
50
+ voices.push({
51
+ ch: c,
52
+ keyOn,
53
+ loop,
54
+ format: pcms === 2 ? "ADPCM" : (pcms === 1 ? "PCM8" : "PCM16"),
55
+ sampleAddr: "$" + sampleAddr.toString(16).toUpperCase(),
56
+ loopStart: lsa,
57
+ loopEnd: lea,
58
+ octave: oct >= 8 ? oct - 16 : oct, // 4-bit signed
59
+ fns,
60
+ volume: disdl, // 0..15 direct send level
61
+ pan: dipan,
62
+ });
63
+ }
64
+
65
+ const mvol = u16(COMMON + 0x00) & 0xf;
66
+ return { chip: "aica", masterVolume: mvol, activeVoices: active, voices };
67
+ }
@@ -367,7 +367,7 @@ export function registerAudioTools(server, z, sessionKey) {
367
367
  "sampled SFX fire' on Genesis is still record-and-FFT.",
368
368
  {
369
369
  op: z.enum(["inspect", "record"]).describe("inspect a sound chip's live state (single-frame, or a frames trace); or record audio to a WAV."),
370
- chip: z.enum(["nes", "gb", "gba", "dsp", "psg", "ym2612", "sid", "mikey", "pce", "ay8910"]).optional().describe("op=inspect: which sound chip to decode (all 14 systems mapped)."),
370
+ chip: z.enum(["nes", "gb", "gba", "dsp", "psg", "ym2612", "sid", "mikey", "pce", "ay8910", "spu", "ai", "aica"]).optional().describe("op=inspect: which sound chip to decode. Tile systems: nes/gb/gba/dsp(SNES)/psg(SN76489)/ym2612(Genesis FM)/sid(C64)/mikey(Lynx)/pce/ay8910(MSX). 3D systems: spu (PS1, 24 ADPCM voices), ai (N64 AI output), aica (Dreamcast, 64 PCM/ADPCM channels)."),
371
371
  frames: z.number().int().min(1).max(60000).optional().describe("op=record: emulator frames to capture (default 180 = 3s NTSC). op=inspect: if set, TRACE the chip over N frames into a per-channel timeline; OMIT for a single-frame snapshot."),
372
372
  sampleEvery: z.number().int().min(1).default(1).describe("op=inspect trace: sample the chip only every Nth frame (thins a long trace; the last frame is always sampled). Changes on skipped frames surface at the next sampled frame, so >1 loses fine timing — keep at 1 to catch every transition."),
373
373
  path: z.string().optional().describe("op=record: absolute path to write the WAV (required for record)."),
@@ -1461,7 +1461,7 @@ export function registerDisasmTools(server, z) {
1461
1461
  "read/write); also walks the vector table. Banked carts are scanned PER BANK (all of the formats above) — " +
1462
1462
  "refs carry `prgBank` (NES) / `romBank` (everything else). LIMITATION: direct addressing only " +
1463
1463
  "(indirect/computed jumps are missed).\n" +
1464
- "── RE ENGINE (Rizin + Ghidra, all 14 platforms) ──\n" +
1464
+ "── RE ENGINE (Rizin + Ghidra, all platforms incl. the 3D CPUs: MIPS R3000/R4300 + SH-4) ──\n" +
1465
1465
  "'functions' = Rizin auto-detected function list {address,size,nbbs,cc,callers,callees,looksLikeData}; the " +
1466
1466
  "structural map of an unknown ROM. Sorted REAL CODE FIRST (by nbbs/cc) — don't rank by `size`, which is a lie " +
1467
1467
  "(rizin folds data tables into giant pseudo-functions). `looksLikeData:true` (and the top-level `dataCount`) flags " +
@@ -155,7 +155,7 @@ const CATEGORIES = [
155
155
  },
156
156
  {
157
157
  name: "debug",
158
- description: "Cross-platform debugging + reverse-engineering: inspectSprites, inspectPalette, getCPUState (main/spc700/z80), getAudioState (dsp/psg/ym2612), and the disasm/RE engine — disassemble (raw/ROM/rebuildable-project), plus the Rizin/Ghidra ops disasm({target:'cfg'|'xrefs'|'functions'|'decompile'}) (control-flow graphs, deep xrefs, auto-detected functions, and C pseudocode — all 14 platforms) and symbols({op:'analyze'}) (one-shot structural map).",
158
+ description: "Cross-platform debugging + reverse-engineering: inspectSprites, inspectPalette, getCPUState (main/spc700/z80, plus the 3D CPUs: MIPS R3000/R4300, SH-4), getAudioState (dsp/psg/ym2612/… + spu/ai/aica for PS1/N64/Dreamcast), and the disasm/RE engine — disassemble (raw/ROM/rebuildable-project), plus the Rizin/Ghidra ops disasm({target:'cfg'|'xrefs'|'functions'|'decompile'}) (control-flow graphs, deep xrefs, auto-detected functions, and C pseudocode) and symbols({op:'analyze'}) (one-shot structural map).",
159
159
  useWhen: ["sprites rendering wrong", "audio silent or distorted", "CPU stuck in unknown state", "need to read what existing ROM bytes do", "reverse-engineering an unknown ROM — carve its functions/structure before labeling them live", "want C-like pseudocode to understand a routine"],
160
160
  register: (s, z, k) => {
161
161
  registerPlatformSpecificTools(s, z, k); // inspectSprites/Palette, getCPUState, getDspState, ...
@@ -70,7 +70,7 @@ export async function listPlatformDocsCore({ platform }) {
70
70
  docs,
71
71
  note: docs.length === 0
72
72
  ? `No docs shipped for '${platform}' yet. Try a different platform, or fork an example game (examples({op:'fork'})) for boilerplate. (For RE/patching workflow, see platform({op:'doc', platform:'romhacking', name:'playbook'}).)`
73
- : `Call platform({op:'doc', platform, name}) to read one. 'name' is 'mental_model' or 'troubleshooting'. For RE/patching workflow across platforms, see platform({op:'doc', platform:'romhacking', name:'playbook'}).`,
73
+ : `Call platform({op:'doc', platform, name}) to read one. 'name' is 'mental_model', 'troubleshooting', or 'upstream_sources' (whichever this platform ships — see the docs[] list above). For RE/patching workflow across platforms, see platform({op:'doc', platform:'romhacking', name:'playbook'}).`,
74
74
  };
75
75
  }
76
76