romdevtools 0.44.0 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +293 -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 +9 -2
- package/src/analysis/analyze.js +169 -29
- package/src/analysis/decompile.js +4 -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 +22 -1
- package/src/cores/capabilities.js +90 -2
- package/src/cores/registry.js +12 -0
- package/src/host/LibretroGL.js +270 -0
- package/src/host/LibretroGLBridge.js +836 -0
- package/src/host/LibretroHost.js +144 -3
- package/src/host/callbacks.js +18 -2
- package/src/host/coreLoader.js +49 -2
- 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 +100 -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 +35 -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/host/LibretroHost.js
CHANGED
|
@@ -49,6 +49,12 @@ const PLATFORM_CORE_OPTIONS = {
|
|
|
49
49
|
// host's port-1 input to pad slot 2 — PCE 2P works (probed 2026-06-10
|
|
50
50
|
// during the ZENITH BARRAGE gold round).
|
|
51
51
|
pce: { geargrafx_turbotap: "Enabled" },
|
|
52
|
+
// parallel_n64 defaults to a software gfx plugin that never presents to our GL
|
|
53
|
+
// FBO. Force glide64 (the GL renderer) so HW render actually produces frames.
|
|
54
|
+
n64: { "parallel-n64-gfxplugin": "glide64" },
|
|
55
|
+
// beetle_psx_hw defaults to the software renderer; force hardware_gl so it
|
|
56
|
+
// renders through the GL context the host set up (otherwise no FBO frames).
|
|
57
|
+
ps1: { beetle_psx_hw_renderer: "hardware_gl" },
|
|
52
58
|
// VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
|
|
53
59
|
// `READY.` prompt — the agent would see a blue boot screen, not the game. Force
|
|
54
60
|
// autostart so a disk/tape image runs the first program automatically (same as
|
|
@@ -155,7 +161,7 @@ export const PLATFORM_VIRTUAL_EXT = {
|
|
|
155
161
|
pce: ".pce",
|
|
156
162
|
msx: ".rom",
|
|
157
163
|
};
|
|
158
|
-
import { RETRO_DEVICE_JOYPAD } from "./retroConstants.js";
|
|
164
|
+
import { RETRO_DEVICE_JOYPAD, ROMDEV_PIXEL_FORMAT_RGBA8888 } from "./retroConstants.js";
|
|
159
165
|
|
|
160
166
|
// C64 controller→keyboard map (the Batocera/RetroDeck model: a CONTROLLER alone
|
|
161
167
|
// plays C64 — no physical keyboard needed — by mapping spare buttons/stick to
|
|
@@ -241,11 +247,45 @@ export class LibretroHost {
|
|
|
241
247
|
* Load a libretro core module. Registers callbacks then runs retro_init.
|
|
242
248
|
* @param {string} jsPath
|
|
243
249
|
* @param {string} [wasmPath]
|
|
250
|
+
* @param {Object} [opts]
|
|
251
|
+
* @param {boolean} [opts.hwRender] this core HW-renders (GL) — n64/ps1. Loads the
|
|
252
|
+
* optional native GL stack and creates a headless context BEFORE the core boots
|
|
253
|
+
* (GL calls happen during init/load). The 14 software cores omit this.
|
|
244
254
|
*/
|
|
245
|
-
async loadCore(jsPath, wasmPath) {
|
|
255
|
+
async loadCore(jsPath, wasmPath, opts = {}) {
|
|
246
256
|
if (this.mod) throw new Error("core already loaded; create a new host");
|
|
247
|
-
|
|
257
|
+
|
|
258
|
+
let glCanvas = null;
|
|
259
|
+
if (opts.hwRender) {
|
|
260
|
+
// Set up the HW-render path: LibretroGL owns the FBO/readback; a webgl-node
|
|
261
|
+
// mock canvas backs the minified core's GLctx. Both pull the OPTIONAL native
|
|
262
|
+
// GL deps lazily (clear install hint if absent).
|
|
263
|
+
const { LibretroGL } = await import("./LibretroGL.js");
|
|
264
|
+
const { loadWebGl2Context } = await import("./glOptionalDep.js");
|
|
265
|
+
this.hwRender = new LibretroGL();
|
|
266
|
+
await this.hwRender.ensureGl();
|
|
267
|
+
const createWebGL2Context = await loadWebGl2Context();
|
|
268
|
+
const { canvas } = createWebGL2Context(640, 480);
|
|
269
|
+
// Skip Emscripten's Safari WebGL2 workaround (breaks instanceof) by
|
|
270
|
+
// pre-setting the flag it checks.
|
|
271
|
+
canvas.getContextSafariWebGL2Fixed = canvas.getContext;
|
|
272
|
+
glCanvas = canvas;
|
|
273
|
+
this._glContextCreated = true;
|
|
274
|
+
// The env callback routes SET_HW_RENDER to this handler.
|
|
275
|
+
this.state.hwRender = this.hwRender;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const mod = await loadLibretroCore({ jsPath, wasmPath, glCanvas });
|
|
248
279
|
this.mod = mod;
|
|
280
|
+
|
|
281
|
+
// Canvas HW-render cores: initialize the Emscripten GL context so `GLctx` is
|
|
282
|
+
// set BEFORE any GL call (the core's context_reset / first getParameter). Must
|
|
283
|
+
// run before _retro_init.
|
|
284
|
+
if (glCanvas && mod.GL) {
|
|
285
|
+
const handle = mod.GL.createContext(glCanvas, { majorVersion: 2 });
|
|
286
|
+
if (handle > 0) mod.GL.makeContextCurrent(handle);
|
|
287
|
+
}
|
|
288
|
+
|
|
249
289
|
registerCallbacks({ mod, state: this.state, log: this.log });
|
|
250
290
|
mod._retro_init();
|
|
251
291
|
this.status.corePath = jsPath;
|
|
@@ -430,8 +470,19 @@ export class LibretroHost {
|
|
|
430
470
|
: this.status.fbWidth / this.status.fbHeight;
|
|
431
471
|
// timing.sample_rate is at offset +32 (double).
|
|
432
472
|
this.status.audioSampleRate = mod.getValue(avInfoPtr + 32, "double");
|
|
473
|
+
// max_width/max_height (+8/+12) bound the FBO for HW-render cores (the core may
|
|
474
|
+
// render larger than base, e.g. N64 hi-res or PS1 internal upscale).
|
|
475
|
+
const maxW = mod.getValue(avInfoPtr + 8, "i32") || this.status.fbWidth;
|
|
476
|
+
const maxH = mod.getValue(avInfoPtr + 12, "i32") || this.status.fbHeight;
|
|
433
477
|
mod._free(avInfoPtr);
|
|
434
478
|
|
|
479
|
+
// HW render: now that AV info is known, create the FBO the core renders into.
|
|
480
|
+
// createContext() also fires the core's context_reset (via dynCall) so it
|
|
481
|
+
// (re)builds its GL resources. The EGL context was already created in loadCore.
|
|
482
|
+
if (this.hwRender?.active) {
|
|
483
|
+
this.hwRender.createContext(maxW, maxH, this._glContextCreated);
|
|
484
|
+
}
|
|
485
|
+
|
|
435
486
|
// Configure controller port 0 as joypad (some cores default to NONE).
|
|
436
487
|
mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
|
|
437
488
|
// Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
|
|
@@ -480,6 +531,7 @@ export class LibretroHost {
|
|
|
480
531
|
let firstRefreshAt = -1;
|
|
481
532
|
while (stepped < MAX_SETTLE) {
|
|
482
533
|
mod._retro_run();
|
|
534
|
+
this._afterRun();
|
|
483
535
|
stepped++;
|
|
484
536
|
if (firstRefreshAt < 0 && this.state.lastFrame && this.state.lastFrame !== beforeRefreshSnapshot) {
|
|
485
537
|
firstRefreshAt = stepped;
|
|
@@ -520,6 +572,7 @@ export class LibretroHost {
|
|
|
520
572
|
if (this.status.paused) return 0;
|
|
521
573
|
for (let i = 0; i < n; i++) {
|
|
522
574
|
mod._retro_run();
|
|
575
|
+
this._afterRun();
|
|
523
576
|
this.status.frameCount++;
|
|
524
577
|
}
|
|
525
578
|
if (this.state.lastFrame) {
|
|
@@ -529,6 +582,23 @@ export class LibretroHost {
|
|
|
529
582
|
return n;
|
|
530
583
|
}
|
|
531
584
|
|
|
585
|
+
/** Post-_retro_run hook: HW-render readback. The video callback set
|
|
586
|
+
* state.hwFramePending during run; now (GL state stable) read back the FBO into
|
|
587
|
+
* state.lastFrame as an RGBA frame. No-op for the 14 software cores. */
|
|
588
|
+
_afterRun() {
|
|
589
|
+
if (this.state.hwFramePending && this.hwRender?.active) {
|
|
590
|
+
this.state.hwFramePending = false;
|
|
591
|
+
const frame = this.hwRender.readbackFrame();
|
|
592
|
+
if (frame) {
|
|
593
|
+
this.state.lastFrame = {
|
|
594
|
+
width: frame.width, height: frame.height,
|
|
595
|
+
pitch: frame.width * 4, format: ROMDEV_PIXEL_FORMAT_RGBA8888,
|
|
596
|
+
pixels: frame.pixels, rgba: true,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
532
602
|
/** Run exactly ONE frame to refresh the framebuffer, even while paused — for a
|
|
533
603
|
* deterministic "restore → screenshot" without un-pausing (so the real-time
|
|
534
604
|
* playtest loop can't race). Advances the (monotonic) frame counter by 1.
|
|
@@ -537,6 +607,7 @@ export class LibretroHost {
|
|
|
537
607
|
const mod = this._needMod();
|
|
538
608
|
this._needMedia();
|
|
539
609
|
mod._retro_run();
|
|
610
|
+
this._afterRun();
|
|
540
611
|
this.status.frameCount++;
|
|
541
612
|
if (this.state.lastFrame) {
|
|
542
613
|
this.status.fbWidth = this.state.lastFrame.width;
|
|
@@ -821,6 +892,19 @@ export class LibretroHost {
|
|
|
821
892
|
|
|
822
893
|
readMemory(region, offset, length) {
|
|
823
894
|
const mod = this._needMod();
|
|
895
|
+
// PS1 GPU VRAM (1024×512×16bpp = 1MB) isn't on the RETRO_MEMORY interface; the
|
|
896
|
+
// rebuilt pcsx core exposes it via romdev_vram_get. Serve video_ram from there.
|
|
897
|
+
if (region === "video_ram" && typeof mod._romdev_vram_get === "function") {
|
|
898
|
+
const SIZE = 1024 * 512 * 2;
|
|
899
|
+
if (offset < 0 || offset + length > SIZE) {
|
|
900
|
+
throw new RangeError(`read out of bounds: offset=${offset} len=${length} size=${SIZE}`);
|
|
901
|
+
}
|
|
902
|
+
const p = mod._malloc(SIZE);
|
|
903
|
+
try {
|
|
904
|
+
mod._romdev_vram_get(p, SIZE / 2);
|
|
905
|
+
return new Uint8Array(mod.HEAPU8.buffer, p + offset, length).slice();
|
|
906
|
+
} finally { mod._free(p); }
|
|
907
|
+
}
|
|
824
908
|
const id = MemoryRegionToRetro[region];
|
|
825
909
|
if (id === undefined) throw new Error(this._unknownRegionError(region));
|
|
826
910
|
const ptr = mod._retro_get_memory_data(id);
|
|
@@ -1380,6 +1464,63 @@ export class LibretroHost {
|
|
|
1380
1464
|
* instruction (ARM: its pipeline PC, the same convention breakpoint
|
|
1381
1465
|
* addresses use). Pass clear to reset the kind.
|
|
1382
1466
|
*/
|
|
1467
|
+
/** True when the loaded MIPS core (n64/ps1) exposes the live R3000/R4300 register
|
|
1468
|
+
* snapshot (the cheat+regsnap-enabled romdev build). */
|
|
1469
|
+
mipsRegsSupported() {
|
|
1470
|
+
return !!(this.mod && typeof this.mod._romdev_mips_regs_get === "function");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Read the live MIPS register file: 32 GPRs + LO + HI + PC, as a Uint32Array(35).
|
|
1474
|
+
* null if the core doesn't expose it (older build). */
|
|
1475
|
+
getMipsRegs() {
|
|
1476
|
+
const mod = this.mod;
|
|
1477
|
+
if (!mod || typeof mod._romdev_mips_regs_get !== "function") return null;
|
|
1478
|
+
const ptr = mod._malloc(35 * 4);
|
|
1479
|
+
try {
|
|
1480
|
+
mod._romdev_mips_regs_get(ptr);
|
|
1481
|
+
return new Uint32Array(mod.HEAPU8.buffer, ptr, 35).slice();
|
|
1482
|
+
} finally {
|
|
1483
|
+
mod._free(ptr);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/** True when the PS1 core exposes the SPU register block (getAudioState chip:'spu'). */
|
|
1488
|
+
spuRegsSupported() {
|
|
1489
|
+
return !!(this.mod && typeof this.mod._romdev_spu_get === "function");
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** True when the N64 core exposes the AI registers (getAudioState chip:'ai'). */
|
|
1493
|
+
aiRegsSupported() {
|
|
1494
|
+
return !!(this.mod && typeof this.mod._romdev_ai_get === "function");
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** Read the N64 AI registers + VI clock as a Uint32Array(7). null if absent. */
|
|
1498
|
+
getAiRegs() {
|
|
1499
|
+
const mod = this.mod;
|
|
1500
|
+
if (!mod || typeof mod._romdev_ai_get !== "function") return null;
|
|
1501
|
+
const ptr = mod._malloc(7 * 4);
|
|
1502
|
+
try {
|
|
1503
|
+
mod._romdev_ai_get(ptr);
|
|
1504
|
+
return new Uint32Array(mod.HEAPU8.buffer, ptr, 7).slice();
|
|
1505
|
+
} finally {
|
|
1506
|
+
mod._free(ptr);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Read the PS1 SPU's 0x400-word register block as a Uint16Array(1024). null if
|
|
1511
|
+
* the core doesn't expose it. */
|
|
1512
|
+
getSpuRegs() {
|
|
1513
|
+
const mod = this.mod;
|
|
1514
|
+
if (!mod || typeof mod._romdev_spu_get !== "function") return null;
|
|
1515
|
+
const ptr = mod._malloc(0x400 * 2);
|
|
1516
|
+
try {
|
|
1517
|
+
mod._romdev_spu_get(ptr, 0x400);
|
|
1518
|
+
return new Uint16Array(mod.HEAPU8.buffer, ptr, 0x400).slice();
|
|
1519
|
+
} finally {
|
|
1520
|
+
mod._free(ptr);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1383
1524
|
getRegSnapshot(clear = false) {
|
|
1384
1525
|
const mod = this.mod;
|
|
1385
1526
|
if (!mod || typeof mod._romdev_regsnap_get !== "function") return null;
|
package/src/host/callbacks.js
CHANGED
|
@@ -164,7 +164,17 @@ 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
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (dataPtr === 0 || uPtr === RETRO_HW_FRAME_BUFFER_VALID) return;
|
|
168
178
|
const bytes = h * pitch;
|
|
169
179
|
const view = mod.HEAPU8.subarray(dataPtr, dataPtr + bytes);
|
|
170
180
|
state.lastFrame = {
|
|
@@ -429,8 +439,14 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
|
|
|
429
439
|
case E.SET_PROC_ADDRESS_CALLBACK:
|
|
430
440
|
return true;
|
|
431
441
|
|
|
442
|
+
// HW render (n64/ps1): if the host set up a GL handler (state.hwRender), let it
|
|
443
|
+
// claim the SET_HW_RENDER request + install its callbacks. Without it (the 14
|
|
444
|
+
// software cores, or no GL stack), reject so the core falls back to software.
|
|
445
|
+
case E.SET_HW_RENDER:
|
|
446
|
+
if (state.hwRender) return state.hwRender.setup(mod, dataPtr);
|
|
447
|
+
return false;
|
|
448
|
+
|
|
432
449
|
// 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
450
|
case E.GET_RUMBLE_INTERFACE:
|
|
435
451
|
case E.GET_SENSOR_INTERFACE:
|
|
436
452
|
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);
|
|
@@ -37,7 +41,50 @@ export async function loadLibretroCore(args) {
|
|
|
37
41
|
print: () => {},
|
|
38
42
|
printErr: () => {},
|
|
39
43
|
};
|
|
40
|
-
|
|
44
|
+
|
|
45
|
+
// HW-render cores: minified glue uses GLctx = canvas.getContext('webgl2'), so we
|
|
46
|
+
// hand it a webgl-node mock canvas + install the WebGL2 globals Emscripten probes.
|
|
47
|
+
if (glCanvas) {
|
|
48
|
+
opts.canvas = glCanvas;
|
|
49
|
+
if (typeof globalThis.WebGL2RenderingContext === "undefined") {
|
|
50
|
+
const { WebGL2RenderingContext } = await import("webgl-node");
|
|
51
|
+
globalThis.WebGL2RenderingContext = WebGL2RenderingContext;
|
|
52
|
+
// CRITICAL: WebGLRenderingContext (WebGL1) must be a DISTINCT class from
|
|
53
|
+
// WebGL2RenderingContext. Emscripten's getContext shim does
|
|
54
|
+
// return (ver=="webgl") == (gl instanceof WebGLRenderingContext) ? gl : null
|
|
55
|
+
// For ver="webgl2" that needs `gl instanceof WebGLRenderingContext` to be
|
|
56
|
+
// FALSE. If we aliased WebGLRenderingContext to the WebGL2 class, a webgl2
|
|
57
|
+
// context IS instanceof it → the shim returns null → GLctx never set → the
|
|
58
|
+
// core renders nothing. So WebGL1 gets its own empty marker class.
|
|
59
|
+
globalThis.WebGLRenderingContext = class WebGLRenderingContext {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (wasmPath && glBridge) {
|
|
64
|
+
// Unminified GL cores: patch GL function imports + no-op EGL (we own EGL via
|
|
65
|
+
// native-gles), and capture the instance memory for the bridge's marshaling.
|
|
66
|
+
const wasmBinary = await readFile(wasmPath);
|
|
67
|
+
opts.instantiateWasm = (info, receiveInstance) => {
|
|
68
|
+
for (const nsObj of Object.values(info)) {
|
|
69
|
+
if (typeof nsObj !== "object" || nsObj === null) continue;
|
|
70
|
+
for (const [name, fn] of Object.entries(glBridge)) {
|
|
71
|
+
if (name in nsObj) nsObj[name] = fn;
|
|
72
|
+
}
|
|
73
|
+
if ("eglGetDisplay" in nsObj) {
|
|
74
|
+
nsObj.eglGetDisplay = () => 62000;
|
|
75
|
+
nsObj.eglInitialize = () => 1;
|
|
76
|
+
nsObj.eglQueryString = () => 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
WebAssembly.instantiate(wasmBinary, info).then((result) => {
|
|
80
|
+
if (glBridge._setMemory && result.instance.exports.memory) {
|
|
81
|
+
glBridge._setMemory(result.instance.exports.memory);
|
|
82
|
+
}
|
|
83
|
+
receiveInstance(result.instance, result.module);
|
|
84
|
+
});
|
|
85
|
+
return {};
|
|
86
|
+
};
|
|
87
|
+
} else if (wasmPath) {
|
|
41
88
|
opts.wasmBinary = await readFile(wasmPath);
|
|
42
89
|
}
|
|
43
90
|
|
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
|
+
}
|
|
@@ -70,3 +70,17 @@ export const RETRO_LOG_ERROR = 3;
|
|
|
70
70
|
// Sentinels
|
|
71
71
|
/** Video refresh data pointer meaning "GL framebuffer is valid". */
|
|
72
72
|
export const RETRO_HW_FRAME_BUFFER_VALID = -1 >>> 0; // unsigned -1
|
|
73
|
+
|
|
74
|
+
// HW render context types (retro_hw_context_type) — the GL flavors the
|
|
75
|
+
// HW-render cores (n64/ps1) request via RETRO_ENVIRONMENT_SET_HW_RENDER.
|
|
76
|
+
export const RETRO_HW_CONTEXT_NONE = 0;
|
|
77
|
+
export const RETRO_HW_CONTEXT_OPENGL = 1; // OpenGL 2.x (compat)
|
|
78
|
+
export const RETRO_HW_CONTEXT_OPENGLES2 = 2; // GLES 2.0
|
|
79
|
+
export const RETRO_HW_CONTEXT_OPENGL_CORE = 3; // OpenGL 3+ core
|
|
80
|
+
export const RETRO_HW_CONTEXT_OPENGLES3 = 4; // GLES 3.0
|
|
81
|
+
export const RETRO_HW_CONTEXT_OPENGLES_VERSION = 5; // GLES with explicit version
|
|
82
|
+
|
|
83
|
+
// romdev-internal sentinel: a HW-render readback frame is already RGBA8888 (from
|
|
84
|
+
// glReadPixels GL_RGBA), NOT the BGRA byte order libretro's XRGB8888 uses. The
|
|
85
|
+
// framebuffer decoder special-cases this so R/B aren't swapped.
|
|
86
|
+
export const ROMDEV_PIXEL_FORMAT_RGBA8888 = 0x52474241; // 'RGBA'
|
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -1609,6 +1609,8 @@ function sniffPlatformFromPath(p) {
|
|
|
1609
1609
|
if (/\.prg$/i.test(p)) return "c64";
|
|
1610
1610
|
if (/\.(lnx|lyx)$/i.test(p)) return "lynx";
|
|
1611
1611
|
if (/\.gba$/i.test(p)) return "gba";
|
|
1612
|
+
if (/\.(z64|n64|v64)$/i.test(p)) return "n64";
|
|
1613
|
+
if (/\.(psexe|psx)$/i.test(p)) return "ps1"; // .exe/.bin are ambiguous — pass platform explicitly
|
|
1612
1614
|
if (/\.(gen|md|bin)$/i.test(p)) return "genesis";
|
|
1613
1615
|
return null;
|
|
1614
1616
|
}
|
package/src/mcp/tools/frame.js
CHANGED
|
@@ -389,10 +389,14 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
389
389
|
async function shootAscii({ cols, rows, symbols, colors, path: outPath, inline }) {
|
|
390
390
|
const host = getHost(sessionKey);
|
|
391
391
|
const { width, height, rgba } = host.screenshotRgba();
|
|
392
|
-
// Default
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
392
|
+
// Default to ONE cell per 8×8 tile (so a 256×224 NES frame → 32×28, legible
|
|
393
|
+
// game state). The old /16 default (16×14 for NES) was too coarse to read
|
|
394
|
+
// anything — feedback 0.44.0 #1. The terminal symbol's own subcell shape adds
|
|
395
|
+
// detail back, so this stays cheap.
|
|
396
|
+
if (cols == null) cols = Math.max(8, Math.floor(width / 8));
|
|
397
|
+
if (rows == null) rows = Math.max(8, Math.floor(height / 8));
|
|
398
|
+
// Warn when the caller forced a grid so coarse it can't show game state.
|
|
399
|
+
const tooCoarse = cols < Math.floor(width / 8) / 2 || rows < Math.floor(height / 8) / 2;
|
|
396
400
|
const { renderRgbaToAnsi } = await import("../../host/chafa-render.js");
|
|
397
401
|
const ansi = await renderRgbaToAnsi(rgba, width, height, { cols, rows, symbols, colors });
|
|
398
402
|
// Livestream sidebands: the human sees BOTH the real PNG and the ANSI.
|
|
@@ -401,15 +405,20 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
401
405
|
_observerImages: [{ kind: "image", mimeType: "image/png", base64: shot.pngBase64 }],
|
|
402
406
|
_observerAnsi: ansi,
|
|
403
407
|
};
|
|
408
|
+
const coarseNote = tooCoarse
|
|
409
|
+
? `NOTE: ${cols}x${rows} is too coarse to read game state from this ${width}x${height} frame. `
|
|
410
|
+
+ `For a pass/fail check ("are we in gameplay?"), a memory({op:'read'}) byte assertion is cheaper and exact — `
|
|
411
|
+
+ `ascii is for a rough visual, not state.`
|
|
412
|
+
: null;
|
|
404
413
|
let result;
|
|
405
414
|
if (!inline) {
|
|
406
415
|
await writeFile(outPath, ansi, "utf-8");
|
|
407
|
-
result = jsonContent({ path: outPath, framebuffer: { width, height }, terminal: { cols, rows, symbols, colors }, ansiBytes: Buffer.byteLength(ansi, "utf-8") });
|
|
416
|
+
result = jsonContent({ path: outPath, framebuffer: { width, height }, terminal: { cols, rows, symbols, colors }, ansiBytes: Buffer.byteLength(ansi, "utf-8"), ...(coarseNote ? { note: coarseNote } : {}) });
|
|
408
417
|
} else {
|
|
409
418
|
result = {
|
|
410
419
|
content: [
|
|
411
420
|
{ type: "text", text: ansi },
|
|
412
|
-
{ type: "text", text: `framebuffer ${width}x${height} → terminal ${cols}x${rows} (${symbols}/${colors}, ${Buffer.byteLength(ansi, "utf-8")}B)` },
|
|
421
|
+
{ type: "text", text: `framebuffer ${width}x${height} → terminal ${cols}x${rows} (${symbols}/${colors}, ${Buffer.byteLength(ansi, "utf-8")}B)` + (coarseNote ? `\n${coarseNote}` : "") },
|
|
413
422
|
],
|
|
414
423
|
};
|
|
415
424
|
}
|
|
@@ -857,10 +866,10 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
857
866
|
inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
|
|
858
867
|
overlayBoxes: z.boolean().default(false).describe("op=screenshot png: draw a colored bounding box per visible sprite (SNES+NES only)."),
|
|
859
868
|
scale: z.number().gt(0).max(16).refine((s) => s <= 1 || Number.isInteger(s), { message: "scale must be 0<scale≤1 (downscale) or an integer ≥2 (upscale)" }).optional().describe("op=screenshot png: nearest-neighbor resample factor. DEFAULT (unset/1) = NATIVE resolution — perfect pixels, the accurate representation; use this. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens — useful for cheap 'did it change?' checks). integer scale≥2 UPscales by pixel-duplication (e.g. scale:4 → GB 160x144 → 640x576): it adds NO information (same pixels enlarged), costs MORE image tokens, and since VLM encoders resize to their own fixed resolution it may not change what the model sees and can slightly degrade it. Only for clients that render tiny images too small to use and can't zoom."),
|
|
860
|
-
cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/
|
|
861
|
-
rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/
|
|
869
|
+
cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/8 — one cell per 8×8 tile, legible game state)."),
|
|
870
|
+
rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/8)."),
|
|
862
871
|
symbols: z.enum(["ascii", "halfblock", "block", "quad", "sextant"]).default("ascii").describe("op=screenshot ascii: chafa symbol set."),
|
|
863
|
-
colors: z.enum(["true", "256", "16", "fgbg"]).default("
|
|
872
|
+
colors: z.enum(["true", "256", "16", "fgbg"]).default("256").describe("op=screenshot ascii: color depth. Default '256' (indexed) — far fewer ANSI escape bytes than 'true' (truecolor per cell) for a near-identical read. Use 'true' only when exact color matters."),
|
|
864
873
|
},
|
|
865
874
|
safeTool(async (args) => {
|
|
866
875
|
switch (args.op) {
|
|
@@ -21,7 +21,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
21
21
|
if (path && base64) throw new Error("loadMedia: provide `path` OR `base64`, not both.");
|
|
22
22
|
const slotB = slot === "b";
|
|
23
23
|
const host = slotB ? resetHostB(sessionKey) : resetHost(sessionKey);
|
|
24
|
-
await host.loadCore(resolved.jsPath, resolved.wasmPath);
|
|
24
|
+
await host.loadCore(resolved.jsPath, resolved.wasmPath, { hwRender: resolved.hwRender });
|
|
25
25
|
const bytes = base64 ? new Uint8Array(Buffer.from(base64, "base64")) : undefined;
|
|
26
26
|
await host.loadMedia({
|
|
27
27
|
platform,
|