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.
@@ -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 'Horizontal scrolling'). Extend with enemies, goals, pickups.",
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`).
@@ -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 && regionsByPlatform[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
- "OP CHEAT-SHEET: resolve {dbg|map, name}; lookup {dbg|map, address}; map {dbg|map, platform?}; list {dbg|map, max?}; " +
362
- "addr {pc, symbolsText|symbolsPath}.\n" +
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: platform id adds per-platform region labels (incl. genesis work-RAM mirror)."),
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
- switch (args.op) {
391
- case "resolve": return jsonContent(await resolveSymbolCore(args));
392
- case "lookup": return jsonContent(await lookupAddressCore(args));
393
- case "map": return jsonContent(await getMemoryMapCore(args));
394
- case "list": return jsonContent(await listSymbolsCore(args));
395
- case "addr": return jsonContent(await addressToSymbolCore(args));
396
- default: throw new Error(`symbols: unknown op '${args.op}'`);
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`).\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`). **`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
- 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)."),
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
- // dmaExact + traceVramSourceCore are reached via watch({on:'dma'}) above —
1136
- // dmaTrace was folded into `watch` (it's a log-all VDP-DMA trace, same family
1137
- // as on:'mem'/'range'/'pc'), so there's no separate top-level tool.
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).