romdevtools 0.22.1 → 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 +65 -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 +58 -6
- 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/src/mcp/tools/project.js
CHANGED
|
@@ -923,7 +923,15 @@ TEMPLATES.genesis = {
|
|
|
923
923
|
runtimeDirs: SGDK_RUNTIME_DIRS,
|
|
924
924
|
lang: SGDK_LANG,
|
|
925
925
|
ext: ".bin",
|
|
926
|
-
describe: "SIDE-SCROLLING platformer for Genesis. Subpixel gravity + jump + land-on-top collision against a static platform list spread across a 512-px world. Camera follows the player; Plane A scrolls with the world via VDP_setHorizontalScroll, Plane B scrolls at half-rate for parallax. A=jump, d-pad=move. The world here is one 64-cell plane wide (no streaming) — for a wider world, stream the column entering view each 8-px camera step (see Genesis MENTAL_MODEL.md '
|
|
926
|
+
describe: "SIDE-SCROLLING platformer for Genesis. Subpixel gravity + jump + land-on-top collision against a static platform list spread across a 512-px world. Camera follows the player; Plane A scrolls with the world via VDP_setHorizontalScroll, Plane B scrolls at half-rate for parallax. A=jump, d-pad=move. The world here is one 64-cell plane wide (no streaming) — for a wider world, stream the column entering view each 8-px camera step (see Genesis MENTAL_MODEL.md 'How Sonic-style large maps REALLY work'). NOTE: it redraws nothing per frame (scroll is hardware) — for a from-scratch smooth-scroll/parallax starting point with ZERO loop-time tilemap writes, see template:'two_plane_parallax'. Extend with enemies, goals, pickups.",
|
|
927
|
+
},
|
|
928
|
+
two_plane_parallax: {
|
|
929
|
+
main: "templates/two_plane_parallax.c",
|
|
930
|
+
runtime: SGDK_RUNTIME,
|
|
931
|
+
runtimeDirs: SGDK_RUNTIME_DIRS,
|
|
932
|
+
lang: SGDK_LANG,
|
|
933
|
+
ext: ".bin",
|
|
934
|
+
describe: "Two-plane parallax SCROLLING scaffold — the smooth-feel starting point for a Uridium/Sonic-style side-scroller. Plane A = a painted foreground world (ground + platform blocks), Plane B = a repeated starfield, one player sprite. The frame loop does HARDWARE SCROLL ONLY (two VDP_setHorizontalScroll writes + one VDP_updateSprites) — ZERO tilemap writes per frame, which is what keeps movement smooth (rewriting a plane each frame is the #1 'choppy horizontal movement' bug). Plane B scrolls at 1/4 speed for depth. Exposes volatile g_player_x / g_cam_x so you can motion-trace it headlessly (symbols->memory->recordSession). Extend by streaming one offscreen column per 8-px camera step for worlds wider than 512 px — see Genesis MENTAL_MODEL.md 'Scrolling, parallax & the feel trap'.",
|
|
927
935
|
},
|
|
928
936
|
puzzle: {
|
|
929
937
|
main: "templates/puzzle.c",
|
|
@@ -823,7 +823,7 @@ export function registerRenderingContextTools(server, z, sessionKey) {
|
|
|
823
823
|
},
|
|
824
824
|
safeTool(async (args) => {
|
|
825
825
|
switch (args.view) {
|
|
826
|
-
case "map": return await inspectBackgroundMapCore(args);
|
|
826
|
+
case "map": return await inspectBackgroundMapCore(args, sessionKey);
|
|
827
827
|
case "renderState": {
|
|
828
828
|
// Pass sessionKey through so getRenderingContextCore can resolve the
|
|
829
829
|
// per-session host (round 26 fix; was `sessionKey is not defined`).
|
package/src/mcp/tools/symbols.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// caching for v1.
|
|
9
9
|
|
|
10
10
|
import { writeFileSync } from "node:fs";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
11
12
|
import { jsonContent, safeTool, writeOutput } from "../util.js";
|
|
12
13
|
import { addressToSymbolCore } from "./address-to-symbol.js";
|
|
13
14
|
|
|
@@ -34,7 +35,7 @@ function siblings(romPath) {
|
|
|
34
35
|
|
|
35
36
|
// build({output:'romWithDebug'}) — the cc65 .dbg / sdld .map / m68k ELF-map
|
|
36
37
|
// build. Exported core; the `build` router (toolchain.js) calls it.
|
|
37
|
-
export async function buildSourceWithDebugCore({ platform, source, sources, includes, linkerConfig, crt0, codeLoc, outputPath, inline = false }) {
|
|
38
|
+
export async function buildSourceWithDebugCore({ platform, source, sources, includes, linkerConfig, crt0, codeLoc, outputPath, inline = false, resolveSymbols }) {
|
|
38
39
|
const CC65_TARGETS = ["nes", "c64", "atari7800", "lynx"];
|
|
39
40
|
const SDCC_TARGETS = ["gb", "gbc", "sms", "gg"];
|
|
40
41
|
const M68K_TARGETS = ["genesis"];
|
|
@@ -42,6 +43,22 @@ export async function buildSourceWithDebugCore({ platform, source, sources, incl
|
|
|
42
43
|
throw new Error("buildSourceWithDebug: pass outputPath (where to save the ROM; .dbg/.map/log land alongside it) or inline:true to get everything in the response.");
|
|
43
44
|
}
|
|
44
45
|
const sib = outputPath ? siblings(outputPath) : null;
|
|
46
|
+
// resolveSymbols:[...] — resolve just these names off the freshly-produced
|
|
47
|
+
// debug source and fold {symbols:{name:{address,hex,region?,ramOffset?}}}
|
|
48
|
+
// into the response, so the agent gets the addresses it asked for WITHOUT
|
|
49
|
+
// the 30-60KB map being dumped (or even written) anywhere it must re-read.
|
|
50
|
+
const wantResolve = Array.isArray(resolveSymbols) && resolveSymbols.length > 0;
|
|
51
|
+
const foldResolved = async (out, { dbg, map }) => {
|
|
52
|
+
if (!wantResolve) return out;
|
|
53
|
+
try {
|
|
54
|
+
const r = await resolveSymbolsBatchCore({ dbg, map, names: resolveSymbols, platform });
|
|
55
|
+
out.resolvedSymbols = r.symbols;
|
|
56
|
+
if (r.missing) out.unresolvedSymbols = r.missing;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
out.resolveSymbolsError = String(e?.message ?? e);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
45
62
|
|
|
46
63
|
if (CC65_TARGETS.includes(platform)) {
|
|
47
64
|
const { buildC, buildAsm } = await import("../../toolchains/cc65/cc65.js");
|
|
@@ -78,6 +95,7 @@ export async function buildSourceWithDebugCore({ platform, source, sources, incl
|
|
|
78
95
|
if (inline) out.dbg = r.dbg;
|
|
79
96
|
else out.dbgPath = writeOutput(r.dbg, { outputPath: sib.dbg, what: ".dbg" }).path;
|
|
80
97
|
}
|
|
98
|
+
await foldResolved(out, { dbg: r.dbg });
|
|
81
99
|
return jsonContent(out);
|
|
82
100
|
}
|
|
83
101
|
|
|
@@ -116,6 +134,7 @@ export async function buildSourceWithDebugCore({ platform, source, sources, incl
|
|
|
116
134
|
if (inline) out.mapText = r.symbols;
|
|
117
135
|
else out.mapPath = writeOutput(r.symbols, { outputPath: sib.map, what: ".map" }).path;
|
|
118
136
|
}
|
|
137
|
+
await foldResolved(out, { map: r.symbols });
|
|
119
138
|
return jsonContent(out);
|
|
120
139
|
}
|
|
121
140
|
|
|
@@ -152,6 +171,7 @@ export async function buildSourceWithDebugCore({ platform, source, sources, incl
|
|
|
152
171
|
} else {
|
|
153
172
|
out.mapNote = "No linker map produced (link likely failed — check exitCode/log).";
|
|
154
173
|
}
|
|
174
|
+
await foldResolved(out, { map: r.symbols });
|
|
155
175
|
return jsonContent(out);
|
|
156
176
|
}
|
|
157
177
|
|
|
@@ -169,6 +189,49 @@ export function registerSymbolTools(server, z) {
|
|
|
169
189
|
|
|
170
190
|
// ── *Core functions for the `symbols` tool ──
|
|
171
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the on-disk `dbgPath`/`mapPath` convenience args into the inline
|
|
194
|
+
* `dbg`/`map` TEXT the core functions consume. The whole point: an agent can
|
|
195
|
+
* point at the .dbg/.map that build({output:'romWithDebug'}) wrote to disk and
|
|
196
|
+
* get back JUST the address — the 30-60KB map never enters the agent's context.
|
|
197
|
+
* Inline `dbg`/`map` always win if both are passed.
|
|
198
|
+
* @param {{dbg?:string, map?:string, dbgPath?:string, mapPath?:string}} args
|
|
199
|
+
* @returns {Promise<{dbg?:string, map?:string}>} the same args with paths read into text
|
|
200
|
+
*/
|
|
201
|
+
export async function loadDebugSource(args) {
|
|
202
|
+
const out = { ...args };
|
|
203
|
+
if (out.dbg == null && out.dbgPath) out.dbg = await readFile(out.dbgPath, "utf-8");
|
|
204
|
+
if (out.map == null && out.mapPath) out.map = await readFile(out.mapPath, "utf-8");
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve a list of symbol NAMES against an already-loaded debug source (cc65
|
|
210
|
+
* .dbg or sdld/GNU ld .map TEXT). Returns {name:{address,hex,region?,ramOffset?}}
|
|
211
|
+
* for each that resolves, and a `missing` list for any that don't. Powers
|
|
212
|
+
* build({resolveSymbols:[...]}) — resolve just the names you care about off the
|
|
213
|
+
* freshly-produced map WITHOUT dumping the whole map into the response.
|
|
214
|
+
* @param {{dbg?:string, map?:string, names:string[], platform?:string}} args
|
|
215
|
+
*/
|
|
216
|
+
export async function resolveSymbolsBatchCore({ dbg, map, names, platform }) {
|
|
217
|
+
/** @type {Record<string, {address:number, hex:string, region?:string, ramOffset?:number}>} */
|
|
218
|
+
const symbols = {};
|
|
219
|
+
/** @type {string[]} */
|
|
220
|
+
const missing = [];
|
|
221
|
+
for (const name of names) {
|
|
222
|
+
try {
|
|
223
|
+
const r = await resolveSymbolCore({ dbg, map, name, platform });
|
|
224
|
+
const entry = { address: r.address, hex: r.hex };
|
|
225
|
+
if (r.region) entry.region = r.region;
|
|
226
|
+
if (r.ramOffset != null) entry.ramOffset = r.ramOffset;
|
|
227
|
+
symbols[name] = entry;
|
|
228
|
+
} catch {
|
|
229
|
+
missing.push(name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { symbols, ...(missing.length ? { missing } : {}) };
|
|
233
|
+
}
|
|
234
|
+
|
|
172
235
|
/**
|
|
173
236
|
* Load a normalized symbol list from whichever debug format the caller passed:
|
|
174
237
|
* cc65 `.dbg`, SDCC sdld `.map`, OR GNU ld `.map` (Genesis/m68k). Auto-detects
|
|
@@ -218,8 +281,14 @@ function ramReadHint(sym) {
|
|
|
218
281
|
};
|
|
219
282
|
}
|
|
220
283
|
|
|
284
|
+
/** Tag a resolve result with the CPU memory region the address falls in (when platform known). */
|
|
285
|
+
function regionField(platform, addr) {
|
|
286
|
+
const region = platform ? regionForAddress(platform, addr) : null;
|
|
287
|
+
return region ? { region } : {};
|
|
288
|
+
}
|
|
289
|
+
|
|
221
290
|
/** op:'resolve' — symbol name → address. cc65 .dbg, sdld .map, OR GNU ld .map (Genesis). */
|
|
222
|
-
export async function resolveSymbolCore({ dbg, map, name }) {
|
|
291
|
+
export async function resolveSymbolCore({ dbg, map, name, platform }) {
|
|
223
292
|
// cc65 .dbg keeps its bespoke index (it understands the '_name' C alias).
|
|
224
293
|
if (dbg && !map) {
|
|
225
294
|
const { parseDbg, DbgIndex } = await import("../../toolchains/cc65/dbgparse.js");
|
|
@@ -233,6 +302,7 @@ export async function resolveSymbolCore({ dbg, map, name }) {
|
|
|
233
302
|
if (addr === null) throw new Error(`no symbol named '${name}' in this .dbg`);
|
|
234
303
|
return {
|
|
235
304
|
name: resolvedName, address: addr, hex: hexAddr(addr),
|
|
305
|
+
...regionField(platform, addr),
|
|
236
306
|
...(resolvedName !== name ? { note: `Resolved as cc65 C symbol '${resolvedName}' (you asked for '${name}').` } : {}),
|
|
237
307
|
};
|
|
238
308
|
}
|
|
@@ -242,7 +312,7 @@ export async function resolveSymbolCore({ dbg, map, name }) {
|
|
|
242
312
|
// SDCC strips the leading underscore on load; a caller passing '_score' still matches.
|
|
243
313
|
if (!hit && name.startsWith("_")) hit = symbols.find((s) => s.name === name.slice(1));
|
|
244
314
|
if (!hit) throw new Error(`no symbol named '${name}' in this ${format}.`);
|
|
245
|
-
return { name: hit.name, address: hit.addr, hex: hexAddr(hit.addr), format, ...ramReadHint(hit) };
|
|
315
|
+
return { name: hit.name, address: hit.addr, hex: hexAddr(hit.addr), format, ...regionField(platform, hit.addr), ...ramReadHint(hit) };
|
|
246
316
|
}
|
|
247
317
|
|
|
248
318
|
/** op:'lookup' — address → the symbol whose value is closest at-or-below it. cc65 .dbg, sdld/GNU .map. */
|
|
@@ -270,35 +340,46 @@ export async function lookupAddressCore({ dbg, map, address }) {
|
|
|
270
340
|
};
|
|
271
341
|
}
|
|
272
342
|
|
|
343
|
+
// Per-platform region boundaries (CPU memory map). cc65 6502 targets + the
|
|
344
|
+
// Z80 family (SDCC) — both keyed the same way. Single source of truth shared by
|
|
345
|
+
// op:'map' (getMemoryMapCore) and the build({resolveSymbols}) region tagging.
|
|
346
|
+
const REGIONS_BY_PLATFORM = {
|
|
347
|
+
nes: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x200,hi:0x7ff},{name:"ppu_regs",lo:0x2000,hi:0x2007},{name:"apu_input",lo:0x4000,hi:0x401f},{name:"sram",lo:0x6000,hi:0x7fff},{name:"prg_rom",lo:0x8000,hi:0xffff}],
|
|
348
|
+
c64: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x200,hi:0x9fff},{name:"basic_rom",lo:0xa000,hi:0xbfff},{name:"io",lo:0xd000,hi:0xdfff},{name:"kernal",lo:0xe000,hi:0xffff}],
|
|
349
|
+
atari7800: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x1800,hi:0x27ff},{name:"cart_rom",lo:0x4000,hi:0xffff}],
|
|
350
|
+
lynx: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x200,hi:0xfbff},{name:"hw_regs",lo:0xfc00,hi:0xffff}],
|
|
351
|
+
// PC Engine (cc65 pce target): ZP + 8KB work RAM at $2200 (the pce.cfg
|
|
352
|
+
// MAIN segment), HuCard ROM mapped high. The HuC6280 stack lives in the
|
|
353
|
+
// $21xx page via the MPR mapping.
|
|
354
|
+
pce: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x2100,hi:0x21ff},{name:"system_ram",lo:0x2200,hi:0x3fff},{name:"hucard_rom",lo:0xe000,hi:0xffff}],
|
|
355
|
+
// Z80 family (SDCC + sdld .map). Cartridge ROM in the low half, work RAM high.
|
|
356
|
+
gb: [{name:"rom",lo:0,hi:0x7fff},{name:"vram",lo:0x8000,hi:0x9fff},{name:"cart_ram",lo:0xa000,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"oam",lo:0xfe00,hi:0xfe9f},{name:"io_hram",lo:0xff00,hi:0xffff}],
|
|
357
|
+
gbc: [{name:"rom",lo:0,hi:0x7fff},{name:"vram",lo:0x8000,hi:0x9fff},{name:"cart_ram",lo:0xa000,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"oam",lo:0xfe00,hi:0xfe9f},{name:"io_hram",lo:0xff00,hi:0xffff}],
|
|
358
|
+
sms: [{name:"rom",lo:0,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"ram_mirror",lo:0xe000,hi:0xffff}],
|
|
359
|
+
gg: [{name:"rom",lo:0,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"ram_mirror",lo:0xe000,hi:0xffff}],
|
|
360
|
+
// MSX cartridge: BIOS low, cart at $4000-$BFFF, work RAM $C000-$FFFF.
|
|
361
|
+
msx: [{name:"bios",lo:0,hi:0x3fff},{name:"cart_rom",lo:0x4000,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xffff}],
|
|
362
|
+
// Genesis (m68k-elf GNU ld map): ROM low, work-RAM mirror at $E0FF0000
|
|
363
|
+
// (= the emulator's `system_ram`, offset = low 16 bits of the symbol addr).
|
|
364
|
+
genesis: [{name:"rom",lo:0,hi:0x3fffff},{name:"work_ram_mirror",lo:0xe0ff0000,hi:0xe0ffffff}],
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** Region NAME an address falls in for a platform, or null if unknown/out-of-range. */
|
|
368
|
+
function regionForAddress(platform, addr) {
|
|
369
|
+
const regions = REGIONS_BY_PLATFORM[platform];
|
|
370
|
+
if (!regions) return null;
|
|
371
|
+
for (const r of regions) {
|
|
372
|
+
if (addr >= r.lo && addr <= r.hi) return r.name;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
273
377
|
/** op:'map' — categorized layout of where the linker placed code+vars (cc65 .dbg OR sdld .map). */
|
|
274
378
|
export async function getMemoryMapCore({ dbg, map, platform }) {
|
|
275
|
-
// Per-platform region boundaries (CPU memory map). cc65 6502 targets +
|
|
276
|
-
// the Z80 family (SDCC) — both keyed the same way.
|
|
277
|
-
const regionsByPlatform = {
|
|
278
|
-
nes: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x200,hi:0x7ff},{name:"ppu_regs",lo:0x2000,hi:0x2007},{name:"apu_input",lo:0x4000,hi:0x401f},{name:"sram",lo:0x6000,hi:0x7fff},{name:"prg_rom",lo:0x8000,hi:0xffff}],
|
|
279
|
-
c64: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x200,hi:0x9fff},{name:"basic_rom",lo:0xa000,hi:0xbfff},{name:"io",lo:0xd000,hi:0xdfff},{name:"kernal",lo:0xe000,hi:0xffff}],
|
|
280
|
-
atari7800: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x1800,hi:0x27ff},{name:"cart_rom",lo:0x4000,hi:0xffff}],
|
|
281
|
-
lynx: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x100,hi:0x1ff},{name:"system_ram",lo:0x200,hi:0xfbff},{name:"hw_regs",lo:0xfc00,hi:0xffff}],
|
|
282
|
-
// PC Engine (cc65 pce target): ZP + 8KB work RAM at $2200 (the pce.cfg
|
|
283
|
-
// MAIN segment), HuCard ROM mapped high. The HuC6280 stack lives in the
|
|
284
|
-
// $21xx page via the MPR mapping.
|
|
285
|
-
pce: [{name:"zeropage",lo:0,hi:0xff},{name:"stack",lo:0x2100,hi:0x21ff},{name:"system_ram",lo:0x2200,hi:0x3fff},{name:"hucard_rom",lo:0xe000,hi:0xffff}],
|
|
286
|
-
// Z80 family (SDCC + sdld .map). Cartridge ROM in the low half, work RAM high.
|
|
287
|
-
gb: [{name:"rom",lo:0,hi:0x7fff},{name:"vram",lo:0x8000,hi:0x9fff},{name:"cart_ram",lo:0xa000,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"oam",lo:0xfe00,hi:0xfe9f},{name:"io_hram",lo:0xff00,hi:0xffff}],
|
|
288
|
-
gbc: [{name:"rom",lo:0,hi:0x7fff},{name:"vram",lo:0x8000,hi:0x9fff},{name:"cart_ram",lo:0xa000,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"oam",lo:0xfe00,hi:0xfe9f},{name:"io_hram",lo:0xff00,hi:0xffff}],
|
|
289
|
-
sms: [{name:"rom",lo:0,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"ram_mirror",lo:0xe000,hi:0xffff}],
|
|
290
|
-
gg: [{name:"rom",lo:0,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xdfff},{name:"ram_mirror",lo:0xe000,hi:0xffff}],
|
|
291
|
-
// MSX cartridge: BIOS low, cart at $4000-$BFFF, work RAM $C000-$FFFF.
|
|
292
|
-
msx: [{name:"bios",lo:0,hi:0x3fff},{name:"cart_rom",lo:0x4000,hi:0xbfff},{name:"work_ram",lo:0xc000,hi:0xffff}],
|
|
293
|
-
// Genesis (m68k-elf GNU ld map): ROM low, work-RAM mirror at $E0FF0000
|
|
294
|
-
// (= the emulator's `system_ram`, offset = low 16 bits of the symbol addr).
|
|
295
|
-
genesis: [{name:"rom",lo:0,hi:0x3fffff},{name:"work_ram_mirror",lo:0xe0ff0000,hi:0xe0ffffff}],
|
|
296
|
-
};
|
|
297
|
-
|
|
298
379
|
// Parse symbols from whichever format was supplied (auto-detects sdld vs GNU ld).
|
|
299
380
|
const { format, symbols: all } = await loadSymbolList({ dbg, map });
|
|
300
381
|
|
|
301
|
-
const regions = (platform &&
|
|
382
|
+
const regions = (platform && REGIONS_BY_PLATFORM[platform]) || [];
|
|
302
383
|
const labelFor = (addr) => {
|
|
303
384
|
for (const r of regions) {
|
|
304
385
|
if (addr >= r.lo && addr <= r.hi) return r.name;
|
|
@@ -358,8 +439,12 @@ function registerSymbolsTool(server, z) {
|
|
|
358
439
|
"(GB/GBC/SMS/GG/MSX) vs GNU ld `.map` (Genesis/m68k, the `mapText`/`symbols` field). So resolve/lookup/map/list " +
|
|
359
440
|
"cover 11 platforms (those listed). The 'addr' op is broader — it parses ANY sdld/ld65-VICE/GNU-ld map text, so it " +
|
|
360
441
|
"also reaches dasm (Atari 2600) and GBA/ARM.\n" +
|
|
361
|
-
"
|
|
362
|
-
"
|
|
442
|
+
"**CHEAP PATH (no map in context): pass `dbgPath`/`mapPath` — an absolute path to the .dbg/.map that " +
|
|
443
|
+
"build({output:'romWithDebug'}) wrote (the `dbgPath`/`mapPath` it returned). The SERVER reads it; you get back " +
|
|
444
|
+
"JUST {address,hex,region?,ramOffset?} without the 30-60KB map ever entering your context.** (Or skip this tool " +
|
|
445
|
+
"entirely: build({resolveSymbols:[...]}) resolves names off the fresh map in one round trip.)\n" +
|
|
446
|
+
"OP CHEAT-SHEET: resolve {dbg|map|dbgPath|mapPath, name, platform?}; lookup {dbg|map|dbgPath|mapPath, address}; " +
|
|
447
|
+
"map {dbg|map|dbgPath|mapPath, platform?}; list {dbg|map|dbgPath|mapPath, max?}; addr {pc, symbolsText|symbolsPath}.\n" +
|
|
363
448
|
"'resolve' (name→address): resolve a C global, then memory({op:'read'}) it for headless assertions. " +
|
|
364
449
|
"**GENESIS: a work-RAM symbol comes back with `ramOffset` (the low 16 bits) + a `readHint` — read it via " +
|
|
365
450
|
"memory({op:'read', region:'system_ram', offset:ramOffset}).** cc65 C symbols become '_score' in the .dbg (the " +
|
|
@@ -375,25 +460,31 @@ function registerSymbolsTool(server, z) {
|
|
|
375
460
|
"(`al XXXX .name`, cc65/dasm).",
|
|
376
461
|
{
|
|
377
462
|
op: z.enum(["resolve", "lookup", "map", "list", "addr"]).describe("resolve name→addr; lookup addr→sym; map = layout by region; list all; addr = PC→nearest symbol."),
|
|
378
|
-
dbg: z.string().optional().describe("op=resolve/lookup/list/map: cc65 .dbg text from build({output:'romWithDebug'}) (NES/C64/Atari7800/Lynx/PCE). Pass this OR `map`."),
|
|
379
|
-
map: z.string().optional().describe("op=resolve/lookup/list/map: .map text (build's `mapText`/`symbols`) — auto-detects sdld (GB/GBC/SMS/GG/MSX) vs GNU ld (Genesis/m68k). Pass this OR `dbg`."),
|
|
463
|
+
dbg: z.string().optional().describe("op=resolve/lookup/list/map: cc65 .dbg text from build({output:'romWithDebug'}) (NES/C64/Atari7800/Lynx/PCE). Pass this OR `map`/`dbgPath`/`mapPath`."),
|
|
464
|
+
map: z.string().optional().describe("op=resolve/lookup/list/map: .map text (build's `mapText`/`symbols`) — auto-detects sdld (GB/GBC/SMS/GG/MSX) vs GNU ld (Genesis/m68k). Pass this OR `dbg`/`dbgPath`/`mapPath`."),
|
|
465
|
+
dbgPath: z.string().optional().describe("op=resolve/lookup/list/map: ABSOLUTE path to a cc65 .dbg on disk (the `dbgPath` build({output:'romWithDebug'}) returned). Server reads it — the map never enters your context. Inline `dbg` wins if both passed."),
|
|
466
|
+
mapPath: z.string().optional().describe("op=resolve/lookup/list/map: ABSOLUTE path to a .map on disk (the `mapPath` build({output:'romWithDebug'}) returned; sdld or GNU ld, auto-detected). Server reads it — the map never enters your context. Inline `map` wins if both passed."),
|
|
380
467
|
name: z.string().optional().describe("op=resolve: symbol name (C name; no leading underscore needed — both spellings tried)."),
|
|
381
468
|
address: z.number().int().min(0).optional().describe("op=lookup: address whose enclosing symbol to find."),
|
|
382
469
|
max: z.number().int().min(1).max(10000).default(200).describe("op=list: max symbols to return (default 200)."),
|
|
383
|
-
platform: z.string().optional().describe("op=map
|
|
470
|
+
platform: z.string().optional().describe("op=map (region labels) / op=resolve (adds a `region` field to the result) — platform id (incl. genesis work-RAM mirror)."),
|
|
384
471
|
// addr
|
|
385
472
|
pc: z.number().int().min(0).max(0xFFFFFF).optional().describe("op=addr: CPU address to look up (e.g. 0x01A7)."),
|
|
386
473
|
symbolsText: z.string().optional().describe("op=addr: inline .map/.sym text (build's `symbols`). Takes precedence over symbolsPath."),
|
|
387
474
|
symbolsPath: z.string().optional().describe("op=addr: path to a .map/.sym file (used only if symbolsText absent)."),
|
|
388
475
|
},
|
|
389
476
|
safeTool(async (args) => {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
case "
|
|
396
|
-
|
|
477
|
+
// dbgPath/mapPath → read the .dbg/.map TEXT off disk so resolve/lookup/map/
|
|
478
|
+
// list work without the agent ever loading the map into context. (addr has
|
|
479
|
+
// its own symbolsPath handling.)
|
|
480
|
+
const a = args.op === "addr" ? args : { ...args, ...(await loadDebugSource(args)) };
|
|
481
|
+
switch (a.op) {
|
|
482
|
+
case "resolve": return jsonContent(await resolveSymbolCore(a));
|
|
483
|
+
case "lookup": return jsonContent(await lookupAddressCore(a));
|
|
484
|
+
case "map": return jsonContent(await getMemoryMapCore(a));
|
|
485
|
+
case "list": return jsonContent(await listSymbolsCore(a));
|
|
486
|
+
case "addr": return jsonContent(await addressToSymbolCore(a));
|
|
487
|
+
default: throw new Error(`symbols: unknown op '${a.op}'`);
|
|
397
488
|
}
|
|
398
489
|
}),
|
|
399
490
|
);
|
|
@@ -277,7 +277,7 @@ export function registerTileInspectTools(server, z, sessionKey) {
|
|
|
277
277
|
if (!args.platform) throw new Error("tiles({op:'png'}) with `path`: `platform` is required for file extraction.");
|
|
278
278
|
return await extractSpriteSheetCore({ ...args, count: args.count || 256 }, sessionKey);
|
|
279
279
|
}
|
|
280
|
-
return await inspectPatternTilesCore(args);
|
|
280
|
+
return await inspectPatternTilesCore(args, sessionKey);
|
|
281
281
|
}
|
|
282
282
|
case "preview": {
|
|
283
283
|
const r = await previewTileArtCore({ ...args, sessionKey });
|
|
@@ -691,9 +691,9 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
691
691
|
"Compile/assemble source for a target platform; one tool keyed by `output`.\n" +
|
|
692
692
|
"ON FAILURE (ok:false): READ `issues[]` FIRST — it's the structured error list ({file,line,col,severity,message,stage}) and usually names the exact line to fix. Only fall back to the raw `log` if `issues[]` is empty. Don't guess or rebuild blindly before reading it.\n" +
|
|
693
693
|
"• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
|
|
694
|
-
"• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`)
|
|
694
|
+
"• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`). **`resolveSymbols:['grid','score']` folds those names' addresses ({resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}}) straight into the result — the cheap way to a WRAM variable's address without loading the whole map (or round-tripping it through `symbols`).**\n" +
|
|
695
695
|
"• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
|
|
696
|
-
"• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`.",
|
|
696
|
+
"• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for a scaffold({op:'project'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a scaffolded project.**",
|
|
697
697
|
{
|
|
698
698
|
output: z.enum(["rom", "romWithDebug", "run", "project"])
|
|
699
699
|
.describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory."),
|
|
@@ -727,6 +727,7 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
727
727
|
rebuildSdk: z.boolean().optional().describe("GBA + Genesis — rebuild the bundled SDK (libtonc/libgba/maxmod/SGDK) from vendored source instead of the prebuilt seed (~20-40s). Only if you edited SDK source (else an `sdkEditIgnored` warning fires)."),
|
|
728
728
|
lint: z.enum(["advisory", "strict"]).default("advisory").describe("output:'rom' SDCC — 'advisory' (default, warnings in issues[]) or 'strict' (any lint warning fails the build with stage:'lint' before the compiler runs — the SDCC crash-pattern guard)."),
|
|
729
729
|
includeSymbols: z.boolean().default(false).describe("output:'rom' — return the toolchain's symbol/map text inline (sdld .map / cc65 .sym). False = only symbolsBytes (call symbols/addressToSymbol to look up a PC). Maps can be 30+ KB."),
|
|
730
|
+
resolveSymbols: z.array(z.string()).optional().describe("output:'romWithDebug' — resolve just these symbol NAMES (e.g. ['grid','score']) off the freshly-produced .dbg/.map and fold {resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}} into the result. The CHEAP path to a WRAM variable's address: you get the addresses you asked for WITHOUT the 30-60KB map entering your context. Genesis work-RAM symbols come back with `ramOffset` for memory({region:'system_ram', offset}). Names that don't resolve are listed in `unresolvedSymbols`."),
|
|
730
731
|
// run-only
|
|
731
732
|
frames: z.number().int().min(1).max(100000).default(60).describe("output:'run' — frames to run before the screenshot (default 60)."),
|
|
732
733
|
holdInputs: z.array(holdInputShape).max(2).optional().describe("output:'run' — per-port input state to hold during the run (index 0 = port 0)."),
|
|
@@ -913,7 +913,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
913
913
|
},
|
|
914
914
|
safeTool(async (args) => {
|
|
915
915
|
switch (args.op) {
|
|
916
|
-
case "read": return await getCPUStateCore(args);
|
|
916
|
+
case "read": return await getCPUStateCore(args, sessionKey);
|
|
917
917
|
case "setReg": {
|
|
918
918
|
if (args.regId == null || args.value == null) throw new Error("cpu({op:'setReg'}): `regId` and `value` are required.");
|
|
919
919
|
return await cpuSetReg(args);
|
|
@@ -1001,7 +1001,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1001
1001
|
"**CAVEAT: frame-level, not instruction-level (last value per frame); the sampled `pc` is a frame-boundary sample — for ISR-driven writes use breakpoint({on:'write', precision:'exact'}) for the real writer.**\n" +
|
|
1002
1002
|
"• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.) `fromState`/`fromStatePath` restores a savestate FIRST so the trace runs from a known moment (jump to the boss, then see what writes HP) — deterministic + repeatable.\n" +
|
|
1003
1003
|
"• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs. Also takes `fromState`/`fromStatePath` to trace from a restored moment.\n" +
|
|
1004
|
-
"• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). On non-Genesis cores returns `notSupported`.",
|
|
1004
|
+
"• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.",
|
|
1005
1005
|
{
|
|
1006
1006
|
on: z.enum(["mem", "range", "pc", "dma"])
|
|
1007
1007
|
.describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace."),
|
|
@@ -1028,7 +1028,8 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1028
1028
|
limit: z.number().int().min(1).max(4000).default(200).describe("on:'range'/'pc' — max events/PCs returned (default 200; full count is in `total`)."),
|
|
1029
1029
|
outputPath: z.string().optional().describe("on:'mem' — stream every filter-passing event to this path as NDJSON + return a compact summary. Use for long watches so the full log never enters your context."),
|
|
1030
1030
|
// on:'dma' (Genesis VDP DMA trace)
|
|
1031
|
-
|
|
1031
|
+
perFrame: z.boolean().default(false).describe("on:'dma' — FEEL/PERF MODE: instead of one aggregated source/dest log, return a PER-FRAME timeline of VDP-DMA WORK [{frame, dmas, bytes, romBytes, ramBytes}] + peakFrame/peakBytes. This is the cheap 'why does horizontal movement feel choppy?' answer — a frame whose DMA bytes spike (esp. romBytes, an asset re-upload) is doing too much VDP work in the loop (the classic 'I rewrote a tilemap every frame' bug). A smooth hardware-scroll loop shows a low, flat curve. Combine with `pressDuring` to correlate the spike with input (hold RIGHT, see which frames burst). No core rebuild — re-arms the DMA counter each frame."),
|
|
1032
|
+
precision: z.enum(["exact", "sampled"]).default("exact").describe("on:'dma' — exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic). Ignored when perFrame:true."),
|
|
1032
1033
|
vramDest: z.number().int().min(0).optional().describe("on:'dma' precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
|
|
1033
1034
|
destWindow: z.number().int().min(0).default(0x40).describe("on:'dma' precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
|
|
1034
1035
|
dedupe: z.boolean().default(true).describe("on:'dma' precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
|
|
@@ -1057,6 +1058,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1057
1058
|
}
|
|
1058
1059
|
case "dma": {
|
|
1059
1060
|
const a = { ...args, frames: args.frames ?? 120, limit: args.limit ?? 200 };
|
|
1061
|
+
if (a.perFrame) return await dmaPerFrame(a);
|
|
1060
1062
|
if (a.precision === "sampled") {
|
|
1061
1063
|
return await traceVramSourceCore({ ...a, romPreviewBytes: a.romPreviewBytes || 16, sessionKey });
|
|
1062
1064
|
}
|
|
@@ -1132,7 +1134,57 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1132
1134
|
(totalDistinct > limit ? `Showing ${out.length}/${totalDistinct} distinct — raise limit or narrow vramDest.` : ""),
|
|
1133
1135
|
}), host);
|
|
1134
1136
|
}
|
|
1135
|
-
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1137
|
+
|
|
1138
|
+
// watch({on:'dma', perFrame:true}) — FEEL/PERF timeline. Steps frame-by-frame,
|
|
1139
|
+
// re-arming the DMA counter each frame (the core resets on arm), and reports
|
|
1140
|
+
// VDP-DMA WORK per frame. The cheap, no-core-rebuild "why is movement choppy?"
|
|
1141
|
+
// diagnostic: a frame whose bytes (esp. romBytes — an asset re-upload) spike is
|
|
1142
|
+
// doing too much VDP work in the loop. Optionally driven by `pressDuring` so the
|
|
1143
|
+
// spike correlates with input. See genesis MENTAL_MODEL "feel trap".
|
|
1144
|
+
async function dmaPerFrame({ frames = 120, pressDuring, maxFrames = 600 }) {
|
|
1145
|
+
const host = getHost(sessionKey);
|
|
1146
|
+
if (!host.dmaWatchSupported || !host.dmaWatchSupported()) {
|
|
1147
|
+
return jsonContent({ notSupported: true, frames: [],
|
|
1148
|
+
note: "watch({on:'dma', perFrame}) is Genesis-only (VDP DMA). On other cores there's no VDP DMA to count." });
|
|
1149
|
+
}
|
|
1150
|
+
const n = Math.min(frames, maxFrames);
|
|
1151
|
+
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
1152
|
+
const pressDriver = makePressDriver(host, presses);
|
|
1153
|
+
const r = host.watchDmaPerFrame(n, (i) => pressDriver.applyForFrame(i));
|
|
1154
|
+
pressDriver.finish();
|
|
1155
|
+
const tl = r.frames;
|
|
1156
|
+
// Compact: only KEEP frames that did any DMA, plus always the peak. A flat
|
|
1157
|
+
// hardware-scroll loop is mostly the steady SAT refresh — summarise it.
|
|
1158
|
+
const nonZero = tl.filter((f) => f.bytes > 0);
|
|
1159
|
+
const avgBytes = tl.length ? Math.round(r.totalBytes / tl.length) : 0;
|
|
1160
|
+
const peak = tl[r.peakFrame] || null;
|
|
1161
|
+
// A spike heuristic: a frame whose bytes are >3x the average AND carries ROM
|
|
1162
|
+
// source bytes (an asset upload, not the steady RAM refresh) is the smell.
|
|
1163
|
+
const spikes = tl.filter((f) => avgBytes > 0 && f.bytes > avgBytes * 3 && f.romBytes > 0)
|
|
1164
|
+
.map((f) => ({ frame: f.frame, bytes: f.bytes, romBytes: f.romBytes, dmas: f.dmas }));
|
|
1165
|
+
// Cap the returned per-frame rows so a long run doesn't flood context.
|
|
1166
|
+
const rows = nonZero.slice(0, 240);
|
|
1167
|
+
return attachObserverFrame(jsonContent({
|
|
1168
|
+
perFrame: true,
|
|
1169
|
+
framesRun: n,
|
|
1170
|
+
totalDmas: r.totalDmas,
|
|
1171
|
+
totalBytes: r.totalBytes,
|
|
1172
|
+
avgBytesPerFrame: avgBytes,
|
|
1173
|
+
peakFrame: r.peakFrame,
|
|
1174
|
+
peakBytes: r.peakBytes,
|
|
1175
|
+
...(peak ? { peakDetail: peak } : {}),
|
|
1176
|
+
framesWithDma: nonZero.length,
|
|
1177
|
+
...(spikes.length ? { spikes } : {}),
|
|
1178
|
+
...(presses.length ? { pressesApplied: pressDriver.applied() } : {}),
|
|
1179
|
+
timeline: rows,
|
|
1180
|
+
...(nonZero.length > rows.length ? { timelineTruncated: nonZero.length } : {}),
|
|
1181
|
+
note: "Per-frame VDP-DMA WORK. `bytes` = VRAM/CRAM/VSRAM bytes DMA'd that frame; `romBytes` = bytes copied FROM cart ROM (an asset upload), `ramBytes` = the steady RAM→VRAM sprite/scroll refresh. " +
|
|
1182
|
+
"A smooth hardware-scroll loop shows a low, flat curve (mostly ramBytes ≈ the SAT refresh). " +
|
|
1183
|
+
"A `spikes` entry (bytes >3x avg WITH romBytes) is the 'I rewrote a tilemap / re-uploaded tiles in the frame loop' smell — move that work to setup or stream ONE column per 8-px scroll step instead. " +
|
|
1184
|
+
"Hold input with `pressDuring` to see which input bursts. CEILING: this counts DMA bytes; CPU writes to the VDP data port (VDP_setTileMapXY without DMA) are NOT DMA and aren't counted here — those need a core-side VDP-write hook (future).",
|
|
1185
|
+
}), host);
|
|
1186
|
+
}
|
|
1187
|
+
// dmaExact + dmaPerFrame + traceVramSourceCore are reached via watch({on:'dma'})
|
|
1188
|
+
// above — dmaTrace was folded into `watch` (it's a log-all VDP-DMA trace, same
|
|
1189
|
+
// family as on:'mem'/'range'/'pc'), so there's no separate top-level tool.
|
|
1138
1190
|
}
|
|
@@ -72,6 +72,21 @@ check these first. All five have shipped fixes in the bundled runtime
|
|
|
72
72
|
If you use the bundled `gb_crt0.s` you're good; if you bring your
|
|
73
73
|
own, make sure gsinit zeros `_DATA`.
|
|
74
74
|
|
|
75
|
+
6. **Don't poke a hardcoded `$C0xx` WRAM pointer for game state — it
|
|
76
|
+
overlaps your statics.** SDCC links the C runtime's data + BSS (every
|
|
77
|
+
`static` global: your PRNG seed, your grids, your scores) at the BOTTOM
|
|
78
|
+
of WRAM starting `$C000`. A `volatile uint8_t *board = (uint8_t*)0xC000;`
|
|
79
|
+
then scribbles right over `static uint32_t rng = ...;` et al. Symptom
|
|
80
|
+
looks exactly like an SDCC *codegen* bug — e.g. a 32-bit xorshift PRNG
|
|
81
|
+
that "degenerates" so every roll is identical (it's not miscompiling;
|
|
82
|
+
its seed is being clobbered). **Use a `static` array and let the linker
|
|
83
|
+
place it** (`static uint8_t board[78]; board[i]=p;`), or, if you must
|
|
84
|
+
hardcode, put scratch at `$C200`+ and confirm with the linker map
|
|
85
|
+
(`build({includeSymbols:true})` → check `s__DATA`/`s__BSS`; your scratch
|
|
86
|
+
must start above the end of `_BSS`). Full write-up + the
|
|
87
|
+
"is-it-really-a-miscompile" repro in
|
|
88
|
+
`lib/c/SDCC_GOTCHAS.md` § "sm83 codegen traps in plain game logic".
|
|
89
|
+
|
|
75
90
|
## Memory map you actually care about
|
|
76
91
|
|
|
77
92
|
```
|
|
@@ -81,6 +96,9 @@ $8000-$97FF VRAM tile data — 384 tiles × 16 bytes (CGB: dual-banked, 768 tot
|
|
|
81
96
|
$9800-$9FFF VRAM BG maps + CGB attribute map (in bank 1)
|
|
82
97
|
$A000-$BFFF Cart RAM (mappers only; not present in 32 KB ROM-only carts)
|
|
83
98
|
$C000-$DFFF WRAM (8 KB) — your variables, your stack
|
|
99
|
+
⚠ statics start at $C000 (rng/grids/scores live here): NEVER
|
|
100
|
+
hardcode a $C0xx pointer for game state — use a `static`
|
|
101
|
+
array; for fixed scratch use $C200+ (see footgun #6).
|
|
84
102
|
$FE00-$FE9F OAM (40 sprites × 4 bytes) — written via DMA
|
|
85
103
|
$FF00 JOYP — joypad I/O
|
|
86
104
|
$FF40-$FF4B I/O registers — LCDC, BGP, OBP0, OBP1, SCY, SCX, etc.
|
|
@@ -188,3 +188,94 @@ build({
|
|
|
188
188
|
},
|
|
189
189
|
})
|
|
190
190
|
```
|
|
191
|
+
|
|
192
|
+
## sm83 codegen traps in plain game logic (WRAM integer/array code)
|
|
193
|
+
|
|
194
|
+
Every footgun above is about VRAM / OAM-DMA / the cart header — the stuff
|
|
195
|
+
that makes sprites vanish. This section is the opposite: **plain WRAM game
|
|
196
|
+
logic** — PRNGs, collision grids, score math. Two such "miscompiles" were
|
|
197
|
+
reported from a real GBC Columns build session and chased to ground here.
|
|
198
|
+
**Verdict: neither was an sm83 codegen bug.** They are documented so you
|
|
199
|
+
don't burn hours blaming the compiler for what is actually a memory-layout
|
|
200
|
+
or static-init trap.
|
|
201
|
+
|
|
202
|
+
### NOT a bug: 32-bit math / `uint32_t` shifts ≥ 16
|
|
203
|
+
|
|
204
|
+
Reported: *"`static uint32_t rng=0x1357; rng ^= rng<<13; rng ^= rng>>17;
|
|
205
|
+
rng ^= rng<<5;` degenerates — every `1+xorshift()%6` roll comes out the
|
|
206
|
+
same (near-monochrome)."*
|
|
207
|
+
|
|
208
|
+
**Reproduced on sm83: it does NOT degenerate.** A ROM that seeds the PRNG,
|
|
209
|
+
calls `xorshift()` 20×, and writes `1 + (result % 6)` to WRAM reads back a
|
|
210
|
+
fully-varied `5,5,5,1,5,5,4,1,3,2,1,...` — the exact sequence a reference
|
|
211
|
+
implementation produces. Full 32-bit fidelity was confirmed byte-for-byte
|
|
212
|
+
across several seeds (`0xDEADBEEF`, `0x00000001`, …). The `<<13` / `>>17` /
|
|
213
|
+
`<<5` shifts (including the ≥16-bit right shift) and `% 6` are all correct.
|
|
214
|
+
**Do not rewrite a working 32-bit xorshift into 16-bit to "dodge" this.**
|
|
215
|
+
32-bit ops are bigger/slower than 16-bit on an 8-bit CPU, so prefer 16-bit
|
|
216
|
+
PRNGs for *speed* — but not for correctness; both are correct.
|
|
217
|
+
|
|
218
|
+
### The REAL trap behind "monochrome RNG": writing game state to a fixed
|
|
219
|
+
`0xC0xx` WRAM address that overlaps your statics
|
|
220
|
+
|
|
221
|
+
This is what actually produces the reported symptom. SDCC links the C
|
|
222
|
+
runtime's `_DATA` / `_INITIALIZED` segment (every value-initialised
|
|
223
|
+
`static`, e.g. `static uint32_t rng = 0x1357;`) **at the very bottom of
|
|
224
|
+
WRAM, starting `$C000`**, with `_BSS` (zero-init statics like
|
|
225
|
+
`static uint8_t grid[78];`) right after it. If your code also pokes a
|
|
226
|
+
**hardcoded** `$C000`-area pointer for game state —
|
|
227
|
+
|
|
228
|
+
```c
|
|
229
|
+
volatile uint8_t *board = (volatile uint8_t *)0xC000; /* DON'T */
|
|
230
|
+
board[i] = piece; /* clobbers `rng` and friends! */
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
— you are scribbling directly over your own statics. Then `xorshift()`
|
|
234
|
+
reads a trashed `rng`, the PRNG collapses, and every roll looks the same.
|
|
235
|
+
It presents *exactly* like a compiler bug; it is not.
|
|
236
|
+
|
|
237
|
+
**Fixes (any one):**
|
|
238
|
+
- **Best — let the linker place it.** Use a `static` array and take its
|
|
239
|
+
address; never hardcode a WRAM pointer:
|
|
240
|
+
`static uint8_t board[6*13]; ... board[i] = piece;`
|
|
241
|
+
- If you *must* use a fixed address, put it well clear of the runtime data:
|
|
242
|
+
`$C200`+ is safe for small projects (statics here end far below `$C100`;
|
|
243
|
+
`shadow_oam` is pinned at `$C100`). Confirm with the linker map — build
|
|
244
|
+
with `includeSymbols:true` and look at `s__DATA` / `s__BSS` (e.g.
|
|
245
|
+
`s__DATA = $C000`, `s__BSS = $C006`): your scratch RAM must start ABOVE
|
|
246
|
+
the end of `_BSS`.
|
|
247
|
+
- **Diagnose it in seconds:** read `system_ram` offset 0 right after boot
|
|
248
|
+
and compare against your initialised statics' expected bytes. If a
|
|
249
|
+
`static uint32_t x = 0x1357;` doesn't read back `57 13 00 00` at its map
|
|
250
|
+
address, something is overwriting it.
|
|
251
|
+
|
|
252
|
+
### NOT a bug: short `for` loop with an indexed `static` array read
|
|
253
|
+
|
|
254
|
+
Reported: *"`for(i=0;i<3;i++){ if(grid[r*6+col]) return 1; }` reads the
|
|
255
|
+
wrong cells (pieces lock mid-air / floating gaps); unrolling the 3
|
|
256
|
+
iterations fixed it."*
|
|
257
|
+
|
|
258
|
+
**Reproduced on sm83: the looped form reads the CORRECT cells.** A ROM that
|
|
259
|
+
seeds `grid[]` with a sparse occupied/empty pattern and runs `collides()`
|
|
260
|
+
both looped and hand-unrolled, for 8 straddling `(col,topy)` inputs, gets
|
|
261
|
+
**identical, correct** results from both forms (`1,0,1,0,1,1,1,1`). The
|
|
262
|
+
`grid[r*6+col]` index math and the 3-iteration loop are fine. If your real
|
|
263
|
+
collision check "floats," look first at the WRAM-collision trap above (a
|
|
264
|
+
clobbered `grid[]`), at off-by-one row/col limits, or at signed/unsigned
|
|
265
|
+
mix-ups — not at loop codegen. **Don't pre-emptively unroll loops as a
|
|
266
|
+
compiler workaround; with the stack-overflow fix in place, sm83 loops with
|
|
267
|
+
indexed array reads are reliable.**
|
|
268
|
+
|
|
269
|
+
### z80 (SMS/GG) ONLY — fixed: value-initialised statics booted as 0
|
|
270
|
+
|
|
271
|
+
Investigating the above on the **z80** port (SMS/GG share the SDCC family)
|
|
272
|
+
surfaced a real bug — but a **crt0** bug, not codegen. The bundled
|
|
273
|
+
`sms_crt0.s` / `gg_crt0.s` placed `_INITIALIZER` (the ROM image of
|
|
274
|
+
value-initialised statics) *after* the `_DATA` RAM block in the area list,
|
|
275
|
+
so sdld put it in RAM; the gsinit `ldir` then copied uninitialised RAM onto
|
|
276
|
+
itself and **every `static uint8_t x = 5;` booted as 0** (and BSS wasn't
|
|
277
|
+
zeroed either). On z80 *this* is what made the xorshift PRNG monochrome
|
|
278
|
+
(seed `rng` booted 0 → stayed 0). Fixed 2026-06-08 by ROM-placing
|
|
279
|
+
`_INITIALIZER` + adding a `_DATA` zero loop, mirroring this sm83 crt0 (which
|
|
280
|
+
was already correct — hence sm83 was never affected). If you bring your own
|
|
281
|
+
z80 crt0, model gsinit on `gb_crt0.s`.
|
|
@@ -47,6 +47,19 @@ the same wall.
|
|
|
47
47
|
`s__DATA` for `l__DATA` bytes; bring-your-own crt0 should do the
|
|
48
48
|
same.
|
|
49
49
|
|
|
50
|
+
6. **Don't poke a hardcoded `$C0xx` WRAM pointer for game state — it
|
|
51
|
+
overlaps your statics.** SDCC links the C runtime's data + BSS (every
|
|
52
|
+
`static` global: your PRNG seed, your grids, your scores) at the BOTTOM
|
|
53
|
+
of WRAM starting `$C000`. A `volatile uint8_t *board = (uint8_t*)0xC000;`
|
|
54
|
+
then scribbles right over `static uint32_t rng = ...;` et al. Symptom
|
|
55
|
+
looks exactly like an SDCC *codegen* bug — e.g. a 32-bit xorshift PRNG
|
|
56
|
+
that "degenerates" so every roll is identical (its seed is being
|
|
57
|
+
clobbered, not miscompiled). **Use a `static` array and let the linker
|
|
58
|
+
place it** (`static uint8_t board[78]; board[i]=p;`), or hardcode at
|
|
59
|
+
`$C200`+ and confirm with the linker map (`build({includeSymbols:true})`
|
|
60
|
+
→ check `s__DATA`/`s__BSS`). Full write-up + repro in
|
|
61
|
+
`lib/c/SDCC_GOTCHAS.md` § "sm83 codegen traps in plain game logic".
|
|
62
|
+
|
|
50
63
|
- **Two VRAM banks** (switched via VBK at $FF4F) — bank 0 holds tile
|
|
51
64
|
pattern data, bank 1 holds per-tile BG attributes (palette index,
|
|
52
65
|
H/V flip, priority, tile bank).
|