romdevtools 0.44.0 → 0.70.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 (71) hide show
  1. package/CHANGELOG.md +572 -0
  2. package/README.md +9 -7
  3. package/examples/dreamcast/hello/main.c +24 -0
  4. package/examples/n64/platformer/main.c +158 -0
  5. package/examples/n64/puzzle/main.c +117 -0
  6. package/examples/n64/racing/main.c +147 -0
  7. package/examples/n64/shmup/main.c +122 -0
  8. package/examples/n64/sports/main.c +127 -0
  9. package/examples/ps1/platformer/main.c +158 -0
  10. package/examples/ps1/puzzle/main.c +125 -0
  11. package/examples/ps1/racing/main.c +147 -0
  12. package/examples/ps1/shmup/main.c +192 -0
  13. package/examples/ps1/sports/main.c +127 -0
  14. package/examples/ps1/sprite_move/main.c +38 -0
  15. package/package.json +11 -2
  16. package/src/analysis/analyze.js +224 -29
  17. package/src/analysis/decompile.js +7 -0
  18. package/src/analysis/decompiler/sleigh/mips.ldefs +25 -0
  19. package/src/analysis/decompiler/sleigh/mips32.pspec +78 -0
  20. package/src/analysis/decompiler/sleigh/mips32be.cspec +107 -0
  21. package/src/analysis/decompiler/sleigh/mips32be.sla +211162 -0
  22. package/src/analysis/decompiler/sleigh/mips32le.cspec +106 -0
  23. package/src/analysis/decompiler/sleigh/mips32le.sla +210624 -0
  24. package/src/analysis/rizin.js +24 -1
  25. package/src/cores/capabilities.js +122 -2
  26. package/src/cores/registry.js +17 -0
  27. package/src/host/LibretroGL.js +273 -0
  28. package/src/host/LibretroGLBridge.js +836 -0
  29. package/src/host/LibretroHost.js +203 -3
  30. package/src/host/callbacks.js +22 -2
  31. package/src/host/coreLoader.js +65 -5
  32. package/src/host/cpu-state.js +27 -0
  33. package/src/host/framebuffer.js +14 -1
  34. package/src/host/glOptionalDep.js +60 -0
  35. package/src/host/n64-ai-state.js +43 -0
  36. package/src/host/ps1-spu-state.js +65 -0
  37. package/src/host/retroConstants.js +14 -0
  38. package/src/mcp/tools/disasm.js +2 -0
  39. package/src/mcp/tools/frame.js +18 -9
  40. package/src/mcp/tools/lifecycle.js +1 -1
  41. package/src/mcp/tools/platform-tools.js +20 -4
  42. package/src/mcp/tools/platforms.js +38 -4
  43. package/src/mcp/tools/project.js +116 -0
  44. package/src/mcp/tools/rendering-context.js +2 -1
  45. package/src/mcp/tools/toolchain.js +1 -1
  46. package/src/platforms/n64/lib/c/n64.c +196 -0
  47. package/src/platforms/n64/lib/c/n64.h +68 -0
  48. package/src/platforms/ps1/lib/c/psx.c +200 -0
  49. package/src/platforms/ps1/lib/c/psx.h +83 -0
  50. package/src/toolchains/index.js +65 -0
  51. package/src/toolchains/mips-c/lib/be/libc.a +0 -0
  52. package/src/toolchains/mips-c/lib/be/libgcc.a +0 -0
  53. package/src/toolchains/mips-c/lib/be/libm.a +0 -0
  54. package/src/toolchains/mips-c/lib/el/libc.a +0 -0
  55. package/src/toolchains/mips-c/lib/el/libm.a +0 -0
  56. package/src/toolchains/mips-c/lib/n64-crt0.s +21 -0
  57. package/src/toolchains/mips-c/lib/n64-ipl3.s +30 -0
  58. package/src/toolchains/mips-c/lib/n64.ld +15 -0
  59. package/src/toolchains/mips-c/lib/ps1-crt0.s +20 -0
  60. package/src/toolchains/mips-c/lib/ps1.ld +15 -0
  61. package/src/toolchains/mips-c/lib/softint.c +37 -0
  62. package/src/toolchains/mips-c/mips-c.js +155 -0
  63. package/src/toolchains/mips-elf-gcc/gcc.js +130 -0
  64. package/src/toolchains/sh-c/lib/dc-crt0.s +23 -0
  65. package/src/toolchains/sh-c/lib/dc.h +102 -0
  66. package/src/toolchains/sh-c/lib/dc.ld +11 -0
  67. package/src/toolchains/sh-c/lib/libc.a +0 -0
  68. package/src/toolchains/sh-c/lib/libgcc.a +0 -0
  69. package/src/toolchains/sh-c/lib/libm.a +0 -0
  70. package/src/toolchains/sh-c/sh-c.js +101 -0
  71. 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
