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
@@ -54,6 +54,21 @@ export const RIZIN_ARCH = {
54
54
  // DECOMPILER uses the proper HuC6280 SLEIGH spec — so CFG/xrefs/functions are
55
55
  // approximate on PCE while decompile is accurate.
56
56
  pce: "6502",
57
+ // 32-bit MIPS tier (analysis-first; the MIPS plugin ships in rizin.wasm). PS1 =
58
+ // R3000 little-endian; N64 = R4300 big-endian. Endianness comes from RIZIN_ENDIAN
59
+ // below — same arch, different byte order.
60
+ ps1: "mips",
61
+ n64: "mips",
62
+ dreamcast: "sh", // SH-4 (SuperH) — rizin's `sh` plugin covers it
63
+ };
64
+
65
+ /** Byte order per platform, for shared-arch families that ship both (MIPS). Only
66
+ * platforms NOT matching their arch default need an entry; absent → use the
67
+ * loader/arch default. PS1 is little, N64 is big. Dreamcast SH-4 is little. */
68
+ export const RIZIN_ENDIAN = {
69
+ ps1: "little",
70
+ n64: "big",
71
+ dreamcast: "little",
57
72
  };
58
73
 
59
74
  /**
@@ -77,13 +92,21 @@ export const RIZIN_ARCH = {
77
92
  * @returns {Promise<{exitCode:number, output:string, log:string, crash?:object}>}
78
93
  */
79
94
  export async function runRizin(opts) {
80
- const { romPath, romBytes, commands, arch, bits, baddr, writeable, timeoutMs } = opts;
95
+ const { romPath, romBytes, commands, arch, bits, baddr, writeable, timeoutMs, endian } = opts;
81
96
  if (!commands) throw new Error("runRizin: commands required");
82
97
  const bytes = romBytes ?? new Uint8Array(await readFile(romPath));
83
98
 
84
99
  const pre = ["e scr.color=0", "e scr.interactive=false", "e scr.prompt=false"];
85
100
  if (arch) pre.push(`e asm.arch=${arch}`);
86
101
  if (bits) pre.push(`e asm.bits=${bits}`);
102
+ // Endianness matters for shared-arch families that ship both byte orders — most
103
+ // pressingly MIPS: PS1 (R3000) is little-endian, N64 (R4300) is big-endian, same
104
+ // `mips` plugin. Set both asm + cfg so the disasm AND the analysis loader agree.
105
+ if (endian === "big" || endian === "little") {
106
+ // `cfg.bigendian` is the rizin config var (there is NO `asm.bigendian` —
107
+ // setting it errors). cfg.bigendian drives BOTH the disasm and the analysis.
108
+ pre.push(`e cfg.bigendian=${endian === "big" ? "true" : "false"}`);
109
+ }
87
110
 
88
111
  // Split trailing command off so its output (only) goes to the file. Earlier
89
112
  // commands (aaa, config) print nothing we need. Rizin's `cmd > file` writes
@@ -202,10 +202,103 @@ export const CAPABILITIES = {
202
202
  cart: false, disasm: true, decompile: true,
203
203
  },
204
204
  },
