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.
- package/CHANGELOG.md +572 -0
- package/README.md +9 -7
- package/examples/dreamcast/hello/main.c +24 -0
- package/examples/n64/platformer/main.c +158 -0
- package/examples/n64/puzzle/main.c +117 -0
- package/examples/n64/racing/main.c +147 -0
- package/examples/n64/shmup/main.c +122 -0
- package/examples/n64/sports/main.c +127 -0
- package/examples/ps1/platformer/main.c +158 -0
- package/examples/ps1/puzzle/main.c +125 -0
- package/examples/ps1/racing/main.c +147 -0
- package/examples/ps1/shmup/main.c +192 -0
- package/examples/ps1/sports/main.c +127 -0
- package/examples/ps1/sprite_move/main.c +38 -0
- package/package.json +11 -2
- package/src/analysis/analyze.js +224 -29
- package/src/analysis/decompile.js +7 -0
- package/src/analysis/decompiler/sleigh/mips.ldefs +25 -0
- package/src/analysis/decompiler/sleigh/mips32.pspec +78 -0
- package/src/analysis/decompiler/sleigh/mips32be.cspec +107 -0
- package/src/analysis/decompiler/sleigh/mips32be.sla +211162 -0
- package/src/analysis/decompiler/sleigh/mips32le.cspec +106 -0
- package/src/analysis/decompiler/sleigh/mips32le.sla +210624 -0
- package/src/analysis/rizin.js +24 -1
- package/src/cores/capabilities.js +122 -2
- package/src/cores/registry.js +17 -0
- package/src/host/LibretroGL.js +273 -0
- package/src/host/LibretroGLBridge.js +836 -0
- package/src/host/LibretroHost.js +203 -3
- package/src/host/callbacks.js +22 -2
- package/src/host/coreLoader.js +65 -5
- package/src/host/cpu-state.js +27 -0
- package/src/host/framebuffer.js +14 -1
- package/src/host/glOptionalDep.js +60 -0
- package/src/host/n64-ai-state.js +43 -0
- package/src/host/ps1-spu-state.js +65 -0
- package/src/host/retroConstants.js +14 -0
- package/src/mcp/tools/disasm.js +2 -0
- package/src/mcp/tools/frame.js +18 -9
- package/src/mcp/tools/lifecycle.js +1 -1
- package/src/mcp/tools/platform-tools.js +20 -4
- package/src/mcp/tools/platforms.js +38 -4
- package/src/mcp/tools/project.js +116 -0
- package/src/mcp/tools/rendering-context.js +2 -1
- package/src/mcp/tools/toolchain.js +1 -1
- package/src/platforms/n64/lib/c/n64.c +196 -0
- package/src/platforms/n64/lib/c/n64.h +68 -0
- package/src/platforms/ps1/lib/c/psx.c +200 -0
- package/src/platforms/ps1/lib/c/psx.h +83 -0
- package/src/toolchains/index.js +65 -0
- package/src/toolchains/mips-c/lib/be/libc.a +0 -0
- package/src/toolchains/mips-c/lib/be/libgcc.a +0 -0
- package/src/toolchains/mips-c/lib/be/libm.a +0 -0
- package/src/toolchains/mips-c/lib/el/libc.a +0 -0
- package/src/toolchains/mips-c/lib/el/libm.a +0 -0
- package/src/toolchains/mips-c/lib/n64-crt0.s +21 -0
- package/src/toolchains/mips-c/lib/n64-ipl3.s +30 -0
- package/src/toolchains/mips-c/lib/n64.ld +15 -0
- package/src/toolchains/mips-c/lib/ps1-crt0.s +20 -0
- package/src/toolchains/mips-c/lib/ps1.ld +15 -0
- package/src/toolchains/mips-c/lib/softint.c +37 -0
- package/src/toolchains/mips-c/mips-c.js +155 -0
- package/src/toolchains/mips-elf-gcc/gcc.js +130 -0
- package/src/toolchains/sh-c/lib/dc-crt0.s +23 -0
- package/src/toolchains/sh-c/lib/dc.h +102 -0
- package/src/toolchains/sh-c/lib/dc.ld +11 -0
- package/src/toolchains/sh-c/lib/libc.a +0 -0
- package/src/toolchains/sh-c/lib/libgcc.a +0 -0
- package/src/toolchains/sh-c/lib/libm.a +0 -0
- package/src/toolchains/sh-c/sh-c.js +101 -0
- package/src/toolchains/sh-elf-gcc/gcc.js +122 -0
package/src/host/LibretroHost.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/host/callbacks.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/src/host/coreLoader.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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);
|
package/src/host/cpu-state.js
CHANGED
|
@@ -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);
|
package/src/host/framebuffer.js
CHANGED
|
@@ -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 ===
|
|
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
|
+
}
|