@@ -49,6 +60,30 @@ const PLATFORM_CORE_OPTIONS = {
49
60
  // host's port-1 input to pad slot 2 — PCE 2P works (probed 2026-06-10
50
61
  // during the ZENITH BARRAGE gold round).
51
62
  pce: { geargrafx_turbotap: "Enabled" },
63
+ // parallel_n64 defaults to a software gfx plugin that never presents to our GL
64
+ // FBO. Force glide64 (the GL renderer) so HW render actually produces frames.
65
+ n64: { "parallel-n64-gfxplugin": "glide64" },
66
+ // beetle_psx_hw defaults to the software renderer; force hardware_gl so it
67
+ // renders through the GL context the host set up (otherwise no FBO frames).
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
+ },
52
87
  // VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
53
88
  // `READY.` prompt — the agent would see a blue boot screen, not the game. Force
54
89
  // autostart so a disk/tape image runs the first program automatically (same as
@@ -155,7 +190,7 @@ export const PLATFORM_VIRTUAL_EXT = {
155
190
  pce: ".pce",
156
191
  msx: ".rom",
157
192
  };
158
- import { RETRO_DEVICE_JOYPAD } from "./retroConstants.js";
193
+ import { RETRO_DEVICE_JOYPAD, ROMDEV_PIXEL_FORMAT_RGBA8888 } from "./retroConstants.js";
159
194
 
160
195
  // C64 controller→keyboard map (the Batocera/RetroDeck model: a CONTROLLER alone
161
196
  // plays C64 — no physical keyboard needed — by mapping spare buttons/stick to