205
+
206
+ // ── 32-bit MIPS tier — ANALYSIS-FIRST (no run-side core yet) ──────────────
207
+ // disasm/decompile are wired through the shipped rizin.wasm MIPS plugin (PS1 =
208
+ // R3000 LE, N64 = R4300 BE). Everything run-side (build/run/screenshot/the
209
+ // tile/sprite inspectors) is FALSE: there is no emulator core in this slice, and
210
+ // the tile/nametable/OAM inspectors are MEANINGLESS on a framebuffer (PS1) / 3D
211
+ // (N64) renderer anyway — so an agent gets the uniform unsupported() signal
212
+ // instead of blindly calling inspectBackground on a polygon machine.
213
+ // disasm/cfg/xrefs/functions WORK (rizin's Capstone MIPS plugin). decompile is
214
+ // FALSE: the rz-ghidra decompiler ships only the 8 SLEIGH specs for the current
215
+ // tier (no MIPS.sla) — adding it is a romdev-analysis-decompiler rebuild, a later
216
+ // step. decompileQuality records the EXPECTED quality once that spec ships.
217
+ ps1: {
218
+ cpuFamily: "mips", decompileQuality: "good", tier: "mips",
219
+ cpus: { main: "r3000", secondary: ["gte"] },
220
+ audioChips: ["spu"],
221
+ memoryRegions: [...GENERIC_REGIONS],
222
+ renderingKind: "3d", introspection: "shallow",
223
+ ops: {
224
+ // beetle_psx_hw: the GPU renders on the REAL GPU via the GLES3/WebGL2 hardware
225
+ // renderer through native-gles (like glide64-N64 + Flycast-DC), with OpenBIOS
226
+ // EMBEDDED (PCSX-Redux, MIT, region-free) — no Sony firmware to ship, no BIOS file.
227
+ // run/screenshot + cheats live; disasm + decompile work (MIPS Capstone + the
228
+ // MIPS:LE:32 SLEIGH spec). cpuState/audioDebug are OFF: those need beetle-side
229
+ // romdev_mips_regs_get/romdev_spu_get exports (a future core patch — the old
230
+ // pcsx_rearmed-software build had them, but it has no WebGL2 GPU path). build needs
231
+ // a PS1 toolchain (PSn00bSDK, not yet).
232
+ build: true, run: true, screenshot: true,
233
+ inspectSprites: false, inspectPalette: false, inspectBackground: false,
234
+ renderingContext: false, cpuState: false, audioDebug: false,
235
+ cart: false, disasm: true, decompile: true,
236
+ },
237
+ },
238
+ n64: {
239
+ cpuFamily: "mips", decompileQuality: "good", tier: "mips",
240
+ cpus: { main: "r4300", secondary: ["rsp"] },
241
+ audioChips: ["ai"], // AI = the audio OUTPUT interface (RSP-mixed; no per-voice chip)
242
+ memoryRegions: [...GENERIC_REGIONS],
243
+ renderingKind: "3d", introspection: "shallow",
244
+ ops: {
245
+ // parallel_n64: HW (GL) render via the optional native-gles bridge.
246
+ // run/screenshot + cpuState (R4300 regsnap) + cheats + breakpoint/watch +
247
+ // audioDebug(AI) live; disasm + decompile work; build via mips-elf-gcc.
248
+ // The 3D renderer has no tile/sprite inspectors (N/A by hardware).
249
+ build: true, run: true, screenshot: true,
250
+ inspectSprites: false, inspectPalette: false, inspectBackground: false,
251
+ renderingContext: false, cpuState: true, audioDebug: true,
252
+ cart: false, disasm: true, decompile: true,
253
+ },
254
+ },
255
+
256
+ dreamcast: {
257
+ cpuFamily: "sh", decompileQuality: "good", tier: "sh",
258
+ cpus: { main: "sh4", secondary: ["arm7"] }, // SH-4 main + ARM7 AICA sound CPU
259
+ audioChips: ["aica"],
260
+ memoryRegions: [...GENERIC_REGIONS],
261
+ renderingKind: "3d", introspection: "shallow",
262
+ ops: {
263
+ // Flycast WASM boots + RUNS homebrew .elf (reios HLE): the SH-4 executes guest
264
+ // code (run + memory introspection), and the PowerVR2 present-path works — flycast
265
+ // renders to the GL FBO and the host reads it back (verified: a framebuffer-writing
266
+ // program shows ~727k captured pixels). `build` lands with the sh-elf-gcc WASM
267
+ // toolchain. The 3D renderer has no tile/sprite inspectors (N/A by hw).
268
+ // disasm/decompile = SH-4 analysis slice (rizin `sh` + Ghidra SuperH4 SLEIGH).
269
+ build: true, run: true, screenshot: true,
270
+ inspectSprites: false, inspectPalette: false, inspectBackground: false,
271
+ renderingContext: false, cpuState: false, audioDebug: false,
272
+ cart: false, disasm: true, decompile: true,
273
+ },
274
+ },
205
275
  };
