romdevtools 0.22.0 → 0.23.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/AGENTS.md +30 -0
- package/CHANGELOG.md +73 -0
- package/examples/genesis/templates/platformer.c +5 -1
- package/examples/genesis/templates/two_plane_parallax.c +166 -0
- package/package.json +1 -1
- package/src/host/LibretroHost.js +55 -1
- package/src/host/framebuffer.js +37 -0
- package/src/mcp/tools/audio.js +2 -2
- package/src/mcp/tools/frame.js +13 -34
- package/src/mcp/tools/index.js +2 -2
- package/src/mcp/tools/metasprite-tools.js +1 -1
- package/src/mcp/tools/platform-tools.js +18 -11
- package/src/mcp/tools/project.js +9 -1
- package/src/mcp/tools/rendering-context.js +1 -1
- package/src/mcp/tools/symbols.js +130 -39
- package/src/mcp/tools/tile-inspect.js +1 -1
- package/src/mcp/tools/toolchain.js +3 -2
- package/src/mcp/tools/watch-memory.js +60 -8
- package/src/platforms/gb/MENTAL_MODEL.md +18 -0
- package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +91 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +13 -0
- package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +91 -0
- package/src/platforms/genesis/MENTAL_MODEL.md +161 -0
- package/src/platforms/genesis/TROUBLESHOOTING.md +32 -0
- package/src/platforms/gg/lib/c/gg_crt0.s +30 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +40 -0
- package/src/toolchains/sdcc/preflight-lint.js +164 -8
package/AGENTS.md
CHANGED
|
@@ -399,6 +399,36 @@ When `build({output:'run'})` is too coarse, the long-form workflow:
|
|
|
399
399
|
5. `input({op:'set'})` / `input({op:'press'})` / `input({op:'sequence'})` to drive the game
|
|
400
400
|
6. `state({op:'save'}, "checkpoint")` / `state({op:'load'}, "checkpoint")` for try/undo
|
|
401
401
|
|
|
402
|
+
## Diagnosing behavior over time (game-feel, not just "is it alive")
|
|
403
|
+
|
|
404
|
+
`frame({op:'verify'})` answers "is it rendering / alive". When the screen looks
|
|
405
|
+
plausible but the game is WRONG — choppy movement, a value that's off, a piece
|
|
406
|
+
that locks mid-air — STOP eyeballing screenshots and trace the state. These tools
|
|
407
|
+
already exist; reach for them by symptom:
|
|
408
|
+
|
|
409
|
+
- **"Movement/scrolling feels choppy / camera desyncs / scroll jumps."**
|
|
410
|
+
`recordSession({frames:180, holdInputs:[{right:true}], includeScreenshots:false,
|
|
411
|
+
memorySamples:[{region, offset, length, label}]})` — holds input over N frames and
|
|
412
|
+
returns an analyzable timeline. Sample the player's screen-X + the scroll
|
|
413
|
+
registers (Genesis: `genesis_vsram` + the HSCROLL table in `video_ram`; per
|
|
414
|
+
platform varies) and look for camera scroll changing while the sprite barely
|
|
415
|
+
moves, or non-monotone deltas. `watch({on:'mem', format:'series', ranges:[...]})`
|
|
416
|
+
gives the same idea as a compact value-vs-frame CURVE per byte. (Genesis: see
|
|
417
|
+
its MENTAL_MODEL "Why does horizontal movement feel choppy?" — the usual cause
|
|
418
|
+
is rewriting tilemaps in the frame loop; `watch({on:'dma', perFrame:true})`
|
|
419
|
+
shows the per-frame DMA bytes that spike when you do.)
|
|
420
|
+
- **"A computed value is wrong but the build is clean."** Don't re-read your C.
|
|
421
|
+
Resolve the variable's address and read it: `build({output:'romWithDebug',
|
|
422
|
+
resolveSymbols:["grid","score"]})` (or `symbols({op:'resolve', mapPath|dbgPath,
|
|
423
|
+
name})`) → `memory({op:'read', region, offset})`. Cheap, zero image tokens, and
|
|
424
|
+
it tells you whether the bug is your logic or your data. (sm83/z80: a "wrong
|
|
425
|
+
value" is far more often a WRAM layout collision than a miscompile — see the
|
|
426
|
+
GB/GBC SDCC_GOTCHAS "codegen traps in plain game logic".)
|
|
427
|
+
- **"I can't tell what's on the background."** `background({view:'map'})` decodes
|
|
428
|
+
the BG tilemap (grid of tile indices, or a rendered PNG) — don't hand-compute
|
|
429
|
+
nametable offsets. Small handheld too tiny to read inline? `frame({op:'screenshot',
|
|
430
|
+
scale:4})` up-scales (nearest-neighbor).
|
|
431
|
+
|
|
402
432
|
## When a call fails: READ THE ERROR FIRST
|
|
403
433
|
|
|
404
434
|
romdev errors are written FOR you — they name what went wrong AND how to recover. Read the message (and `issues[]`) before guessing, screenshotting, or retrying blindly. Two shapes:
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,79 @@ All notable changes to `romdevtools`. Dates are release dates.
|
|
|
4
4
|
(Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
|
|
5
5
|
the `romdev-mcp` bin is kept as an alias.)
|
|
6
6
|
|
|
7
|
+
## 0.23.0
|
|
8
|
+
|
|
9
|
+
Response to two real build-session feedback reports (a GBC Columns clone + a
|
|
10
|
+
Genesis Uridium POC). Theme: bugs found, false alarms removed, and — the recurring
|
|
11
|
+
finding — **tools that already existed but agents couldn't find them**.
|
|
12
|
+
|
|
13
|
+
### Fixed — multi-tenant host cross-talk (a whole class of bugs)
|
|
14
|
+
`sprites({op:'inspect', platform:'genesis'})` returned a *GBC*-flavored error while
|
|
15
|
+
Genesis was loaded. Root cause: `inspectSpritesCore` / `getCPUStateCore` /
|
|
16
|
+
`getAudioStateCore` / `inspectBackgroundMapCore` / `inspectPatternTilesCore` were
|
|
17
|
+
module-global `export let` bindings reassigned per session, each closing over THAT
|
|
18
|
+
registration's `sessionKey` — so the last session to register stole the host for
|
|
19
|
+
everyone. Now the caller's `sessionKey` is threaded through. Verified on all 7
|
|
20
|
+
tile-based sprite platforms with sessions live simultaneously.
|
|
21
|
+
|
|
22
|
+
### Fixed — SMS/GG C crt0: `static x = N;` initializers booted to 0
|
|
23
|
+
Investigating two reported "silent sm83 miscompiles" (32-bit xorshift, indexed
|
|
24
|
+
loop): NEITHER reproduces on GB/GBC — both produce byte-exact output. The real bug
|
|
25
|
+
was a genuine **z80 crt0 defect**: `sms_crt0.s`/`gg_crt0.s` placed `_INITIALIZER`
|
|
26
|
+
in RAM not ROM, so the gsinit `ldir` copied RAM onto itself → every value-static
|
|
27
|
+
booted 0 and BSS wasn't zeroed. Fixed both (mirrors the correct `gb_crt0.s`).
|
|
28
|
+
Re-verified all SMS/GG scaffolds still build clean + render.
|
|
29
|
+
|
|
30
|
+
### Changed — lint stops crying wolf on WRAM copies
|
|
31
|
+
The SDCC `xdata-copy-miscompile` warning fired on EVERY `dst[i]=src[i]` loop,
|
|
32
|
+
including plain WRAM arrays (the message even said "ignore if plain WRAM") —
|
|
33
|
+
training agents to ignore lint. Now: provably-VRAM dest → warning, plain RAM array
|
|
34
|
+
→ suppressed, unknown → info. (Deliberately did NOT add the requested 32-bit-shift
|
|
35
|
+
/ short-loop lint heuristics — they'd be false positives for non-bugs.)
|
|
36
|
+
|
|
37
|
+
### Added — feedback ergonomics
|
|
38
|
+
- **`frame({op:'screenshot', scale})` up-scales** (integer ≥2, nearest-neighbor)
|
|
39
|
+
for legible handheld shots (GB 160×144 → 640×576 at `scale:4`), plus the
|
|
40
|
+
existing `0<scale<1` downscale. All platforms.
|
|
41
|
+
- **Cheap symbol→address:** `symbols({op:'resolve', dbgPath|mapPath, name})` reads
|
|
42
|
+
the map FILE on disk (no 63 KB round-trip through context); `build({output:
|
|
43
|
+
'romWithDebug', resolveSymbols:[...]})` folds just those addresses into the
|
|
44
|
+
result. cc65 `.dbg` / sdld `.map` / GNU ld `.map`.
|
|
45
|
+
|
|
46
|
+
### Added — Genesis feel/perf diagnostics
|
|
47
|
+
- **`watch({on:'dma', perFrame:true})`** — per-frame DMA-bytes timeline + spike
|
|
48
|
+
detection (catches "rewriting tilemaps in the frame loop", the #1 Genesis feel
|
|
49
|
+
bug). A hardware-scroll scaffold settles to a flat ~8 bytes/frame.
|
|
50
|
+
- **`scaffold` template `two_plane_parallax`** — plane-A foreground + plane-B
|
|
51
|
+
repeated starfield + player sprite, hardware scroll only, zero loop-time tilemap
|
|
52
|
+
writes. Builds clean, renders, scrolls (verified).
|
|
53
|
+
- Genesis MENTAL_MODEL/TROUBLESHOOTING: "do NOT rewrite tilemaps in the frame
|
|
54
|
+
loop", logical-vs-hardware plane size, the correct parallax loop, Sonic-style
|
|
55
|
+
column streaming, and a "why does movement feel choppy?" recipe.
|
|
56
|
+
|
|
57
|
+
### Changed — discoverability (the recurring root cause)
|
|
58
|
+
Several feedback "feature asks" were tools that already existed. `recordSession`
|
|
59
|
+
(motion/telemetry timeline) was mislabeled in the catalog as "capture inputs for
|
|
60
|
+
replay"; relabeled. New AGENTS.md "Diagnosing behavior over time (game-feel)"
|
|
61
|
+
section maps symptom → existing tool (choppy movement → `recordSession`/`watch`
|
|
62
|
+
series on scroll+sprite; wrong-but-clean value → `resolveSymbols` + `memory` read;
|
|
63
|
+
can't-read-the-BG → `background({view:'map'})` + `screenshot scale:4`).
|
|
64
|
+
|
|
65
|
+
### Other
|
|
66
|
+
- `build({output:'project', path})` already defaults the gb/gbc/sms/gg/msx crt0 +
|
|
67
|
+
codeLoc — documented (the GBC agent was hand-passing them to `output:'rom'`).
|
|
68
|
+
- P4 doc + a new info-level `wram-static-overlap` lint advisory (hardcoded
|
|
69
|
+
`$C000–$C0FF` pointer overlapping SDCC's static-data segment — the actual cause
|
|
70
|
+
of the reporter's "monochrome RNG").
|
|
71
|
+
|
|
72
|
+
## 0.22.1
|
|
73
|
+
|
|
74
|
+
Doc-only follow-up to 0.22.0's movement-analysis feedback: the `pressDuring`
|
|
75
|
+
schema on `watch` and `breakpoint` now states that entries with OVERLAPPING
|
|
76
|
+
windows on the same port are OR'd into a chord (e.g. `b`+`right` held while `a`
|
|
77
|
+
fires mid-window), not overwritten. The driver already behaved this way; this
|
|
78
|
+
documents the guarantee so it doesn't have to be confirmed empirically.
|
|
79
|
+
|
|
7
80
|
## 0.22.0
|
|
8
81
|
|
|
9
82
|
**Transparency + correctness pass: every tool failure is actionable, dangerous
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* level fits in the scroll plane and plain VDP_setHorizontalScroll moves
|
|
14
14
|
* it — a real working scroller with no streaming. For a world WIDER than
|
|
15
15
|
* 512 px you additionally stream the column entering view each time camX
|
|
16
|
-
* crosses an 8-px boundary (see MENTAL_MODEL.md "
|
|
16
|
+
* crosses an 8-px boundary (see MENTAL_MODEL.md "How Sonic-style large
|
|
17
|
+
* maps REALLY work"). For a from-scratch smooth-scroll + parallax
|
|
18
|
+
* starting point with NO per-frame tilemap writes, see the
|
|
19
|
+
* two_plane_parallax template + MENTAL_MODEL.md "Scrolling, parallax &
|
|
20
|
+
* the feel trap".
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
#include <genesis.h>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/* ── two_plane_parallax.c — Genesis SGDK two-plane parallax scaffold ──
|
|
2
|
+
*
|
|
3
|
+
* The "Uridium / Sonic-feel" starting point: a side-scrolling world that
|
|
4
|
+
* moves SMOOTHLY because the frame loop does HARDWARE SCROLL ONLY. There
|
|
5
|
+
* are ZERO tilemap writes inside the loop — the two planes are painted
|
|
6
|
+
* ONCE at setup, and every frame we just nudge two scroll registers and
|
|
7
|
+
* re-stage one sprite. This is the single most important thing to copy:
|
|
8
|
+
*
|
|
9
|
+
* ★ Do NOT rewrite tilemaps in the frame loop. ★
|
|
10
|
+
*
|
|
11
|
+
* Rewriting a plane each frame is a big VDP/DMA burst that overruns
|
|
12
|
+
* vblank and makes movement feel choppy/juddery. Hardware scroll is free.
|
|
13
|
+
*
|
|
14
|
+
* Layout:
|
|
15
|
+
* - Plane A (foreground): a painted "world" — a ground strip + scattered
|
|
16
|
+
* platform blocks across a 512-px (64-cell) plane. Scrolls 1:1 with
|
|
17
|
+
* the camera.
|
|
18
|
+
* - Plane B (background): a repeated starfield filling the whole plane.
|
|
19
|
+
* Scrolls at 1/4 the camera speed for parallax depth (far things
|
|
20
|
+
* move slower). Because Plane B is filled edge-to-edge and the VDP
|
|
21
|
+
* scroll wraps within the plane, it tiles forever with no redraw.
|
|
22
|
+
* - One hardware sprite = the player, drawn in SCREEN space.
|
|
23
|
+
*
|
|
24
|
+
* Drive it: D-pad LEFT/RIGHT scrolls the camera (the player walks within
|
|
25
|
+
* the world). The camera clamps to the world edges.
|
|
26
|
+
*
|
|
27
|
+
* IMPORTANT (logical vs hardware plane size): the Genesis has ONE shared
|
|
28
|
+
* plane-size setting for BOTH planes. We use the default 64x32 cells
|
|
29
|
+
* (512x256 px). A "32-wide level" still lives inside a 64-wide PHYSICAL
|
|
30
|
+
* plane — you don't get an independent per-plane size. See the Genesis
|
|
31
|
+
* MENTAL_MODEL.md "Scrolling, parallax & the feel trap".
|
|
32
|
+
*
|
|
33
|
+
* To go WIDER than 512 px (a true Sonic-size level) you keep this exact
|
|
34
|
+
* loop and add ONE thing: stream the single offscreen column that's about
|
|
35
|
+
* to scroll into view each time the camera crosses an 8-px tile boundary
|
|
36
|
+
* (a circular buffer in the 64-cell plane) — NOT a whole-plane redraw.
|
|
37
|
+
* See MENTAL_MODEL.md "How Sonic-style large maps really work".
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
#include <genesis.h>
|
|
41
|
+
|
|
42
|
+
/* ── Tiles (4bpp, 8 rows x 4 bytes). Indices live at/above TILE_USER_INDEX
|
|
43
|
+
* so they don't clobber SGDK's font + system tiles below it. ── */
|
|
44
|
+
#define T_BLANK (TILE_USER_INDEX + 0)
|
|
45
|
+
#define T_GROUND (TILE_USER_INDEX + 1)
|
|
46
|
+
#define T_BLOCK (TILE_USER_INDEX + 2)
|
|
47
|
+
#define T_PLAYER (TILE_USER_INDEX + 3)
|
|
48
|
+
#define T_STAR (TILE_USER_INDEX + 4)
|
|
49
|
+
|
|
50
|
+
static const u32 tile_blank[8] = { 0,0,0,0,0,0,0,0 };
|
|
51
|
+
/* Solid ground: lit top edge, darker body. */
|
|
52
|
+
static const u32 tile_ground[8] = {
|
|
53
|
+
0x11111111, 0x12222221, 0x12222221, 0x12222221,
|
|
54
|
+
0x12222221, 0x12222221, 0x12222221, 0x12222221,
|
|
55
|
+
};
|
|
56
|
+
/* A floating platform block. */
|
|
57
|
+
static const u32 tile_block[8] = {
|
|
58
|
+
0x33333333, 0x34444443, 0x34444443, 0x34444443,
|
|
59
|
+
0x34444443, 0x34444443, 0x34444443, 0x33333333,
|
|
60
|
+
};
|
|
61
|
+
/* Player: a chunky diamond so it reads on screen. */
|
|
62
|
+
static const u32 tile_player[8] = {
|
|
63
|
+
0x00055000, 0x00555500, 0x05555550, 0x55555555,
|
|
64
|
+
0x55555555, 0x05555550, 0x00555500, 0x00055000,
|
|
65
|
+
};
|
|
66
|
+
/* A single off-center star pixel on a dark field (background twinkle). */
|
|
67
|
+
static const u32 tile_star[8] = {
|
|
68
|
+
0x00000000, 0x00060000, 0x00000000, 0x00000600,
|
|
69
|
+
0x00000000, 0x06000000, 0x00000000, 0x00000060,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
#define PLANE_W_CELLS 64 /* default plane is 64x32 cells */
|
|
73
|
+
#define WORLD_W 512 /* = 64 cells * 8 px (one plane wide) */
|
|
74
|
+
#define SCREEN_W 320 /* H40 */
|
|
75
|
+
#define GROUND_Y 192 /* px — top of the ground strip */
|
|
76
|
+
|
|
77
|
+
/* Static foreground platforms in WORLD PIXEL coords. Painted ONCE. */
|
|
78
|
+
typedef struct { s16 x, y, wcells; } Block;
|
|
79
|
+
static const Block blocks[] = {
|
|
80
|
+
{ 48, 152, 5 }, { 160, 128, 4 }, { 256, 160, 6 },
|
|
81
|
+
{ 352, 112, 4 }, { 432, 144, 5 },
|
|
82
|
+
};
|
|
83
|
+
#define N_BLOCKS (sizeof(blocks) / sizeof(blocks[0]))
|
|
84
|
+
|
|
85
|
+
/* Player WORLD x exposed for headless motion-trace inspection:
|
|
86
|
+
* symbols({op:'resolve', name:'g_player_x'}) → read it over frames while
|
|
87
|
+
* holding RIGHT and compare against the camera scroll (see MENTAL_MODEL
|
|
88
|
+
* "Why does horizontal movement feel choppy?"). `volatile` keeps it from
|
|
89
|
+
* being optimised into a register so the read resolves. */
|
|
90
|
+
volatile s16 g_player_x = 32; /* world px */
|
|
91
|
+
volatile s16 g_cam_x = 0; /* camera scroll (world px) */
|
|
92
|
+
|
|
93
|
+
int main(bool hard) {
|
|
94
|
+
(void)hard;
|
|
95
|
+
|
|
96
|
+
/* Palettes (BGR, 3 bits/chan). PAL0 = sprite, PAL1 = planes. */
|
|
97
|
+
PAL_setColor(0 + 5, 0x008E); /* player orange (Uridium-ish) */
|
|
98
|
+
PAL_setColor(16 + 1, 0x0A86); /* ground top (light) */
|
|
99
|
+
PAL_setColor(16 + 2, 0x0530); /* ground body (dark) */
|
|
100
|
+
PAL_setColor(16 + 3, 0x0CCC); /* block edge (white) */
|
|
101
|
+
PAL_setColor(16 + 4, 0x0844); /* block body (steel) */
|
|
102
|
+
PAL_setColor(16 + 6, 0x0EEE); /* star (white) */
|
|
103
|
+
|
|
104
|
+
/* Upload all tiles once. */
|
|
105
|
+
VDP_loadTileData(tile_blank, T_BLANK, 1, DMA);
|
|
106
|
+
VDP_loadTileData(tile_ground, T_GROUND, 1, DMA);
|
|
107
|
+
VDP_loadTileData(tile_block, T_BLOCK, 1, DMA);
|
|
108
|
+
VDP_loadTileData(tile_player, T_PLAYER, 1, DMA);
|
|
109
|
+
VDP_loadTileData(tile_star, T_STAR, 1, DMA);
|
|
110
|
+
|
|
111
|
+
/* ── PAINT PLANE B (starfield) ONCE — fills the whole 64x32 plane so
|
|
112
|
+
* it tiles forever as the scroll wraps. NEVER touched again. ── */
|
|
113
|
+
VDP_fillTileMapRect(BG_B, TILE_ATTR_FULL(PAL1, 0, 0, 0, T_STAR),
|
|
114
|
+
0, 0, PLANE_W_CELLS, 32);
|
|
115
|
+
|
|
116
|
+
/* ── PAINT PLANE A (foreground world) ONCE. ──
|
|
117
|
+
* Ground strip across the whole world width, then the platform blocks
|
|
118
|
+
* on top. After this, the loop does NOT write the tilemap again. */
|
|
119
|
+
VDP_fillTileMapRect(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, T_GROUND),
|
|
120
|
+
0, GROUND_Y >> 3, PLANE_W_CELLS, (256 - GROUND_Y) >> 3);
|
|
121
|
+
for (u16 i = 0; i < N_BLOCKS; i++) {
|
|
122
|
+
const Block* b = &blocks[i];
|
|
123
|
+
VDP_fillTileMapRect(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, T_BLOCK),
|
|
124
|
+
b->x >> 3, b->y >> 3, b->wcells, 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
VDP_drawText("PARALLAX D-PAD = SCROLL", 7, 1);
|
|
128
|
+
|
|
129
|
+
const s16 PLAYER_SPEED = 2;
|
|
130
|
+
s16 player_screen_x = SCREEN_W / 2 - 4; /* player stays mid-screen */
|
|
131
|
+
|
|
132
|
+
while (TRUE) {
|
|
133
|
+
u16 pad = JOY_readJoypad(JOY_1);
|
|
134
|
+
|
|
135
|
+
/* Move the player through the WORLD. */
|
|
136
|
+
if (pad & BUTTON_LEFT) g_player_x -= PLAYER_SPEED;
|
|
137
|
+
if (pad & BUTTON_RIGHT) g_player_x += PLAYER_SPEED;
|
|
138
|
+
if (g_player_x < 0) g_player_x = 0;
|
|
139
|
+
if (g_player_x > WORLD_W - 8) g_player_x = WORLD_W - 8;
|
|
140
|
+
|
|
141
|
+
/* Camera centers on the player, clamped to the world edges. */
|
|
142
|
+
s16 cam = g_player_x - (SCREEN_W / 2 - 4);
|
|
143
|
+
if (cam < 0) cam = 0;
|
|
144
|
+
if (cam > WORLD_W - SCREEN_W) cam = WORLD_W - SCREEN_W;
|
|
145
|
+
g_cam_x = cam;
|
|
146
|
+
|
|
147
|
+
/* When the camera is hard against an edge, let the player walk to
|
|
148
|
+
* the screen edge instead of staying pinned to the centre. */
|
|
149
|
+
player_screen_x = g_player_x - cam;
|
|
150
|
+
|
|
151
|
+
/* ★ THE ENTIRE PER-FRAME RENDER COST: two scroll-register writes.
|
|
152
|
+
* Positive cam scrolls the plane LEFT, so we write the negative.
|
|
153
|
+
* Plane A: 1:1 with the world. Plane B: 1/4 speed = parallax. */
|
|
154
|
+
VDP_setHorizontalScroll(BG_A, -cam);
|
|
155
|
+
VDP_setHorizontalScroll(BG_B, -(cam >> 2));
|
|
156
|
+
|
|
157
|
+
/* Re-stage the one player sprite (screen space) + flush the SAT.
|
|
158
|
+
* SPRITE_SIZE(1,1) = 8x8. */
|
|
159
|
+
VDP_setSprite(0, player_screen_x, GROUND_Y - 8, SPRITE_SIZE(1, 1),
|
|
160
|
+
TILE_ATTR_FULL(PAL0, 1, 0, 0, T_PLAYER));
|
|
161
|
+
VDP_updateSprites(1, DMA);
|
|
162
|
+
|
|
163
|
+
SYS_doVBlankProcess();
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "romdevtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
package/src/host/LibretroHost.js
CHANGED
|
@@ -1545,6 +1545,60 @@ export class LibretroHost {
|
|
|
1545
1545
|
}
|
|
1546
1546
|
}
|
|
1547
1547
|
|
|
1548
|
+
/**
|
|
1549
|
+
* Per-frame VDP-DMA timeline. Steps `frames` frames ONE AT A TIME, and for
|
|
1550
|
+
* each frame reports how many mem→VDP DMAs fired + how many VRAM/CRAM/VSRAM
|
|
1551
|
+
* bytes they moved. The core's `romdev_dmawatch_set(1)` RESETS its counters
|
|
1552
|
+
* (see the patch), so re-arming before each single-frame step gives a clean
|
|
1553
|
+
* per-frame bucket with no core rebuild — the cheap derivation of "VDP/DMA
|
|
1554
|
+
* work per frame" the feel-diagnostics workflow needs.
|
|
1555
|
+
*
|
|
1556
|
+
* `onFrame(i)` is called at the top of each frame (before the step) so the
|
|
1557
|
+
* caller can drive scheduled input. Genesis-only.
|
|
1558
|
+
*
|
|
1559
|
+
* Returns { frames:[{frame, dmas, words, bytes, romBytes, ramBytes}], ... }.
|
|
1560
|
+
* `romBytes`/`ramBytes` split the moved bytes by source bus (ROM asset upload
|
|
1561
|
+
* vs the RAM→VRAM sprite/scroll refresh) so a per-frame asset-DMA spike — the
|
|
1562
|
+
* "I redrew a tilemap in the loop" smell — stands out from the steady refresh.
|
|
1563
|
+
*/
|
|
1564
|
+
watchDmaPerFrame(frames, onFrame) {
|
|
1565
|
+
const mod = this._needMod();
|
|
1566
|
+
this._needMedia();
|
|
1567
|
+
if (!this.dmaWatchSupported()) throw new Error("VDP-DMA watch not supported by this core (Genesis only).");
|
|
1568
|
+
const CAP = 1024;
|
|
1569
|
+
const outPtr = mod._malloc(CAP * 4 * 4);
|
|
1570
|
+
const out2Ptr = mod._malloc(8);
|
|
1571
|
+
const isRam = (src) => (src >>> 0) >= 0xE00000;
|
|
1572
|
+
const out = [];
|
|
1573
|
+
let peakBytes = 0, peakFrame = -1, totalBytes = 0, totalDmas = 0;
|
|
1574
|
+
try {
|
|
1575
|
+
for (let i = 0; i < frames; i++) {
|
|
1576
|
+
if (onFrame) onFrame(i);
|
|
1577
|
+
mod._romdev_dmawatch_set(1); // arm + RESET counters for this frame
|
|
1578
|
+
this._runFramesExclusive(() => false, 1);
|
|
1579
|
+
const n = mod._romdev_dmawatch_get(outPtr, CAP, out2Ptr);
|
|
1580
|
+
const out2 = new Uint32Array(mod.HEAPU8.buffer, out2Ptr, 2);
|
|
1581
|
+
const total = out2[0]; // total DMAs this frame (>= stored)
|
|
1582
|
+
const u = new Uint32Array(mod.HEAPU8.buffer, outPtr, n * 4);
|
|
1583
|
+
let words = 0, romBytes = 0, ramBytes = 0;
|
|
1584
|
+
for (let k = 0; k < n; k++) {
|
|
1585
|
+
const src = u[k * 4 + 1], len = u[k * 4 + 2];
|
|
1586
|
+
words += len;
|
|
1587
|
+
if (isRam(src)) ramBytes += len * 2; else romBytes += len * 2;
|
|
1588
|
+
}
|
|
1589
|
+
const bytes = words * 2;
|
|
1590
|
+
out.push({ frame: i, dmas: total, words, bytes, romBytes, ramBytes,
|
|
1591
|
+
...(total > n ? { coreBufferTruncated: true } : {}) });
|
|
1592
|
+
totalBytes += bytes; totalDmas += total;
|
|
1593
|
+
if (bytes > peakBytes) { peakBytes = bytes; peakFrame = i; }
|
|
1594
|
+
}
|
|
1595
|
+
mod._romdev_dmawatch_set(0); // disarm
|
|
1596
|
+
return { frames: out, totalBytes, totalDmas, peakBytes, peakFrame };
|
|
1597
|
+
} finally {
|
|
1598
|
+
mod._free(outPtr); mod._free(out2Ptr);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1548
1602
|
pause() {
|
|
1549
1603
|
this.status.paused = true;
|
|
1550
1604
|
}
|
|
@@ -1630,7 +1684,7 @@ export class LibretroHost {
|
|
|
1630
1684
|
sms: { video_ram: "sms_vram (or sms_cram for palette, sms_vdp_regs for VDP regs)" },
|
|
1631
1685
|
gg: { video_ram: "gg_vram (or gg_cram for the 64-byte 12-bit palette, sms_vdp_regs for VDP regs)" },
|
|
1632
1686
|
snes: { video_ram: "snes_oam (sprite OAM), snes_cgram (palette), snes_aram (SPC700), or snes_fillram (PPU/DMA reg shadow). The libretro generic 'video_ram' id isn't wired in snes9x." },
|
|
1633
|
-
genesis: { video_ram: "
|
|
1687
|
+
genesis: { video_ram: "Genesis VRAM IS exposed via 'video_ram' (gpgx) once a ROM is loaded and a frame has run — if it reads empty, step a frame first (the SAT/sprites need the game to have written VRAM). Palette/scroll/VDP regs are genesis_cram / genesis_vsram / genesis_vdp_regs. For decoded views use inspectSprites / inspectPatternTiles / inspectBackgroundMap / getRenderingContext." },
|
|
1634
1688
|
c64: { video_ram: "c64_color_ram (1 KB) / c64_vic_regs / c64_sid_regs / c64_cia1_regs / c64_cia2_regs. The C64 has no separate VRAM — the VIC-II reads from main system_ram." },
|
|
1635
1689
|
};
|
|
1636
1690
|
const hint = suggestions[plat] && suggestions[plat][region];
|
package/src/host/framebuffer.js
CHANGED
|
@@ -113,3 +113,40 @@ export function framebufferToScreenshot(width, height, src, pitch, format) {
|
|
|
113
113
|
const buf = framebufferToPng(width, height, src, pitch, format);
|
|
114
114
|
return { width, height, pngBase64: buf.toString("base64") };
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Nearest-neighbor resample of a base64 PNG by `scale`. Works BOTH directions:
|
|
119
|
+
* scale<1 → downscale (e.g. 0.5 = half size; ~75% fewer image tokens for a
|
|
120
|
+
* routine "did it change?" sanity check).
|
|
121
|
+
* scale>=2 → integer up-scale (e.g. 4 = 4x size) so tiny handheld targets
|
|
122
|
+
* (GB/GG 160x144, etc.) are legible inline without ImageMagick.
|
|
123
|
+
*
|
|
124
|
+
* Nearest-neighbor (not averaging/smoothing) is deliberate in both directions:
|
|
125
|
+
* it keeps pixel-art edges crisp and palette colors exact, so a scaled shot
|
|
126
|
+
* still reads accurately. The PNG is fully decoded already (it's a tiny
|
|
127
|
+
* framebuffer), so this is cheap. Platform-agnostic — same pixel scaling for
|
|
128
|
+
* every core.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} pngBase64 source PNG, base64-encoded
|
|
131
|
+
* @param {number} scale resample factor (>0)
|
|
132
|
+
* @returns {{ base64: string, width: number, height: number }}
|
|
133
|
+
*/
|
|
134
|
+
export function resamplePng(pngBase64, scale) {
|
|
135
|
+
const src = PNG.sync.read(Buffer.from(pngBase64, "base64"));
|
|
136
|
+
const dw = Math.max(1, Math.round(src.width * scale));
|
|
137
|
+
const dh = Math.max(1, Math.round(src.height * scale));
|
|
138
|
+
const dst = new PNG({ width: dw, height: dh });
|
|
139
|
+
for (let y = 0; y < dh; y++) {
|
|
140
|
+
const sy = Math.min(src.height - 1, Math.floor(y / scale));
|
|
141
|
+
for (let x = 0; x < dw; x++) {
|
|
142
|
+
const sx = Math.min(src.width - 1, Math.floor(x / scale));
|
|
143
|
+
const si = (sy * src.width + sx) * 4;
|
|
144
|
+
const di = (y * dw + x) * 4;
|
|
145
|
+
dst.data[di] = src.data[si];
|
|
146
|
+
dst.data[di + 1] = src.data[si + 1];
|
|
147
|
+
dst.data[di + 2] = src.data[si + 2];
|
|
148
|
+
dst.data[di + 3] = src.data[si + 3];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { base64: PNG.sync.write(dst).toString("base64"), width: dw, height: dh };
|
|
152
|
+
}
|
package/src/mcp/tools/audio.js
CHANGED
|
@@ -380,7 +380,7 @@ export function registerAudioTools(server, z, sessionKey) {
|
|
|
380
380
|
if (args.frames != null && args.frames > 0) {
|
|
381
381
|
return await traceAudioChip(sessionKey, args);
|
|
382
382
|
}
|
|
383
|
-
return await getAudioStateCore(args);
|
|
383
|
+
return await getAudioStateCore(args, sessionKey);
|
|
384
384
|
}
|
|
385
385
|
if (args.op !== "record") throw new Error(`audioDebug: unknown op '${args.op}'`);
|
|
386
386
|
if (!args.path) throw new Error("audioDebug({op:'record'}): `path` is required.");
|
|
@@ -485,7 +485,7 @@ async function traceAudioChip(sessionKey, { chip, platform, frames, sampleEvery
|
|
|
485
485
|
|
|
486
486
|
// getAudioStateCore returns jsonContent({...}); pull the structured object.
|
|
487
487
|
const decode = async () => {
|
|
488
|
-
const r = await getAudioStateCore({ chip, platform });
|
|
488
|
+
const r = await getAudioStateCore({ chip, platform }, sessionKey);
|
|
489
489
|
const txt = r.content?.find?.((c) => c.type === "text")?.text;
|
|
490
490
|
return txt ? JSON.parse(txt) : {};
|
|
491
491
|
};
|
package/src/mcp/tools/frame.js
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { PNG } from "pngjs";
|
|
5
|
+
import { resamplePng } from "../../host/framebuffer.js";
|
|
5
6
|
import { getHost } from "../state.js";
|
|
6
7
|
import { imageContent, jsonContent, safeTool } from "../util.js";
|
|
7
8
|
import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
|
|
@@ -258,30 +259,6 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
258
259
|
}
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
// Nearest-neighbor downscale of a PNG by an integer divisor. Nearest-neighbor
|
|
262
|
-
// (not averaging) is deliberate: it keeps pixel-art edges crisp and palette
|
|
263
|
-
// colors exact, so a half-size sanity-check shot still reads accurately. The
|
|
264
|
-
// PNG is fully decoded already (it's a tiny framebuffer), so this is cheap.
|
|
265
|
-
function downscalePng(pngBase64, scale) {
|
|
266
|
-
const src = PNG.sync.read(Buffer.from(pngBase64, "base64"));
|
|
267
|
-
const dw = Math.max(1, Math.round(src.width * scale));
|
|
268
|
-
const dh = Math.max(1, Math.round(src.height * scale));
|
|
269
|
-
const dst = new PNG({ width: dw, height: dh });
|
|
270
|
-
for (let y = 0; y < dh; y++) {
|
|
271
|
-
const sy = Math.min(src.height - 1, Math.floor(y / scale));
|
|
272
|
-
for (let x = 0; x < dw; x++) {
|
|
273
|
-
const sx = Math.min(src.width - 1, Math.floor(x / scale));
|
|
274
|
-
const si = (sy * src.width + sx) * 4;
|
|
275
|
-
const di = (y * dw + x) * 4;
|
|
276
|
-
dst.data[di] = src.data[si];
|
|
277
|
-
dst.data[di + 1] = src.data[si + 1];
|
|
278
|
-
dst.data[di + 2] = src.data[si + 2];
|
|
279
|
-
dst.data[di + 3] = src.data[si + 3];
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return { base64: PNG.sync.write(dst).toString("base64"), width: dw, height: dh };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
262
|
// PNG capture. Writes to outPath, or returns inline when `inline`.
|
|
286
263
|
async function shootPng({ path: outPath, inline, overlayBoxes, scale }) {
|
|
287
264
|
const host = getHost(sessionKey);
|
|
@@ -300,16 +277,18 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
300
277
|
overlayInfo = { platform, spritesDrawn: 0, note: `overlay not yet supported for '${platform}'` };
|
|
301
278
|
}
|
|
302
279
|
}
|
|
303
|
-
//
|
|
304
|
-
// unset) is the
|
|
305
|
-
// fewer image tokens for routine "did it change?" sanity checks
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
280
|
+
// Resample AFTER overlay so the boxes scale with the image. scale=1 (or
|
|
281
|
+
// unset) is the native-resolution default; scale<1 is a downscaled shot
|
|
282
|
+
// (~75% fewer image tokens for routine "did it change?" sanity checks),
|
|
283
|
+
// scale>=2 is an integer up-scale so tiny handheld targets read legibly.
|
|
284
|
+
const scaled = scale && scale !== 1;
|
|
285
|
+
if (scaled) {
|
|
286
|
+
const r = resamplePng(pngBase64, scale);
|
|
287
|
+
pngBase64 = r.base64; width = r.width; height = r.height;
|
|
309
288
|
}
|
|
310
289
|
if (!inline) {
|
|
311
290
|
await writeFile(outPath, Buffer.from(pngBase64, "base64"));
|
|
312
|
-
const json = jsonContent({ path: outPath, width, height, ...(
|
|
291
|
+
const json = jsonContent({ path: outPath, width, height, ...(scaled ? { scale, fullWidth: shot.width, fullHeight: shot.height } : {}), overlay: overlayInfo });
|
|
313
292
|
json._observerImages = [{ kind: "image", mimeType: "image/png", base64: pngBase64 }];
|
|
314
293
|
return json;
|
|
315
294
|
}
|
|
@@ -321,7 +300,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
321
300
|
return {
|
|
322
301
|
content: [
|
|
323
302
|
imageContent(pngBase64),
|
|
324
|
-
{ type: "text", text: `framebuffer ${shot.width}x${shot.height}${
|
|
303
|
+
{ type: "text", text: `framebuffer ${shot.width}x${shot.height}${scaled ? ` (scaled ${scale}x to ${width}x${height})` : ""}${overlayInfo ? ` (overlay: ${overlayInfo.spritesDrawn} sprites)` : ""} — also written to ${tempPath} (use this path for ImageMagick/crops; pass outputPath for a permanent location).` },
|
|
325
304
|
],
|
|
326
305
|
};
|
|
327
306
|
}
|
|
@@ -414,7 +393,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
414
393
|
"level with 7200; prefer ONE big call.\n" +
|
|
415
394
|
"'screenshot': capture the latest frame. `format:'png'` (default, exact colors) or `'ascii'` (lossy chafa text " +
|
|
416
395
|
"render for agents that can't view images). `overlayBoxes` (png) draws a box per visible sprite (SNES+NES only); " +
|
|
417
|
-
"`scale` (0
|
|
396
|
+
"`scale` (png) resamples nearest-neighbor BOTH ways: 0<scale<1 DOWNscales (~75% fewer image tokens at 0.5), integer scale≥2 UPscales so tiny handheld targets read inline (e.g. scale:4 → GB 160x144 becomes 640x576); ascii cols/rows/symbols/colors knobs in the param hints. " +
|
|
418
397
|
"**CHEAP VERIFY: for a binary pass/fail check (theme changed? sprite present? HUD ticked?) prefer scale:0.5 or " +
|
|
419
398
|
"format:'ascii' — BETTER, read the byte directly: symbols({op:'resolve', name}) → memory({op:'read'}) is a 1-byte " +
|
|
420
399
|
"assertion that costs zero image tokens.**\n" +
|
|
@@ -438,7 +417,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
438
417
|
path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
|
|
439
418
|
inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
|
|
440
419
|
overlayBoxes: z.boolean().default(false).describe("op=screenshot png: draw a colored bounding box per visible sprite (SNES+NES only)."),
|
|
441
|
-
scale: z.number().gt(0).max(1).optional().describe("op=screenshot png:
|
|
420
|
+
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. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens for cheap 'did it change?' checks); scale≥2 (integer) UPscales (nearest-neighbor — keeps pixel-art crisp) so tiny handheld targets are legible inline, e.g. scale:4 makes a GB 160x144 shot 640x576. scale=1/unset = native resolution."),
|
|
442
421
|
cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/16)."),
|
|
443
422
|
rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/16)."),
|
|
444
423
|
symbols: z.enum(["ascii", "halfblock", "block", "quad", "sextant"]).default("ascii").describe("op=screenshot ascii: chafa symbol set."),
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -188,8 +188,8 @@ const CATEGORIES = [
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
name: "advanced",
|
|
191
|
-
description: "Less common automation: runUntil (drive a ROM headlessly until a condition),
|
|
192
|
-
useWhen: ["want to automate reaching a specific game state", "tracking down which code writes a specific RAM byte (gameplay variable hunting)", "recording an input macro for regression testing"],
|
|
191
|
+
description: "Less common automation + MOTION/TELEMETRY tracing: runUntil (drive a ROM headlessly until a condition), watch({on:'mem', format:'series'}) (a compact value-vs-frame CURVE per byte — the primitive for velocity/scroll/sprite-position over time), runUntilWrite (step until target byte is written, return the PC), recordSession (hold/script input over N frames while sampling memory + screenshots into an analyzable timeline — use it to diagnose game-FEEL issues: choppy movement, scroll jumps, camera-vs-sprite desync, NOT just input macros).",
|
|
192
|
+
useWhen: ["want to automate reaching a specific game state", "tracking down which code writes a specific RAM byte (gameplay variable hunting)", "diagnosing why movement/scrolling feels choppy or wrong — sample sprite X + scroll regs over frames with recordSession or watch series", "recording an input macro for regression testing"],
|
|
193
193
|
register: (s, z, k) => { registerRunUntilTools(s, z, k); registerWatchMemoryTools(s, z, k); registerRecordTools(s, z, k); },
|
|
194
194
|
},
|
|
195
195
|
];
|
|
@@ -221,7 +221,7 @@ export function registerMetaSpriteTools(server, z, sessionKey) {
|
|
|
221
221
|
},
|
|
222
222
|
safeTool(async (args) => {
|
|
223
223
|
switch (args.op) {
|
|
224
|
-
case "inspect": return await inspectSpritesCore(args);
|
|
224
|
+
case "inspect": return await inspectSpritesCore(args, sessionKey);
|
|
225
225
|
case "group": return await spritesGroup(sessionKey, args);
|
|
226
226
|
case "preview": return await spritesPreview(sessionKey, args);
|
|
227
227
|
case "capture": return await spritesCapture(sessionKey, { ...args, name: args.name ?? "metasprite" });
|
|
@@ -53,7 +53,7 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
53
53
|
// inspectPatternTiles lives in the `tiles` tool (tiles({op:'png'}), in
|
|
54
54
|
// tile-inspect.js) now — extracted here as a live-binding core. Reads the
|
|
55
55
|
// running emulator's pattern tables / VRAM (or an iNES file via `path`).
|
|
56
|
-
inspectPatternTilesCore = async ({ platform, path: romPath, bpp = 4, tileBaseByte = 0, paletteBase = 0, paletteIndex = 0, tileCount = 0, scale = 1, outputPath, inline }) => {
|
|
56
|
+
inspectPatternTilesCore = async ({ platform, path: romPath, bpp = 4, tileBaseByte = 0, paletteBase = 0, paletteIndex = 0, tileCount = 0, scale = 1, outputPath, inline }, callerSessionKey) => {
|
|
57
57
|
requireImageTarget(outputPath, inline, "inspectPatternTiles");
|
|
58
58
|
// Integer nearest-neighbor upscale of a PNG — keeps pixel-art tiles crisp
|
|
59
59
|
// while making a small tile strip actually readable inline.
|
|
@@ -102,7 +102,7 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
102
102
|
if (!hasChr) throw new Error(`'${romPath}' is a CHR-RAM cart — no graphics in the ROM file. Load it and call inspectPatternTiles without path.`);
|
|
103
103
|
return emit(png, { platform: p, source: "file", sourcePath: romPath, width, height });
|
|
104
104
|
}
|
|
105
|
-
const host = getHost(sessionKey);
|
|
105
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
106
106
|
const p = resolvePlatform(host, platform);
|
|
107
107
|
if (p === "nes") {
|
|
108
108
|
const { snapshotPatternTables } = await import("../../platforms/nes/ppu.js");
|
|
@@ -342,8 +342,8 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
// getCPUState → cpu({op:'read'}) (router in watch-memory.js). Live-binding core.
|
|
345
|
-
getCPUStateCore = async ({ platform, cpu = "main" }) => {
|
|
346
|
-
const host = getHost(sessionKey);
|
|
345
|
+
getCPUStateCore = async ({ platform, cpu = "main" }, callerSessionKey) => {
|
|
346
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
347
347
|
const p = resolvePlatform(host, platform);
|
|
348
348
|
const state = getCPUState(host, p, cpu);
|
|
349
349
|
if (!state) {
|
|
@@ -357,8 +357,8 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
357
357
|
// Subsumes the old getDspState / getPsgState / getYm2612State, which
|
|
358
358
|
// remain as thin deprecated aliases below for back-compat.
|
|
359
359
|
/** Run the chip decoder for one chip; returns the structured JSON object. */
|
|
360
|
-
function readAudioChip(chip) {
|
|
361
|
-
const host = getHost(sessionKey);
|
|
360
|
+
function readAudioChip(chip, callerSessionKey) {
|
|
361
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
362
362
|
if (chip === "dsp") {
|
|
363
363
|
const p = resolvePlatform(host, "snes");
|
|
364
364
|
const dsp = getDspState(host, p);
|
|
@@ -421,13 +421,20 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
421
421
|
throw new Error(`getAudioState: unknown chip '${chip}'. Use 'nes' (NES 2A03), 'gb' (Game Boy/GBC), 'gba' (GBA), 'dsp' (SNES), 'psg' (Genesis/SMS/GG SN76489), 'ym2612' (Genesis FM), 'sid' (C64), or 'mikey' (Lynx).`);
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
getAudioStateCore = async ({ chip }) => jsonContent(readAudioChip(chip));
|
|
424
|
+
getAudioStateCore = async ({ chip }, callerSessionKey) => jsonContent(readAudioChip(chip, callerSessionKey));
|
|
425
425
|
|
|
426
426
|
// inspectSprites lives in the `sprites` tool (metasprite-tools.js) now —
|
|
427
427
|
// extracted here as a live-binding core so the router can call it without
|
|
428
428
|
// disturbing the other handlers registerPlatformTools owns.
|
|
429
|
-
inspectSpritesCore = async ({ platform, maxSlots, slots, outputPath, inline }) => {
|
|
430
|
-
|
|
429
|
+
inspectSpritesCore = async ({ platform, maxSlots, slots, outputPath, inline }, callerSessionKey) => {
|
|
430
|
+
// sessionKey MUST come from the live call, not the closure: these *Core
|
|
431
|
+
// functions are module-level `export let` bindings reassigned on every
|
|
432
|
+
// registerPlatformTools() run, so the closure's `sessionKey` is whatever
|
|
433
|
+
// session registered LAST — not the caller's. Threading it through the
|
|
434
|
+
// call args keeps each session reading its OWN host. (Same fix shape as
|
|
435
|
+
// inspectPaletteCore.) Falls back to the registration key for any caller
|
|
436
|
+
// that still invokes the old 1-arg form.
|
|
437
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
431
438
|
const p = resolvePlatform(host, platform);
|
|
432
439
|
// Generic slot filter, applied by each platform branch before returning.
|
|
433
440
|
// `slots` (explicit index list) wins over `maxSlots` (first N); the
|
|
@@ -693,8 +700,8 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
693
700
|
// inspectBackgroundMap lives in the `background` tool (rendering-context.js)
|
|
694
701
|
// now — extracted here as a live-binding core so the router can call it
|
|
695
702
|
// without disturbing the other handlers registerPlatformTools owns.
|
|
696
|
-
inspectBackgroundMapCore = async ({ platform, render, region, attributesOnly, tilesOnly, which, window, plane, tilemapBaseByte, tileBaseByte, bpp, mapWidth, mapHeight, outputPath, inline }) => {
|
|
697
|
-
const host = getHost(sessionKey);
|
|
703
|
+
inspectBackgroundMapCore = async ({ platform, render, region, attributesOnly, tilesOnly, which, window, plane, tilemapBaseByte, tileBaseByte, bpp, mapWidth, mapHeight, outputPath, inline }, callerSessionKey) => {
|
|
704
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
698
705
|
const p = resolvePlatform(host, platform);
|
|
699
706
|
if (attributesOnly && tilesOnly) {
|
|
700
707
|
throw new Error("inspectBackgroundMap: attributesOnly and tilesOnly are mutually exclusive — omit both to get tiles + subPaletteGrid together.");
|