@@ -241,12 +276,74 @@ export class LibretroHost {
241
276
  * Load a libretro core module. Registers callbacks then runs retro_init.
242
277
  * @param {string} jsPath
243
278
  * @param {string} [wasmPath]
279
+ * @param {Object} [opts]
280
+ * @param {boolean} [opts.hwRender] this core HW-renders (GL) — n64/ps1. Loads the
281
+ * optional native GL stack and creates a headless context BEFORE the core boots
282
+ * (GL calls happen during init/load). The 14 software cores omit this.
244
283
  */
245
- async loadCore(jsPath, wasmPath) {
284
+ /** Infer the platform from a core's js filename stem (e.g.
285
+ * ".../parallel_n64_libretro.js" → "n64"), so loadCore can pre-seed that
286
+ * platform's option overrides without the caller threading the platform
287
+ * through. Covers the cores whose boot-time renderer choice depends on an
288
+ * option; returns null otherwise (the overrides are then a no-op anyway). */
289
+ _platformForCore(jsPath) {
290
+ const stem = (jsPath.split("/").pop() || "").replace(/_libretro\.js$/, "");
291
+ return CORE_STEM_TO_PLATFORM[stem] ?? null;
292
+ }
293
+
294
+ async loadCore(jsPath, wasmPath, opts = {}) {
246
295
  if (this.mod) throw new Error("core already loaded; create a new host");
247
- const mod = await loadLibretroCore({ jsPath, wasmPath });
296
+
297
+ let glCanvas = null;
298
+ if (opts.hwRender) {
299
+ // Set up the HW-render path: LibretroGL owns the FBO/readback; a webgl-node
300
+ // mock canvas backs the minified core's GLctx. Both pull the OPTIONAL native
301
+ // GL deps lazily (clear install hint if absent).
302
+ const { LibretroGL } = await import("./LibretroGL.js");
303
+ const { loadWebGl2Context } = await import("./glOptionalDep.js");
304
+ this.hwRender = new LibretroGL();
305
+ await this.hwRender.ensureGl();
306
+ const createWebGL2Context = await loadWebGl2Context();
307
+ const { canvas } = createWebGL2Context(640, 480);
308
+ // Skip Emscripten's Safari WebGL2 workaround (breaks instanceof) by
309
+ // pre-setting the flag it checks.
310
+ canvas.getContextSafariWebGL2Fixed = canvas.getContext;
311
+ glCanvas = canvas;
312
+ this._glContextCreated = true;
313
+ // The env callback routes SET_HW_RENDER to this handler.
314
+ this.state.hwRender = this.hwRender;
315
+ }
316
+
317
+ const mod = await loadLibretroCore({ jsPath, wasmPath, glCanvas });
248
318
  this.mod = mod;
319
+
320
+ // Canvas HW-render cores: initialize the Emscripten GL context so `GLctx` is
321
+ // set BEFORE any GL call (the core's context_reset / first getParameter). Must
322
+ // run before _retro_init.
323
+ if (glCanvas && mod.GL) {
324
+ const handle = mod.GL.createContext(glCanvas, { majorVersion: 2 });
325
+ if (handle > 0) mod.GL.makeContextCurrent(handle);
326
+ }
327
+
249
328
  registerCallbacks({ mod, state: this.state, log: this.log });
329
+
330
+ // Pre-seed per-platform core-option overrides BEFORE _retro_init. The core
331
+ // registers its variables (SET_VARIABLES) during retro_init and decides its
332
+ // renderer THEN — e.g. parallel_n64 reads `parallel-n64-gfxplugin` to pick
333
+ // glide64 (GL/HW-render) vs angrylion (software). Seeding the override here
334
+ // (not in loadMedia, which is too late) makes the SET_VARIABLES handler keep
335
+ // our value instead of the core's default, so HW render engages from boot.
336
+ const platform = opts.platform ?? this._platformForCore(jsPath);
337
+ if (platform) {
338
+ const overrides = PLATFORM_CORE_OPTIONS[platform];
339
+ if (overrides) {
340
+ for (const [key, value] of Object.entries(overrides)) {
341
+ this.state.coreVariables.set(key, { value });
342
+ }
343
+ this.state.variablesUpdated = true;
344
+ }
345
+ }
346
+
250
347
  mod._retro_init();
251
348
  this.status.corePath = jsPath;
252
349
  }
@@ -430,8 +527,19 @@ export class LibretroHost {
430
527
  : this.status.fbWidth / this.status.fbHeight;
431
528
  // timing.sample_rate is at offset +32 (double).
432
529
  this.status.audioSampleRate = mod.getValue(avInfoPtr + 32, "double");
530
+ // max_width/max_height (+8/+12) bound the FBO for HW-render cores (the core may
531
+ // render larger than base, e.g. N64 hi-res or PS1 internal upscale).
532
+ const maxW = mod.getValue(avInfoPtr + 8, "i32") || this.status.fbWidth;
533
+ const maxH = mod.getValue(avInfoPtr + 12, "i32") || this.status.fbHeight;
433
534
  mod._free(avInfoPtr);
434
535
 
536
+ // HW render: now that AV info is known, create the FBO the core renders into.
537
+ // createContext() also fires the core's context_reset (via dynCall) so it
538
+ // (re)builds its GL resources. The EGL context was already created in loadCore.
539
+ if (this.hwRender?.active) {
540
+ this.hwRender.createContext(maxW, maxH, this._glContextCreated);
541
+ }
542
+
435
543
  // Configure controller port 0 as joypad (some cores default to NONE).
436
544
  mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
437
545
  // Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
@@ -480,6 +588,7 @@ export class LibretroHost {
480
588
  let firstRefreshAt = -1;
481
589
  while (stepped < MAX_SETTLE) {
482
590
  mod._retro_run();
591
+ this._afterRun();
483
592
  stepped++;
484
593
  if (firstRefreshAt < 0 && this.state.lastFrame && this.state.lastFrame !== beforeRefreshSnapshot) {
485
594
  firstRefreshAt = stepped;
@@ -520,6 +629,7 @@ export class LibretroHost {
520
629
  if (this.status.paused) return 0;
521
630
  for (let i = 0; i < n; i++) {
522
631
  mod._retro_run();
632
+ this._afterRun();
523
633
  this.status.frameCount++;
524
634
  }
525
635
  if (this.state.lastFrame) {
@@ -529,6 +639,25 @@ export class LibretroHost {
529
639
  return n;
530
640
  }
531
641
 
642
+ /** Post-_retro_run hook: HW-render readback. The video callback set
643
+ * state.hwFramePending during run; now (GL state stable) read back the FBO into
644
+ * state.lastFrame as an RGBA frame. No-op for the 14 software cores. */
645
+ _afterRun() {
646
+ if (this.state.hwFramePending && this.hwRender?.active) {
647
+ this.state.hwFramePending = false;
648
+ // Crop the GL FBO to the core's reported active resolution (e.g. DC 640x480 in an
649
+ // 853x853 FBO) so screenshots don't carry the dead viewport border.
650
+ const frame = this.hwRender.readbackFrame(this.state.hwFrameW, this.state.hwFrameH);
651
+ if (frame) {
652
+ this.state.lastFrame = {
653
+ width: frame.width, height: frame.height,
654
+ pitch: frame.width * 4, format: ROMDEV_PIXEL_FORMAT_RGBA8888,
655
+ pixels: frame.pixels, rgba: true,
656
+ };
657
+ }
658
+ }
659
+ }
660
+
532
661
  /** Run exactly ONE frame to refresh the framebuffer, even while paused — for a
533
662
  * deterministic "restore → screenshot" without un-pausing (so the real-time
534
663
  * playtest loop can't race). Advances the (monotonic) frame counter by 1.
@@ -537,6 +666,7 @@ export class LibretroHost {
537
666
  const mod = this._needMod();
538
667
  this._needMedia();
539
668
  mod._retro_run();
669
+ this._afterRun();
540
670
  this.status.frameCount++;
541
671
  if (this.state.lastFrame) {
542
672
  this.status.fbWidth = this.state.lastFrame.width;
@@ -821,6 +951,19 @@ export class LibretroHost {
821
951
 
822
952
  readMemory(region, offset, length) {
823
953
  const mod = this._needMod();
954
+ // PS1 GPU VRAM (1024×512×16bpp = 1MB) isn't on the RETRO_MEMORY interface; the
955
+ // rebuilt pcsx core exposes it via romdev_vram_get. Serve video_ram from there.
956
+ if (region === "video_ram" && typeof mod._romdev_vram_get === "function") {
957
+ const SIZE = 1024 * 512 * 2;
958
+ if (offset < 0 || offset + length > SIZE) {
959
+ throw new RangeError(`read out of bounds: offset=${offset} len=${length} size=${SIZE}`);
960
+ }
961
+ const p = mod._malloc(SIZE);
962
+ try {
963
+ mod._romdev_vram_get(p, SIZE / 2);
964
+ return new Uint8Array(mod.HEAPU8.buffer, p + offset, length).slice();
965
+ } finally { mod._free(p); }
966
+ }
824
967
  const id = MemoryRegionToRetro[region];
825
968
  if (id === undefined) throw new Error(this._unknownRegionError(region));
826
969
  const ptr = mod._retro_get_memory_data(id);
@@ -1380,6 +1523,63 @@ export class LibretroHost {
1380
1523
  * instruction (ARM: its pipeline PC, the same convention breakpoint
1381
1524
  * addresses use). Pass clear to reset the kind.
1382
1525
  */
1526
+ /** True when the loaded MIPS core (n64/ps1) exposes the live R3000/R4300 register
1527
+ * snapshot (the cheat+regsnap-enabled romdev build). */
1528
+ mipsRegsSupported() {
1529
+ return !!(this.mod && typeof this.mod._romdev_mips_regs_get === "function");
1530
+ }
1531
+
1532
+ /** Read the live MIPS register file: 32 GPRs + LO + HI + PC, as a Uint32Array(35).
1533
+ * null if the core doesn't expose it (older build). */
1534
+ getMipsRegs() {
1535
+ const mod = this.mod;
1536
+ if (!mod || typeof mod._romdev_mips_regs_get !== "function") return null;
1537
+ const ptr = mod._malloc(35 * 4);
1538
+ try {
1539
+ mod._romdev_mips_regs_get(ptr);
1540
+ return new Uint32Array(mod.HEAPU8.buffer, ptr, 35).slice();
1541
+ } finally {
1542
+ mod._free(ptr);
1543
+ }
1544
+ }
1545
+
1546
+ /** True when the PS1 core exposes the SPU register block (getAudioState chip:'spu'). */
1547
+ spuRegsSupported() {
1548
+ return !!(this.mod && typeof this.mod._romdev_spu_get === "function");
1549
+ }
1550
+
1551
+ /** True when the N64 core exposes the AI registers (getAudioState chip:'ai'). */
1552
+ aiRegsSupported() {
1553
+ return !!(this.mod && typeof this.mod._romdev_ai_get === "function");
1554
+ }
1555
+
1556
+ /** Read the N64 AI registers + VI clock as a Uint32Array(7). null if absent. */
1557
+ getAiRegs() {
1558
+ const mod = this.mod;
1559
+ if (!mod || typeof mod._romdev_ai_get !== "function") return null;
1560
+ const ptr = mod._malloc(7 * 4);
1561
+ try {
1562
+ mod._romdev_ai_get(ptr);
1563
+ return new Uint32Array(mod.HEAPU8.buffer, ptr, 7).slice();
1564
+ } finally {
1565
+ mod._free(ptr);
1566
+ }
1567
+ }
1568
+
1569
+ /** Read the PS1 SPU's 0x400-word register block as a Uint16Array(1024). null if
1570
+ * the core doesn't expose it. */
1571
+ getSpuRegs() {
1572
+ const mod = this.mod;
1573
+ if (!mod || typeof mod._romdev_spu_get !== "function") return null;
1574
+ const ptr = mod._malloc(0x400 * 2);
1575
+ try {
1576
+ mod._romdev_spu_get(ptr, 0x400);
1577
+ return new Uint16Array(mod.HEAPU8.buffer, ptr, 0x400).slice();
1578
+ } finally {
1579
+ mod._free(ptr);
1580
+ }
1581
+ }
1582
+
1383
1583
  getRegSnapshot(clear = false) {
1384
1584
  const mod = this.mod;
1385
1585
  if (!mod || typeof mod._romdev_regsnap_get !== "function") return null;
@@ -164,7 +164,21 @@ export function registerCallbacks(args) {
164
164
  mod._retro_set_environment(envCb);
165
165
 
166
166
  const videoCb = mod.addFunction((dataPtr, w, h, pitch) => {
167
- if (dataPtr === 0 || dataPtr === RETRO_HW_FRAME_BUFFER_VALID) return;
167
+ // HW-render path: dataPtr == RETRO_HW_FRAME_BUFFER_VALID means "the frame is in
168
+ // the GL framebuffer." The WASM passes the i32 as SIGNED -1; the constant is the
169
+ // UNSIGNED 0xFFFFFFFF — coerce with >>>0 so both match. DON'T read back here —
170
+ // GL state is only stable AFTER _retro_run returns. Flag it; stepFrames does the
171
+ // glReadPixels post-run.
172
+ const uPtr = dataPtr >>> 0;
173
+ if (uPtr === RETRO_HW_FRAME_BUFFER_VALID && state.hwRender?.active) {
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; }
179
+ return;
180
+ }
181
+ if (dataPtr === 0 || uPtr === RETRO_HW_FRAME_BUFFER_VALID) return;
168
182
  const bytes = h * pitch;
169
183
  const view = mod.HEAPU8.subarray(dataPtr, dataPtr + bytes);
170
184
  state.lastFrame = {
@@ -429,8 +443,14 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
429
443
  case E.SET_PROC_ADDRESS_CALLBACK:
430
444
  return true;
431
445
 
446
+ // HW render (n64/ps1): if the host set up a GL handler (state.hwRender), let it
447
+ // claim the SET_HW_RENDER request + install its callbacks. Without it (the 14
448
+ // software cores, or no GL stack), reject so the core falls back to software.
449
+ case E.SET_HW_RENDER:
450
+ if (state.hwRender) return state.hwRender.setup(mod, dataPtr);
451
+ return false;
452
+
432
453
  // Things we can't or don't want to support — reject so core falls back.
433
- case E.SET_HW_RENDER: // no GL in headless mode
434
454
  case E.GET_RUMBLE_INTERFACE:
435
455
  case E.GET_SENSOR_INTERFACE:
436
456
  case E.GET_CAMERA_INTERFACE:
@@ -14,6 +14,10 @@ import { RETRO_API_VERSION } from "./retroConstants.js";
14
14
  * @typedef {Object} LoadCoreArgs
15
15
  * @property {string} jsPath absolute path to the `_libretro.js` glue
16
16
  * @property {string} [wasmPath] absolute path to the `.wasm`; if omitted, Emscripten resolves next to the .js
17
+ * @property {object} [glCanvas] HW-render cores (n64/ps1): a webgl-node mock canvas the
18
+ * minified Emscripten glue drives via `GLctx = canvas.getContext('webgl2')`.
19
+ * @property {object} [glBridge] HW-render cores with UNMINIFIED imports: a map of GL
20
+ * function name → native-gles impl, patched directly into the WASM env imports.
17
21
  */
18
22
 
19
23
  /**
@@ -22,7 +26,7 @@ import { RETRO_API_VERSION } from "./retroConstants.js";
22
26
  * @param {LoadCoreArgs} args
23
27
  */
24
28
  export async function loadLibretroCore(args) {
25
- const { jsPath, wasmPath } = args;
29
+ const { jsPath, wasmPath, glCanvas, glBridge } = args;
26
30
 
27
31
  const url = pathToFileURL(jsPath).href + "?t=" + Date.now();
28
32
  const ns = await import(url);
@@ -33,12 +37,68 @@ export async function loadLibretroCore(args) {
33
37
 
34
38
  /** @type {Record<string, unknown>} */
35
39
  const opts = {
36
- noInitialRun: true,
37
- print: () => {},
38
- 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) : () => {},
39
42
  };
40
- if (wasmPath) {
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;
49
+
50
+ // HW-render cores: minified glue uses GLctx = canvas.getContext('webgl2'), so we
51
+ // hand it a webgl-node mock canvas + install the WebGL2 globals Emscripten probes.
52
+ if (glCanvas) {
53
+ opts.canvas = glCanvas;
54
+ if (typeof globalThis.WebGL2RenderingContext === "undefined") {
55
+ const { WebGL2RenderingContext } = await import("webgl-node");
56
+ globalThis.WebGL2RenderingContext = WebGL2RenderingContext;
57
+ // CRITICAL: WebGLRenderingContext (WebGL1) must be a DISTINCT class from
58
+ // WebGL2RenderingContext. Emscripten's getContext shim does
59
+ // return (ver=="webgl") == (gl instanceof WebGLRenderingContext) ? gl : null
60
+ // For ver="webgl2" that needs `gl instanceof WebGLRenderingContext` to be
61
+ // FALSE. If we aliased WebGLRenderingContext to the WebGL2 class, a webgl2
62
+ // context IS instanceof it → the shim returns null → GLctx never set → the
63
+ // core renders nothing. So WebGL1 gets its own empty marker class.
64
+ globalThis.WebGLRenderingContext = class WebGLRenderingContext {};
65
+ }
66
+ }
67
+
68
+ if (wasmPath && glBridge) {
69
+ // Unminified GL cores: patch GL function imports + no-op EGL (we own EGL via
70
+ // native-gles), and capture the instance memory for the bridge's marshaling.
71
+ const wasmBinary = await readFile(wasmPath);
72
+ opts.instantiateWasm = (info, receiveInstance) => {
73
+ for (const nsObj of Object.values(info)) {
74
+ if (typeof nsObj !== "object" || nsObj === null) continue;
75
+ for (const [name, fn] of Object.entries(glBridge)) {
76
+ if (name in nsObj) nsObj[name] = fn;
77
+ }
78
+ if ("eglGetDisplay" in nsObj) {
79
+ nsObj.eglGetDisplay = () => 62000;
80
+ nsObj.eglInitialize = () => 1;
81
+ nsObj.eglQueryString = () => 0;
82
+ }
83
+ }
84
+ WebAssembly.instantiate(wasmBinary, info).then((result) => {
85
+ if (glBridge._setMemory && result.instance.exports.memory) {
86
+ glBridge._setMemory(result.instance.exports.memory);
87
+ }
88
+ receiveInstance(result.instance, result.module);
89
+ });
90
+ return {};
91
+ };
92
+ } else if (wasmPath && !glCanvas) {
41
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;
42
102
  }
43
103
 
44
104
  const mod = await factory(opts);
@@ -139,6 +139,30 @@ function decodeSnes9xSPC(state) {
139
139
  * @param {Uint8Array} bytes 28-byte snapshot
140
140
  * @returns {ReturnType<typeof formatCpuState> | null}
141
141
  */
142
+ /** MIPS R3000 (PS1) / R4300 (N64) register names, $0..$31. */
143
+ const MIPS_GPR_NAMES = [
144
+ "zero", "at", "v0", "v1", "a0", "a1", "a2", "a3",
145
+ "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7",
146
+ "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7",
147
+ "t8", "t9", "k0", "k1", "gp", "sp", "fp", "ra",
148
+ ];
149
+
150
+ /** Decode the romdev MIPS regsnap (Uint32Array(35): 32 GPRs + LO + HI + PC) into a
151
+ * CPUState. Names follow the MIPS o32 ABI convention. */
152
+ function decodeMips(regs) {
153
+ if (!regs || regs.length < 35) return null;
154
+ const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase().padStart(8, "0");
155
+ const named = {};
156
+ for (let i = 0; i < 32; i++) named[MIPS_GPR_NAMES[i]] = hx(regs[i]);
157
+ named.lo = hx(regs[32]);
158
+ named.hi = hx(regs[33]);
159
+ return {
160
+ pc: regs[34] >>> 0,
161
+ pcHex: hx(regs[34]),
162
+ registers: named,
163
+ };
164
+ }
165
+
142
166
  function decode6502(bytes) {
143
167
  if (!bytes || bytes.length < 25) return null;
144
168
  const u16le = (o) => bytes[o] | (bytes[o + 1] << 8);
@@ -300,6 +324,9 @@ function decodeZ80(bytes) {
300
324
  * @returns {CPUState | null}
301
325
  */
302
326
  export function getCPUState(host, platform, cpu = "main") {
327
+ if (platform === "n64" || platform === "ps1") {
328
+ return decodeMips(host.getMipsRegs?.());
329
+ }
303
330
  if (platform === "nes") {
304
331
  const bytes = host.readMemory("nes_cpu_regs", 0, 28);
305
332
  return decode6502(bytes);
@@ -12,6 +12,7 @@ import {
12
12
  RETRO_PIXEL_FORMAT_0RGB1555,
13
13
  RETRO_PIXEL_FORMAT_RGB565,
14
14
  RETRO_PIXEL_FORMAT_XRGB8888,
15
+ ROMDEV_PIXEL_FORMAT_RGBA8888,
15
16
  } from "./retroConstants.js";
16
17
 
17
18
  /**
@@ -35,7 +36,19 @@ export function framebufferToRgba(width, height, src, pitch, format) {
35
36
  }
36
37
 
37
38
  function decodePixelsInto(dst, width, height, src, pitch, format) {
38
- if (format === RETRO_PIXEL_FORMAT_XRGB8888) {
39
+ if (format === ROMDEV_PIXEL_FORMAT_RGBA8888) {
40
+ // HW-render readback: already RGBA. Copy RGB row-by-row but FORCE alpha=255 —
41
+ // the N64/PS1 GL framebuffer leaves alpha at 0 (it's the render target's unused
42
+ // channel), which would make every pixel transparent → composites to white.
43
+ for (let y = 0; y < height; y++) {
44
+ const sRow = y * pitch, dRow = y * width * 4;
45
+ for (let x = 0; x < width; x++) {
46
+ const s = sRow + x * 4, d = dRow + x * 4;
47
+ dst[d] = src[s]; dst[d + 1] = src[s + 1]; dst[d + 2] = src[s + 2];
48
+ dst[d + 3] = 0xff;
49
+ }
50
+ }
51
+ } else if (format === RETRO_PIXEL_FORMAT_XRGB8888) {
39
52
  for (let y = 0; y < height; y++) {
40
53
  const srcRow = y * pitch;
41
54
  const dstRow = y * width * 4;
@@ -0,0 +1,60 @@
1
+ // glOptionalDep.js — lazy, optional loader for the native GL stack.
2
+ //
3
+ // The HW-render cores (N64 ParaLLEl, PS1 Beetle-PSX) need a real headless GL
4
+ // context. romdev provides it through `native-gles` (EGL/GLES via a prebuilt
5
+ // .node) and `webgl-node` (a WebGL2 context object for the minified Emscripten
6
+ // cores). Both are OPTIONAL dependencies: the 14 software-rendered cores never
7
+ // touch them, and a headless user who never boots N64/PS1 doesn't need them
8
+ // installed. We load them lazily and, if absent, throw ONE clear, actionable
9
+ // error instead of a cryptic module-not-found.
10
+
11
+ let _gl = null;
12
+ let _webgl = null;
13
+
14
+ const INSTALL_HINT =
15
+ "N64/PS1 emulation needs the optional native GL module. Install it with:\n" +
16
+ " npm install native-gles webgl-node\n" +
17
+ "(the other 14 platforms are software-rendered and don't require it).";
18
+
19
+ /**
20
+ * Load the `native-gles` default export (the EGL/GLES binding). Cached.
21
+ * @returns {Promise<object>} the native-gles module
22
+ * @throws {Error} a clear install hint if the module isn't available
23
+ */
24
+ export async function loadNativeGles() {
25
+ if (_gl) return _gl;
26
+ try {
27
+ _gl = (await import("native-gles")).default;
28
+ } catch (e) {
29
+ throw new Error(`${INSTALL_HINT}\n(underlying: ${e.message})`);
30
+ }
31
+ if (!_gl || typeof _gl.createContext !== "function") {
32
+ throw new Error(`native-gles loaded but has no createContext — ${INSTALL_HINT}`);
33
+ }
34
+ return _gl;
35
+ }
36
+
37
+ /**
38
+ * Load `webgl-node`'s createWebGL2Context (the canvas/context the minified
39
+ * Emscripten cores use via GLctx = canvas.getContext('webgl2')). Cached.
40
+ * @returns {Promise<Function>} createWebGL2Context(width, height) → { canvas, gl }
41
+ * @throws {Error} a clear install hint if the module isn't available
42
+ */
43
+ export async function loadWebGl2Context() {
44
+ if (_webgl) return _webgl;
45
+ try {
46
+ const m = await import("webgl-node");
47
+ _webgl = m.createWebGL2Context;
48
+ } catch (e) {
49
+ throw new Error(`${INSTALL_HINT}\n(underlying: ${e.message})`);
50
+ }
51
+ if (typeof _webgl !== "function") {
52
+ throw new Error(`webgl-node loaded but has no createWebGL2Context — ${INSTALL_HINT}`);
53
+ }
54
+ return _webgl;
55
+ }
56
+
57
+ /** Best-effort probe: is the native GL stack importable? (for capability flags) */
58
+ export async function glStackAvailable() {
59
+ try { await loadNativeGles(); return true; } catch { return false; }
60
+ }
@@ -0,0 +1,43 @@
1
+ // N64 AI (Audio Interface) decoder.
2
+ //
3
+ // Data source: the romdev_ai_get export (parallel_n64) — the 6 AI registers + the
4
+ // VI clock. The N64 has no per-voice sound chip like the SNES DSP; audio is mixed
5
+ // by the RSP (microcode-dependent) and streamed to the AI's DAC via DMA. So the
6
+ // AI tells us the OUTPUT state: sample rate, whether audio is playing (a buffer is
7
+ // queued), and the DMA source address — the actionable "is sound on, at what rate"
8
+ // answer, not a per-channel breakdown (which lives in game-specific RSP audio lists).
9
+ //
10
+ // out layout: [DRAM_ADDR, LEN, CONTROL, STATUS, DACRATE, BITRATE, VI_CLOCK]
11
+
12
+ /**
13
+ * @param {Uint32Array} a the 7-word AI snapshot (6 regs + VI clock)
14
+ * @returns {{ chip:"ai", playing:boolean, sampleRate:number, dmaAddress:number,
15
+ * queuedBytes:number, dacRate:number, bitRate:number, control:number,
16
+ * status:number, note:string }}
17
+ */
18
+ export function decodeN64Ai(a) {
19
+ if (!a || a.length < 7) return { chip: "ai", playing: false, note: "no AI data" };
20
+ const dramAddr = a[0] >>> 0;
21
+ const len = a[1] >>> 0;
22
+ const control = a[2] >>> 0;
23
+ const status = a[3] >>> 0;
24
+ const dacRate = a[4] >>> 0;
25
+ const bitRate = a[5] >>> 0;
26
+ const viClock = a[6] >>> 0;
27
+ // DAC sample rate = VI clock / (dacrate + 1). dacrate 0 / no clock → unknown.
28
+ const sampleRate = dacRate && viClock ? Math.round(viClock / (dacRate + 1)) : 0;
29
+ // AI_LEN nonzero (a buffer is queued) AND DMA enabled (control bit0) = playing.
30
+ const playing = (len & 0x3ffff) > 0 && (control & 1) !== 0;
31
+ return {
32
+ chip: "ai",
33
+ playing,
34
+ sampleRate,
35
+ queuedBytes: len & 0x3ffff,
36
+ dmaAddress: "0x" + dramAddr.toString(16).toUpperCase(),
37
+ dacRate,
38
+ bitRate,
39
+ control,
40
+ status,
41
+ note: "N64 audio is RSP-mixed; the AI exposes the OUTPUT (sample rate + whether a buffer is playing + the DMA source), not per-channel voices. For per-channel, trace the game's RSP audio list in RDRAM.",
42
+ };
43
+ }
@@ -0,0 +1,65 @@
1
+ // PS1 SPU (Sound Processing Unit) decoder.
2
+ //
3
+ // Data source: the SPU's 0x400-word register block ($1F801C00-based), exposed by
4
+ // the romdev_spu_get export in the rebuilt pcsx_rearmed core. The block is indexed
5
+ // as 16-bit words; per-voice registers occupy the first 24×8 words (16 bytes each),
6
+ // then the global voice/control registers follow.
7
+ //
8
+ // The SPU has 24 ADPCM voices. Per voice (offset = voice*8 words):
9
+ // +0 VolumeLeft +1 VolumeRight +2 ADPCM SampleRate (pitch)
10
+ // +3 ADPCM StartAddr +4 ADSR lo +5 ADSR hi +6 ADSR CurrentVol +7 RepeatAddr
11
+ // Global (word index):
12
+ // 0xC0/0xC1 = main volume L/R, 0xCC/0xCD = KeyOn (24-bit across two words),
13
+ // 0xCE/0xCF = KeyOff, 0xD6 = SPU control (SPUCNT).
14
+
15
+ const NUM_VOICES = 24;
16
+
17
+ /** SPU pitch → Hz: the 16-bit pitch is in units of 44100/4096 Hz per step
18
+ * (4096 = "1.0" = 44100 Hz). */
19
+ function pitchToHz(pitch) {
20
+ return Math.round((pitch / 4096) * 44100);
21
+ }
22
+
23
+ /**
24
+ * @param {Uint16Array} regs the 0x400-word SPU register block
25
+ * @returns {{ chip:"spu", mainVolumeLeft:number, mainVolumeRight:number,
26
+ * control:number, voices:Array<object> }}
27
+ */
28
+ export function decodePs1Spu(regs) {
29
+ if (!regs || regs.length < 0x200) return { chip: "spu", voices: [] };
30
+ // Key-on / key-off are 24-bit, split across two 16-bit words.
31
+ const keyOn = (regs[0xCC] | (regs[0xCD] << 16)) >>> 0;
32
+ const keyOff = (regs[0xCE] | (regs[0xCF] << 16)) >>> 0;
33
+
34
+ const voices = [];
35
+ for (let v = 0; v < NUM_VOICES; v++) {
36
+ const b = v * 8;
37
+ const volL = regs[b + 0];
38
+ const volR = regs[b + 1];
39
+ const pitch = regs[b + 2];
40
+ const adsr = (regs[b + 4] | (regs[b + 5] << 16)) >>> 0;
41
+ const curVol = regs[b + 6];
42
+ const on = !!(keyOn & (1 << v));
43
+ voices.push({
44
+ voice: v,
45
+ keyOn: on,
46
+ keyOff: !!(keyOff & (1 << v)),
47
+ // signed 15-bit volumes (top bit = sweep mode; report the magnitude)
48
+ volumeLeft: volL & 0x7fff,
49
+ volumeRight: volR & 0x7fff,
50
+ pitch,
51
+ hz: pitch ? pitchToHz(pitch) : 0,
52
+ adsr: "0x" + adsr.toString(16).padStart(8, "0").toUpperCase(),
53
+ currentVolume: curVol,
54
+ active: curVol > 0 || on,
55
+ });
56
+ }
57
+ return {
58
+ chip: "spu",
59
+ mainVolumeLeft: regs[0xC0] & 0x7fff,
60
+ mainVolumeRight: regs[0xC1] & 0x7fff,
61
+ control: regs[0xD6],
62
+ keyOnMask: "0x" + keyOn.toString(16).toUpperCase(),
63
+ voices,
64
+ };
65
+ }