206
276
 
207
- /** All platform ids in the contract. */
208
- export const CONTRACT_PLATFORMS = Object.keys(CAPABILITIES);
277
+ /** The 32-bit MIPS tier (PS1/N64) marked `tier:"mips"`. A PARTIAL tier: they
278
+ * run + screenshot + disasm, but don't yet have the full op surface of the canonical
279
+ * 14 (no build toolchain, no MIPS decompile/cpuState, framebuffer/3D renderers have
280
+ * no tile/sprite inspectors). They're held to their OWN conformance, not the
281
+ * "all 14" cross-checks. They graduate to CONTRACT_PLATFORMS as the gaps close. */
282
+ export const MIPS_TIER_PLATFORMS = Object.entries(CAPABILITIES)
283
+ .filter(([, c]) => c.tier === "mips")
284
+ .map(([p]) => p);
285
+
286
+ /** The next-gen tiers (32-bit CPU families added after the canonical 14): MIPS
287
+ * (PS1/N64) + SH (Dreamcast) + future. Any platform carrying a CPU-family `tier`
288
+ * is held to its OWN conformance, not the "all 14" cross-checks; a new platform
289
+ * starts here (analysis-first) and graduates as run/build/etc. land. */
290
+ export const NEXTGEN_TIER_PLATFORMS = Object.entries(CAPABILITIES)
291
+ .filter(([, c]) => c.tier === "mips" || c.tier === "sh")
292
+ .map(([p]) => p);
293
+
294
+ /** Back-compat: the analysis-only set is now empty (PS1/N64 gained run/screenshot
295
+ * in the run-side wiring). Kept so the name still resolves for older imports. */
296
+ export const ANALYSIS_ONLY_PLATFORMS = [];
297
+
298
+ /** The canonical tier-1 platforms (the 14): full op surface, universal build/run/
299
+ * screenshot. Excludes the partial next-gen tiers above. */
300
+ export const CONTRACT_PLATFORMS = Object.keys(CAPABILITIES)
301
+ .filter((p) => !NEXTGEN_TIER_PLATFORMS.includes(p));
209
302
 
210
303
  /** Does `platform` support `op`? Unknown platform/op → false. */
211
304
  export function supports(platform, op) {
@@ -216,3 +309,30 @@ export function supports(platform, op) {
216
309
  export function capabilitiesFor(platform) {
217
310
  return CAPABILITIES[platform] ?? null;
218
311
  }
312
+
313
+ // Why an op is unsupported, grounded in the HARDWARE — so `unsupported()`'s reason
314
+ // distinguishes "N/A by hardware, permanent" from "a decoder we haven't wired."
315
+ // Keyed on renderingKind: a framebuffer (PS1) / 3D (N64) renderer has no tile,
316
+ // sprite-attribute, nametable, or palette tables for the tile-era inspectors to
317
+ // read — those ops are MEANINGLESS on the hardware, not merely absent.
318
+ const RENDERING_NA = {
319
+ framebuffer: "this platform renders to a flat framebuffer — there are no tile/sprite-attribute/nametable/palette tables to inspect (the GPU draws pixels/polys directly). Read the raw framebuffer via memory({region:'video_ram'}).",
320
+ "3d": "this platform is a 3D (polygon) renderer — there are no tile/sprite-attribute/nametable/palette tables to inspect (geometry is transformed + rasterized, not composed from tile maps). Inspect the scene via memory({region:'system_ram'}) / cpu state.",
321
+ };
322
+ const NA_OPS = new Set(["inspectSprites", "inspectPalette", "inspectBackground", "renderingContext"]);
323
+
324
+ /** A hardware-grounded reason an op is unsupported on a platform, or null if the
325
+ * op isn't one of the hardware-N/A introspection ops. Drives unsupported().reason
326
+ * so agents see "N/A by hardware" (don't retry / don't request a decoder) rather
327
+ * than a generic "no decoder for this platform". */
328
+ export function naReason(platform, op) {
329
+ const cap = CAPABILITIES[platform];
330
+ if (!cap || cap.ops?.[op]) return null; // supported → no N/A reason
331
+ if (op === "cart") {
332
+ return cap.renderingKind === "framebuffer"
333
+ ? "this platform is disc-based (no cartridge ROM to inspect/patch as a cart)."
334
+ : null;
335
+ }
336
+ if (NA_OPS.has(op)) return RENDERING_NA[cap.renderingKind] ?? null;
337
+ return null;
338
+ }
@@ -53,6 +53,23 @@ export const CORES = {
53
53
  c64: { platform: "c64", coreName: "vice_x64", pkg: "romdev-core-vice", displayName: "Commodore 64 (VICE x64)" },
54
54
  pce: { platform: "pce", coreName: "geargrafx", pkg: "romdev-core-geargrafx", displayName: "PC Engine / TurboGrafx-16 (Geargrafx)", aka: "turbografx,tg16,pcengine" },
55
55
  msx: { platform: "msx", coreName: "bluemsx", pkg: "romdev-core-bluemsx", displayName: "MSX / MSX2 (blueMSX)", aka: "msx2" },
56
+ // 32-bit MIPS tier. These cores HW-render (GL): the host lazy-loads the OPTIONAL
57
+ // webgl-node bridge only when one of these boots (hwRender:true). The other 14 are
58
+ // software-rendered and never touch GL, so a headless user without the GPU module
59
+ // is unaffected.
60
+ // parallel_n64 renders the RDP on the REAL GPU through glide64 (GL HLE) → native-gles
61
+ // (the host's WebGL2 bridge), same path as Flycast. The host forces the glide64 plugin
62
+ // via a core option. NOT software RDP (angrylion) — that was the old headless build.
63
+ n64: { platform: "n64", coreName: "parallel_n64", pkg: "romdev-core-parallel-n64", displayName: "Nintendo 64 (ParaLLEl N64, glide64 GL)", hwRender: true },
64
+ // beetle_psx_hw = mednafen PSX with the GLES3/WebGL2 HARDWARE renderer → rendered on
65
+ // the real GPU through native-gles (like glide64-N64 + Flycast-DC). Ships with OpenBIOS
66
+ // EMBEDDED (PCSX-Redux, MIT-licensed, region-free) so there's no copyrighted Sony
67
+ // firmware to ship and no BIOS file to supply — the GPU PS1 path with an open BIOS.
68
+ ps1: { platform: "ps1", coreName: "beetle_psx_hw", pkg: "romdev-core-beetle-psx-hw", displayName: "Sony PlayStation (Beetle PSX HW, OpenBIOS)", aka: "psx,playstation", hwRender: true },
69
+ // Flycast = full Dreamcast emulator, GLES3/WebGL2 HW-render (PowerVR2 is GPU-first,
70
+ // no software framebuffer path) → driven through the native-gles/webgl-node bridge
71
+ // like the GL N64 build. HLE BIOS (reios) on by default — no firmware to ship.
72
+ dreamcast: { platform: "dreamcast", coreName: "flycast", pkg: "romdev-core-flycast", displayName: "Sega Dreamcast (Flycast)", aka: "dc", hwRender: true },
56
73
  };
57
74
 
58
75
  /** Try to get {jsPath,wasmPath} for a core from its binary package. */
@@ -0,0 +1,273 @@
1
+ // LibretroGL.js — Hardware-rendered libretro core support
2
+ //
3
+ // Handles RETRO_ENVIRONMENT_SET_HW_RENDER by creating an EGL context via
4
+ // native-gles and providing the three required callbacks:
5
+ // context_reset, get_current_framebuffer, get_proc_address
6
+ //
7
+ // The core renders to an FBO. After retro_run(), we readback via glReadPixels
8
+ // and send the pixels to VideoOutput (same as wasmcart GL carts).
9
+
10
+ import {
11
+ RETRO_HW_CONTEXT_OPENGLES2,
12
+ RETRO_HW_CONTEXT_OPENGLES3,
13
+ RETRO_HW_CONTEXT_OPENGLES_VERSION,
14
+ RETRO_HW_CONTEXT_OPENGL,
15
+ RETRO_HW_CONTEXT_OPENGL_CORE,
16
+ } from './retroConstants.js';
17
+ import { loadNativeGles } from './glOptionalDep.js';
18
+
19
+ // `native-gles` is an OPTIONAL native dependency — only the HW-render cores
20
+ // (n64/ps1) need it. It's loaded lazily at setup() so the 14 software-rendered
21
+ // cores and headless installs without the GPU module are completely unaffected.
22
+ let gl = null;
23
+
24
+ // retro_hw_render_callback struct offsets (WASM32):
25
+ // +0 i32 context_type
26
+ // +4 ptr context_reset (set by frontend)
27
+ // +8 ptr get_current_framebuffer (set by frontend)
28
+ // +12 ptr get_proc_address (set by frontend)
29
+ // +16 u8 depth
30
+ // +17 u8 stencil
31
+ // +18 u8 bottom_left_origin
32
+ // +20 u32 version_major (aligned)
33
+ // +24 u32 version_minor
34
+ // +28 u8 cache_context
35
+ // +32 ptr context_destroy (set by core)
36
+ // +36 u8 debug_context
37
+
38
+ export class LibretroGL {
39
+ constructor() {
40
+ this.active = false;
41
+ this.contextType = 0;
42
+ this.bottomLeftOrigin = true;
43
+ this.fbo = 0;
44
+ this.fboW = 0;
45
+ this.fboH = 0;
46
+ this.contextResetPtr = 0; // WASM function pointer
47
+ this.contextDestroyPtr = 0; // WASM function pointer
48
+ this._mod = null;
49
+ this._glPixels = null;
50
+ this._flipped = null;
51
+
52
+ // GL function name → addFunction wrapper pointer (cached)
53
+ this._procAddressCache = new Map();
54
+ }
55
+
56
+ /**
57
+ * Load the optional native GL module (lazy). MUST be awaited before the core
58
+ * boots, since the 14 software cores never call this and the module is an
59
+ * optional dependency. Populates the module-level `gl` used by
60
+ * createContext/readbackFrame/destroy.
61
+ * @returns {Promise<void>}
62
+ */
63
+ async ensureGl() {
64
+ if (!gl) gl = await loadNativeGles();
65
+ }
66
+
67
+ /** Make this core's GL context current. Called before each _retro_run so the
68
+ * core renders into OUR FBO (a multi-host server may have switched contexts). */
69
+ makeCurrent() {
70
+ if (this.active && gl) gl.makeCurrent();
71
+ }
72
+
73
+ /**
74
+ * Handle RETRO_ENVIRONMENT_SET_HW_RENDER.
75
+ * Reads the struct, creates EGL context, writes callback pointers back.
76
+ * @param {object} mod - Emscripten module
77
+ * @param {number} dataPtr - pointer to retro_hw_render_callback
78
+ * @returns {boolean} true if HW render is supported
79
+ */
80
+ setup(mod, dataPtr) {
81
+ this._mod = mod;
82
+
83
+ // Read struct fields set by the core
84
+ const contextType = mod.getValue(dataPtr, 'i32');
85
+ const depth = mod.HEAPU8[dataPtr + 16];
86
+ const stencil = mod.HEAPU8[dataPtr + 17];
87
+ this.bottomLeftOrigin = !!mod.HEAPU8[dataPtr + 18];
88
+ const versionMajor = mod.getValue(dataPtr + 20, 'i32');
89
+ const versionMinor = mod.getValue(dataPtr + 24, 'i32');
90
+ this.contextDestroyPtr = mod.getValue(dataPtr + 32, 'i32');
91
+
92
+ // Validate: we only support GL ES contexts
93
+ const supported = [
94
+ RETRO_HW_CONTEXT_OPENGLES2,
95
+ RETRO_HW_CONTEXT_OPENGLES3,
96
+ RETRO_HW_CONTEXT_OPENGLES_VERSION,
97
+ RETRO_HW_CONTEXT_OPENGL,
98
+ RETRO_HW_CONTEXT_OPENGL_CORE,
99
+ ];
100
+ if (!supported.includes(contextType)) {
101
+ console.error(`[libretro-gl] Unsupported HW context type: ${contextType}`);
102
+ return false;
103
+ }
104
+
105
+ this.contextType = contextType;
106
+ console.error(`[libretro-gl] HW render requested: type=${contextType} version=${versionMajor}.${versionMinor} depth=${depth} stencil=${stencil} bottomLeft=${this.bottomLeftOrigin}`);
107
+
108
+ // Create callback wrappers the WASM core can call
109
+
110
+ // get_current_framebuffer() → returns FBO 0 (default framebuffer)
111
+ // The core renders through Emscripten's GL which manages its own FBOs.
112
+ // We read back from FBO 0 after retro_run.
113
+ const getFBCb = mod.addFunction(() => {
114
+ return 0;
115
+ }, 'i');
116
+
117
+ // get_proc_address(const char* sym) → returns function pointer
118
+ const getProcCb = mod.addFunction((symPtr) => {
119
+ return this._getProcAddress(symPtr);
120
+ }, 'ii');
121
+
122
+ // Write our callback pointers into the struct
123
+ // context_reset at +4 — we'll call the core's context_reset after context creation
124
+ // For now, store the core's context_reset so we can call it later
125
+ this.contextResetPtr = mod.getValue(dataPtr + 4, 'i32');
126
+ // We don't write context_reset back — the core already set it.
127
+ // We only need to write get_current_framebuffer and get_proc_address.
128
+ mod.setValue(dataPtr + 8, getFBCb, 'i32');
129
+ mod.setValue(dataPtr + 12, getProcCb, 'i32');
130
+
131
+ this.active = true;
132
+ return true;
133
+ }
134
+
135
+ /**
136
+ * Create FBO for the core to render into. Called after retro_load_game succeeds
137
+ * and AV info is available.
138
+ *
139
+ * @param {number} width
140
+ * @param {number} height
141
+ * @param {boolean} contextExists - if true, EGL context was already created externally
142
+ */
143
+ createContext(width, height, contextExists = false) {
144
+ this.fboW = width;
145
+ this.fboH = height;
146
+
147
+ if (contextExists) {
148
+ // Context already created by LibretroHost before core loading.
149
+ // Resize if needed and ensure it's current.
150
+ gl.resizeContext(width, height);
151
+ gl.makeCurrent();
152
+ } else {
153
+ // Create pbuffer EGL context
154
+ if (!gl.createContext(width, height)) {
155
+ console.error('[libretro-gl] Failed to create EGL context');
156
+ return false;
157
+ }
158
+ gl.makeCurrent();
159
+ }
160
+
161
+ // Core renders to default FBO (0). We read back from there after retro_run.
162
+ // The Emscripten GL layer manages its own FBO tracking — we don't create one.
163
+ this.fbo = 0;
164
+ gl.glViewport(0, 0, width, height);
165
+ console.error(`[libretro-gl] Using default FBO: ${width}x${height}`);
166
+
167
+ // Call the core's context_reset callback
168
+ if (this.contextResetPtr) {
169
+ this._mod.dynCall('v', this.contextResetPtr, []);
170
+ }
171
+
172
+ return true;
173
+ }
174
+
175
+ /**
176
+ * Read back the rendered frame as RGBA pixels.
177
+ * @param {number} [cropW] active width the core reported (e.g. DC 640); when smaller
178
+ * than the FBO, read back only that bottom-left region instead of the whole viewport
179
+ * (the GL bottom-left origin puts the active framebuffer in the lower-left corner).
180
+ * @param {number} [cropH] active height the core reported (e.g. DC 480).
181
+ * Returns { pixels, width, height } or null if not active.
182
+ */
183
+ readbackFrame(cropW, cropH) {
184
+ if (!this.active) return null;
185
+
186
+ gl.glFinish();
187
+ // Crop to the core's active resolution when it's a sub-region of the FBO; this
188
+ // strips the dead border a fixed-size GL FBO leaves around a smaller native frame.
189
+ const w = (cropW > 0 && cropW <= this.fboW) ? cropW : this.fboW;
190
+ const h = (cropH > 0 && cropH <= this.fboH) ? cropH : this.fboH;
191
+ const GL_RGBA = 0x1908, GL_UNSIGNED_BYTE = 0x1401;
192
+
193
+ if (!this._glPixels || this._glPixels.length !== w * h * 4) {
194
+ this._glPixels = new Uint8Array(w * h * 4);
195
+ }
196
+
197
+ gl.glBindFramebuffer(0x8D40, 0); // GL_FRAMEBUFFER — read from default FBO
198
+ // x=0,y=0 is the bottom-left of the FBO, which is where the native frame is drawn.
199
+ gl.glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, this._glPixels);
200
+
201
+
202
+ // Flip vertically if bottom-left origin (GL convention)
203
+ let pixels;
204
+ if (this.bottomLeftOrigin) {
205
+ if (!this._flipped || this._flipped.length !== w * h * 4) {
206
+ this._flipped = new Uint8Array(w * h * 4);
207
+ }
208
+ const rowBytes = w * 4;
209
+ for (let y = 0; y < h; y++) {
210
+ const srcOff = (h - 1 - y) * rowBytes;
211
+ const dstOff = y * rowBytes;
212
+ this._flipped.set(this._glPixels.subarray(srcOff, srcOff + rowBytes), dstOff);
213
+ }
214
+ pixels = this._flipped;
215
+ } else {
216
+ pixels = this._glPixels;
217
+ }
218
+
219
+ return { pixels, width: w, height: h };
220
+ }
221
+
222
+ /**
223
+ * Resolve a GL function name to a WASM-callable function pointer.
224
+ * The core calls get_proc_address("glClear") and gets back a function
225
+ * table index it can call via indirect call.
226
+ */
227
+ _getProcAddress(symPtr) {
228
+ const mod = this._mod;
229
+ const name = mod.UTF8ToString(symPtr);
230
+
231
+ // Check cache
232
+ if (this._procAddressCache.has(name)) {
233
+ return this._procAddressCache.get(name);
234
+ }
235
+
236
+ // PREFERRED: cores built with emscripten's own WebGL layer (GL_ENABLE_GET_PROC_
237
+ // ADDRESS=1, e.g. Flycast) resolve GL through emscripten_GetProcAddress, which
238
+ // returns a REAL function-table pointer into the WebGL JS implementation —
239
+ // correct signature, no stub. Use it when the core exports it. (The old
240
+ // native-gles `gl[name]` + addFunction(...,'i') stub path traps the moment the
241
+ // core calls a multi-arg GL fn through a 0-arg pointer — that's the
242
+ // context_reset "null function or function signature mismatch".)
243
+ if (typeof mod._emscripten_GetProcAddress === "function") {
244
+ const ptr = mod._emscripten_GetProcAddress(symPtr) >>> 0;
245
+ this._procAddressCache.set(name, ptr);
246
+ return ptr;
247
+ }
248
+
249
+ // Fallback (native-gles bridge cores, e.g. the glide64 N64 build): the GL fns
250
+ // are linked directly into the core, so a non-zero "available" marker suffices —
251
+ // the core calls its own linked function, not this pointer.
252
+ const fn = gl[name];
253
+ if (!fn) {
254
+ this._procAddressCache.set(name, 0);
255
+ return 0;
256
+ }
257
+ const stub = mod.addFunction(() => { return 0; }, 'i');
258
+ this._procAddressCache.set(name, stub);
259
+ return stub;
260
+ }
261
+
262
+ destroy() {
263
+ if (this.contextDestroyPtr && this._mod) {
264
+ try {
265
+ this._mod.dynCall('v', this.contextDestroyPtr, []);
266
+ } catch { /* ignore */ }
267
+ }
268
+ if (this.active) {
269
+ gl.destroyContext();
270
+ this.active = false;
271
+ }
272
+ }
273
+ }