romdevtools 0.22.1 → 0.24.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.
Files changed (54) hide show
  1. package/AGENTS.md +169 -494
  2. package/CHANGELOG.md +103 -0
  3. package/examples/genesis/templates/platformer.c +5 -1
  4. package/examples/genesis/templates/two_plane_parallax.c +166 -0
  5. package/package.json +2 -2
  6. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  7. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  8. package/src/host/LibretroHost.js +225 -2
  9. package/src/host/framebuffer.js +37 -0
  10. package/src/http/skill-doc.js +1 -1
  11. package/src/mcp/tools/audio.js +2 -2
  12. package/src/mcp/tools/frame.js +13 -34
  13. package/src/mcp/tools/index.js +2 -2
  14. package/src/mcp/tools/input-layout.js +10 -0
  15. package/src/mcp/tools/input.js +26 -2
  16. package/src/mcp/tools/metasprite-tools.js +1 -1
  17. package/src/mcp/tools/platform-tools.js +18 -11
  18. package/src/mcp/tools/playtest.js +17 -2
  19. package/src/mcp/tools/project.js +9 -1
  20. package/src/mcp/tools/rendering-context.js +1 -1
  21. package/src/mcp/tools/symbols.js +130 -39
  22. package/src/mcp/tools/tile-inspect.js +1 -1
  23. package/src/mcp/tools/toolchain.js +3 -2
  24. package/src/mcp/tools/watch-memory.js +58 -6
  25. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
  26. package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
  27. package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
  28. package/src/platforms/c64/MENTAL_MODEL.md +83 -6
  29. package/src/platforms/gb/MENTAL_MODEL.md +74 -0
  30. package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +91 -0
  31. package/src/platforms/gba/MENTAL_MODEL.md +57 -3
  32. package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
  33. package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
  34. package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
  35. package/src/platforms/gbc/MENTAL_MODEL.md +34 -0
  36. package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +91 -0
  37. package/src/platforms/genesis/MENTAL_MODEL.md +180 -0
  38. package/src/platforms/genesis/TROUBLESHOOTING.md +32 -0
  39. package/src/platforms/genesis/lib/c/libc.a +0 -0
  40. package/src/platforms/genesis/lib/c/libgcc.a +0 -0
  41. package/src/platforms/genesis/lib/c/libm.a +0 -0
  42. package/src/platforms/gg/MENTAL_MODEL.md +24 -0
  43. package/src/platforms/gg/lib/c/gg_crt0.s +30 -0
  44. package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
  45. package/src/platforms/msx/MENTAL_MODEL.md +27 -0
  46. package/src/platforms/nes/MENTAL_MODEL.md +35 -0
  47. package/src/platforms/sms/MENTAL_MODEL.md +51 -0
  48. package/src/platforms/sms/lib/c/sms_crt0.s +40 -0
  49. package/src/platforms/snes/MENTAL_MODEL.md +21 -0
  50. package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
  51. package/src/playtest/playtest.js +48 -0
  52. package/src/toolchains/sdcc/preflight-lint.js +164 -8
  53. package/examples/msx/catch_game/_verify.mjs +0 -93
  54. package/examples/pce/catch_game/_verify.mjs +0 -75
@@ -267,6 +267,57 @@ Loadable via genesis_plus_gx (`loadMedia`).
267
267
  - Game Gear-specific buttons add port $00 read (Start) and stereo
268
268
  PSG control on port $06.
269
269
 
270
+ ## MCP debug & inspection tooling
271
+
272
+ SMS and Game Gear share the genesis_plus_gx (gpgx, patched) core, so the
273
+ inspectors below behave identically on both. This section is the **canonical
274
+ shared reference**; the Game Gear MENTAL_MODEL points back here and only lists
275
+ its own deltas (12-bit CRAM, etc.). Reach for these when something renders
276
+ wrong and you can't see why from the source alone — they read the *live* core
277
+ state, which beats trusting comments.
278
+
279
+ - **`sprites({op:'inspect'})`** — decodes the live SAT (sprite attribute
280
+ table) and renders a sprite-sheet PNG. Reports each slot's X/Y/tile plus
281
+ `spriteTileDataBase` (the VRAM address the VDP is actually fetching sprite
282
+ tiles from). This is the tool that catches the $D0-terminator and the
283
+ R6/$2000-vs-$0000 sprite-bank footguns described above — trust its bytes
284
+ over any comment.
285
+ - **`palette({source:'live'})`** — reads CRAM and converts to RGB. On SMS
286
+ that's **6-bit BGR** (2-2-2, 32 bytes). On Game Gear it's **12-bit BGR**
287
+ (4-4-4, 64 bytes) — see the GG MENTAL_MODEL for that delta.
288
+ - **`tiles({op:'png'})`** — dumps VRAM tile patterns as a sheet. The format
289
+ is 4bpp bitplane-interleaved (32 bytes/tile); the 16 KB VRAM renders as a
290
+ 512-tile sheet.
291
+ - **`cpu({op:'read'})`** — Z80 register dump: A/F, BC/DE/HL, IX/IY, the
292
+ shadow register set, the flag bits, and interrupt state (IM mode / IFF).
293
+ - **`audioDebug({op:'inspect', chip:'psg'})`** — decodes the live SN76489:
294
+ 3 tone channels + 1 noise channel. The PSG region is shared by SMS, GG, and
295
+ Genesis (same gpgx region).
296
+ - **`background({view:'renderState'})`** — reads the VDP registers and reports
297
+ the derived addresses (name table, BG-tile base, sprite-tile base, SAT base),
298
+ the scroll registers, and the display-enable / mode state. Use this to
299
+ confirm the R2/R4/R5/R6 baseline matches where you actually uploaded data.
300
+
301
+ ### Memory regions (`memory({op:'read', region:…})`)
302
+
303
+ | Region | Contents |
304
+ |-----------------|------------------------------------------------------|
305
+ | `sms_vram` | 16 KB VRAM — tiles + name table + SAT + sprite tiles |
306
+ | `sms_cram` | 32-byte palette (2-2-2 BGR) |
307
+ | `sms_vdp_regs` | the 11 VDP control registers ($00-$0A) |
308
+ | `sms_z80_regs` | Z80 register snapshot |
309
+ | `gg_vram` | Game Gear VRAM |
310
+ | `gg_cram` | Game Gear 64-byte palette (4-4-4 BGR) |
311
+
312
+ ### Disassembly (`disasm({target:…})`)
313
+
314
+ `disasm({target:'rom'})`, `disasm({target:'references'})`, and
315
+ `disasm({target:'project'})` all run the live ROM through the native binutils
316
+ **z80 `objdump`** (WASM, `-m z80`). It has full prefix coverage —
317
+ CB/ED/DD/FD/DDCB/FDCB — and feeds the same auto-label, register-annotation,
318
+ file-offset, and `untilReturn` pipeline used by the NES and SNES
319
+ disassemblers.
320
+
270
321
  ## Horizontal scrolling (for side-scrollers)
271
322
 
272
323
  The `platformer` scaffold is single-screen. To make it a side-scroller:
@@ -22,6 +22,8 @@
22
22
  .globl l__INITIALIZER
23
23
  .globl s__INITIALIZER
24
24
  .globl s__INITIALIZED
25
+ .globl s__DATA
26
+ .globl l__DATA
25
27
 
26
28
  ;; ─── Reset vector at $0000 ────────────────────────────────────────
27
29
  .area _HEADER (ABS)
@@ -72,7 +74,19 @@
72
74
  ;; call main. The initializer area is filled by sdcc when it sees
73
75
  ;; global initializations.
74
76
 
77
+ ;; AREA ORDERING IS LOad-BEARING. `_INITIALIZER` (the ROM image of
78
+ ;; every value-initialised `static` global) MUST be declared in the
79
+ ;; ROM group here — BEFORE the `_DATA` RAM block. If it isn't, sdld
80
+ ;; places `_INITIALIZER` in RAM right after `_INITIALIZED`, so the
81
+ ;; gsinit copy below copies uninitialised RAM onto itself and every
82
+ ;; `static uint8_t x = 5;` boots as 0. (Bug found 2026-06-08: a GBC
83
+ ;; Columns agent's `static uint32_t rng = 0x1357;` booted as 0, so
84
+ ;; the xorshift PRNG stayed 0 and every "random" roll came out the
85
+ ;; same — a "monochrome RNG" that looked like an SDCC codegen bug
86
+ ;; but was really this missing ROM placement. The sm83 GB crt0 has
87
+ ;; always placed _INITIALIZER in ROM; the z80 crt0s never did.)
75
88
  .area _HOME
89
+ .area _INITIALIZER
76
90
  .area _CODE
77
91
  .area _GSINIT
78
92
  .area _GSFINAL
@@ -86,6 +100,32 @@
86
100
  .area _CODE
87
101
 
88
102
  gsinit:
103
+ ;; ── Zero the BSS segment (`_DATA`). ──────────────────────────
104
+ ;; Every uninitialised `static` global lands in `_DATA` and MUST
105
+ ;; read back 0 at boot. Without this, `static uint8_t flag;` boots
106
+ ;; with whatever power-on WRAM byte was there (gambatte/gpgx leave
107
+ ;; garbage), and `if (flag)` spuriously fires. Mirrors the sm83 GB
108
+ ;; crt0's gsinit_data loop.
109
+ ld bc, #l__DATA
110
+ ld a, b
111
+ or a, c
112
+ jr Z, gsinit_bss_done
113
+ ld hl, #s__DATA
114
+ ld (hl), #0x00
115
+ ld d, h
116
+ ld e, l
117
+ inc de
118
+ dec bc
119
+ ld a, b
120
+ or a, c
121
+ jr Z, gsinit_bss_done
122
+ ldir ; propagate the 0 across _DATA
123
+ gsinit_bss_done:
124
+
125
+ ;; ── Copy `_INITIALIZER` (ROM) → `_INITIALIZED` (RAM). ────────
126
+ ;; The value-initialised-statics path: `static uint8_t lives = 3;`
127
+ ;; lives in _INITIALIZED at runtime; its initial value sits in
128
+ ;; _INITIALIZER in ROM (now correctly ROM-placed, see above).
89
129
  ld bc, #l__INITIALIZER
90
130
  ld a, b
91
131
  or a, c
@@ -208,6 +208,27 @@ per-voice vol/pitch/ADSR + `env` (0 = silent regardless of vol) + `bufLastSample
208
208
  produced output" from "muted by mixer." GOTCHA: S-DSP FLG is $6C, KOFF is $5C
209
209
  (many refs swap them); power-on FLG=$E0 means your driver MUST clear bit 6.
210
210
 
211
+ ## MCP debug & inspection tooling
212
+
213
+ The shipped snes9x core is patched for deep introspection — both audio and
214
+ video are fully readable, so you assert live state instead of guessing:
215
+
216
+ - **Sprites:** `sprites({op:'inspect'})` decodes live OAM (per-sprite
217
+ renderable/hidden, resolved tile VRAM addr, CGRAM palette range, and the
218
+ uninitialized-OBJ-palette warning described under "The OBJ stable-path
219
+ recipe").
220
+ - **Palette:** `palette({source:'live'})` reads live CGRAM.
221
+ - **CPUs:** `cpu({op:'read', cpu:'main'})` for the 65816, `cpu({op:'read',
222
+ cpu:'spc700'})` for the sound CPU.
223
+ - **Audio:** the S-DSP is fully decodable — full per-voice state plus the
224
+ master mixer (see "Debugging sound" above for `audioDebug`).
225
+ - **Memory regions:** `memory({op:'read'})` exposes OAM, CGRAM, ARAM (SPC700
226
+ audio RAM), and **FillRAM**. Note the FillRAM quirk: snes9x mirrors the
227
+ PPU registers $2100-$213F (OBSEL/BGMODE/TM/TS/color-math, etc.) into
228
+ FillRAM indexed by the FULL address (e.g. `FillRAM[0x2101]` = OBSEL), so
229
+ the PPU register state is readable through the `snes_fillram` region — no
230
+ core patch needed.
231
+
211
232
  ## ROM layout (LoROM)
212
233
 
213
234
  ```
@@ -193,6 +193,49 @@ Expected. tcc-65816.wasm + wla-65816.wasm cold-load + compile takes
193
193
  1-2s. Subsequent builds reuse the warm worker pool (R12 crash
194
194
  isolation infrastructure). Steady-state builds are sub-second.
195
195
 
196
+ ## "asm build fails with `ok:false` and an empty/cryptic `issues[]`" (asar idioms)
197
+
198
+ The `language:"asm"` SNES path runs through **asar**, which **silently crashes**
199
+ (a heap-pointer Emscripten exit code, no diagnostic) on a handful of source
200
+ idioms. romdev's asar wrapper has a preflight that catches the common cases and,
201
+ when asar dies with `ok:false` and no parsed errors, retries with `--verbose` and
202
+ synthesizes a fallback `issues[]` entry with a hint. The idioms to avoid:
203
+
204
+ - **`$ - label` size expressions** (current-PC minus a label) crash asar. Use an
205
+ explicit `end_label - start_label` difference instead:
206
+ ```asm
207
+ ; WRONG — crashes asar silently
208
+ my_size = $ - my_data
209
+ ; RIGHT
210
+ my_data_end:
211
+ my_size = my_data_end - my_data
212
+ ```
213
+ - **Opcode + operand arithmetic on an `=`-defined symbol**, e.g.
214
+ `STA SYMBOL + N` where `SYMBOL` is a `=` constant (not a label), can also crash
215
+ silently. The preflight flags the label-arithmetic-across-bank case
216
+ (`label-arithmetic constant`); for the rest, if you see `ok:false` with no real
217
+ error, suspect a `SYMBOL + N` and rewrite to a plain label or pre-compute the
218
+ address.
219
+ - **Bank-border crossed.** If your `org` + `dw`/data runs past `$00FFFF` you've
220
+ crossed a bank boundary and the layout is wrong. Native interrupt vectors live
221
+ at `$FFE4-$FFEE`, emulation vectors at `$FFF4-$FFFF` — keep your header/vector
222
+ block where the layout expects it. Use
223
+ `scaffold({op:'snippets', platform:"snes", mode:"get", name:"lorom_header.asm"})`
224
+ for the canonical layout (and `lorom_multibank.asm` for multi-bank).
225
+
226
+ (This is the asar/asm path. The default PVSnesLib **C** path goes through
227
+ tcc-65816 + wla-65816 and has its own C89 trap, above.)
228
+
229
+ ## "CHR and tilemap overlap in VRAM / background renders as garbage tiles"
230
+
231
+ SNES CHR (tile patterns) and the BG tilemap share the 64 KB VRAM, addressed in
232
+ **words**. CHR starts at word `$0000`; if your CHR is 16 KB it occupies words
233
+ `$0000-$1FFF`, so a tilemap placed at word `$2000` collides with the tail of CHR
234
+ and you get garbage. **Put the tilemap at word `$4000` or later when your CHR is
235
+ big.** Verify the bases with `background({view:'renderState', platform:'snes'})`
236
+ (it decodes the BG char/map base) and cross-check the tile region with
237
+ `tiles({op:'png'})` at the CHR base.
238
+
196
239
  ## "I want to use real graphics from a PNG"
197
240
 
198
241
  `gfx4snes` (the PVSnesLib companion tool) converts PNG → .pic + .pal.
@@ -231,6 +231,32 @@ const KEY_TO_LIBRETRO_BIT = {
231
231
  w: 11, // R shoulder
232
232
  };
233
233
 
234
+ // C64-only keyboard fallback: PC key → the virtual C64 button name the host's
235
+ // C64 layer maps to the key matrix (Space/Run-Stop/Return/F1-F7). Lets a human
236
+ // with NO controller still reach the C64 keyboard keys games need to start.
237
+ // (Arrows + Z give the joystick + Fire via KEY_TO_LIBRETRO_BIT above.)
238
+ const C64_KEYBOARD_FALLBACK = {
239
+ f1: "c64_f1", f2: "c64_f3", f3: "c64_f5", f4: "c64_f7", // F1-F4 → C64 F1/F3/F5/F7
240
+ space: "west", // Space
241
+ return: "r2", // Return (also START via the standard map — harmless)
242
+ escape: "l2", // Run/Stop (note: ESC also closes — see handler order)
243
+ };
244
+
245
+ // Human-readable C64 controls (controller + keyboard), relayed to the user when
246
+ // a C64 game is in the playtest window so they're not guessing.
247
+ export const C64_BINDINGS_HELP = `C64 — a CONTROLLER alone is enough (no keyboard needed):
248
+ D-pad / Left stick Joystick (port 2 by default)
249
+ Z / bottom face Fire
250
+ X face / Space key Space
251
+ L2 Run/Stop
252
+ R2 / Enter Return
253
+ Right stick ↑/←/→/↓ F1 / F3 / F5 / F7 (the 1-player / start keys)
254
+ Top face F1 (also)
255
+
256
+ No controller? Keyboard fallback: Arrows = joystick, Z = Fire, F1-F4 = C64
257
+ F1/F3/F5/F7, Space = Space, Enter = Return, ESC = Run/Stop (hold; ESC tapped
258
+ also closes the window). Switch joystick port with input({op:'joyport'}).`;
259
+
234
260
  // Human-readable summary printed by --help and at playtest startup.
235
261
  export const KEYBOARD_BINDINGS_HELP = `Keyboard:
236
262
  Arrow keys D-pad
@@ -578,6 +604,7 @@ export async function playtest(args) {
578
604
  // into its own port object; the agent's setInput is overwritten each
579
605
  // tick (matching prior behavior). Select+Start on any controller quits.
580
606
  let quit = false;
607
+ const isC64 = h.status?.platform === "c64";
581
608
  function readControllerInto(port, inst) {
582
609
  if (!inst) return;
583
610
  const btn = inst.buttons || {};
@@ -595,6 +622,18 @@ export async function playtest(args) {
595
622
  else if (lx < -STICK_DEADZONE) port.left = true;
596
623
  if (ly > STICK_DEADZONE) port.down = true;
597
624
  else if (ly < -STICK_DEADZONE) port.up = true;
625
+ // C64: the RIGHT stick selects the function keys (F1/F3/F5/F7) — the
626
+ // Batocera/RetroDeck convention so a controller alone reaches the keyboard
627
+ // keys C64 setup screens need. Emitted as virtual buttons the host's C64
628
+ // layer maps to the key matrix; harmless on other platforms (no mapping).
629
+ if (isC64) {
630
+ const rx = axes.rightStickX ?? 0;
631
+ const ry = axes.rightStickY ?? 0;
632
+ if (ry < -STICK_DEADZONE) port.c64_f1 = true; // up → F1
633
+ else if (ry > STICK_DEADZONE) port.c64_f7 = true; // down → F7
634
+ if (rx < -STICK_DEADZONE) port.c64_f3 = true; // left → F3
635
+ else if (rx > STICK_DEADZONE) port.c64_f5 = true; // right → F5
636
+ }
598
637
  }
599
638
 
600
639
  const port0 = {};
@@ -619,6 +658,15 @@ export async function playtest(args) {
619
658
  for (const [keyName, bit] of Object.entries(KEY_TO_LIBRETRO_BIT)) {
620
659
  if (heldKeys.has(keyName)) port0[bitToName(bit)] = true;
621
660
  }
661
+ // C64 keyboard fallback (no controller / mixing): map PC keys to the C64
662
+ // KEYBOARD keys games need — the host's C64 layer routes these virtual
663
+ // button names to the key matrix. (Arrows + Z=Fire already give the
664
+ // joystick above.) The agent relays these to the human.
665
+ if (isC64) {
666
+ for (const [keyName, vbtn] of Object.entries(C64_KEYBOARD_FALLBACK)) {
667
+ if (heldKeys.has(keyName)) port0[vbtn] = true;
668
+ }
669
+ }
622
670
  const isRewinding = heldKeys.has("r") && rewindBuffer.length > 0;
623
671
  if (isRewinding) {
624
672
  // Restore the previous snapshot and run one frame to produce its visual.
@@ -152,12 +152,29 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
152
152
  }
153
153
 
154
154
  // ─── __xdata / VRAM byte-copy miscompile ────────────────────────
155
- // SDCC sm83 miscompiles `for (i...) dst[i] = src[i];` when dst is an
155
+ // SDCC sm83 miscompiles `for (i...) dst[i] = src[i];` ONLY when dst is an
156
156
  // __xdata pointer (e.g. into VRAM $8000) — it writes through the return
157
- // address and crashes the CPU. Hard to prove the pointer target
158
- // statically, so flag the SHAPE (an array-index copy `a[i] = b[i];`
159
- // inside a for-loop) as ADVISORY, pointing at memcpy_vram. Conservative:
160
- // require a for-loop on the line/just above + an indexed-to-indexed copy.
157
+ // address and crashes the CPU. A plain WRAM array copy (`static uint8_t
158
+ // rb[78]; ... rb[i]=grid[i];`) is perfectly fine. The old lint flagged the
159
+ // SHAPE unconditionally as a "warning" every WRAM copy in every genre
160
+ // scaffold cried wolf, training agents to distrust the linter. We now
161
+ // classify the DESTINATION identifier before deciding the severity:
162
+ //
163
+ // • PROVABLY VRAM/__xdata → "warning" (the real crash-class footgun)
164
+ // - dst is declared as a POINTER (`type *dst`) — only pointers can
165
+ // alias __xdata; an indexed write through one is the bug.
166
+ // - dst is a known-VRAM name (vram*, VRAM, *vram*, bgmap, _VRAM*).
167
+ // - dst is assigned from a cast/literal in $8000-$9FFF anywhere in
168
+ // the source (e.g. `dst = (uint8_t*)0x9800;`).
169
+ // • PLAIN RAM ARRAY → SUPPRESS (declared `type dst[N];` here).
170
+ // • UNKNOWN (bare ident, → "info" — visible, not scary. Better to
171
+ // no decl in this TU) occasionally downgrade a real VRAM case to
172
+ // info than to keep crying wolf on WRAM.
173
+ //
174
+ // The crash cases that MATTER (the documented memcpy_vram footgun) are
175
+ // pointer-to-VRAM, which the "provably VRAM" path still catches as a
176
+ // warning.
177
+ const dstClass = classifyCopyDest(lines);
161
178
  for (let i = 0; i < lines.length; i++) {
162
179
  const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
163
180
  // indexed-to-indexed copy: ident[idx] = ident[idx]; (same index token)
@@ -168,17 +185,72 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
168
185
  // Require a for-loop driving this copy (this line or the 2 above).
169
186
  const ctx = (lines[i] + "\n" + (lines[i - 1] || "") + "\n" + (lines[i - 2] || ""));
170
187
  if (!/\bfor\s*\(/.test(ctx)) continue;
188
+ const dst = cp[1];
189
+ // A VRAM-suggestive NAME promotes an otherwise-unknown dest to VRAM even
190
+ // with no decl in this TU (e.g. `vram_buf[i] = tiles[i];`). A declared
191
+ // plain array still wins as "array" (suppress) — names rarely collide.
192
+ const klass = dstClass.get(dst) || (isVramName(dst) ? "vram" : "unknown");
193
+ if (klass === "array") continue; // plain WRAM array — provably safe, suppress
194
+ const isVram = klass === "vram";
171
195
  issues.push({
172
- severity: "warning",
196
+ // Provably-VRAM → warning (the crash-class footgun). Unknown bare
197
+ // pointer → info (visible, not "your code is broken"). Never critical:
198
+ // even the VRAM case only miscompiles, it isn't an unconditional hang.
199
+ severity: isVram ? "warning" : "info",
173
200
  file,
174
201
  line: i + 1,
175
202
  stage: "lint",
176
- message: `byte-copy loop \`${cp[1]}[${idx1}] = ${cp[3]}[${idx2}]\` — miscompiles if the destination is VRAM/__xdata`,
177
- details: `${portLabel} miscompiles this pattern when '${cp[1]}' points into VRAM ($8000-$9FFF) or another __xdata region — it writes through the return address and crashes the CPU (PC near $002B, sprites/tiles never show). If '${cp[1]}' is a VRAM/__xdata pointer, use \`memcpy_vram(${cp[1]}, ${cp[3]}, n)\` (in gb_runtime.c) instead. Ignore if '${cp[1]}' is plain WRAM/an array. See GB TROUBLESHOOTING § the #1 SDCC footgun.`,
203
+ message: isVram
204
+ ? `byte-copy loop \`${dst}[${idx1}] = ${cp[3]}[${idx2}]\` into VRAM/__xdata ${portLabel} miscompiles this`
205
+ : `byte-copy loop \`${dst}[${idx1}] = ${cp[3]}[${idx2}]\` — safe for WRAM arrays, but miscompiles if '${dst}' points into VRAM/__xdata`,
206
+ details: isVram
207
+ ? `${portLabel} miscompiles this pattern when '${dst}' points into VRAM ($8000-$9FFF) or another __xdata region — it writes through the return address and crashes the CPU (PC near $002B, sprites/tiles never show). Use \`memcpy_vram(${dst}, ${cp[3]}, n)\` (in gb_runtime.c) instead. See GB TROUBLESHOOTING § the #1 SDCC footgun.`
208
+ : `If '${dst}' is a plain WRAM array (\`type ${dst}[N];\`) this is FINE — ignore. ${portLabel} only miscompiles it when '${dst}' is a pointer into VRAM ($8000-$9FFF)/__xdata, where it writes through the return address and crashes the CPU. If '${dst}' is a VRAM pointer, use \`memcpy_vram(${dst}, ${cp[3]}, n)\` instead. See GB TROUBLESHOOTING § the #1 SDCC footgun.`,
178
209
  ref: "xdata-copy-miscompile",
179
210
  });
180
211
  }
181
212
 
213
+ // ─── hardcoded $C000-area WRAM pointer overlaps the C statics ───
214
+ // SDCC links `_DATA`/`_INITIALIZED` (value-init statics) + `_BSS` (zero-init
215
+ // statics) at the BOTTOM of WRAM starting $C000. A program that ALSO pokes a
216
+ // hardcoded pointer into that low range (e.g. `(uint8_t*)0xC000`) scribbles
217
+ // over its own statics — the seed of a PRNG, a collision grid, the score —
218
+ // and the symptom looks EXACTLY like an SDCC codegen bug (a 32-bit xorshift
219
+ // that "degenerates" because its seed got clobbered, never a real
220
+ // miscompile). This was the real root cause behind a GBC Columns agent's
221
+ // "monochrome RNG" report (2026-06-08); the math itself compiles correctly.
222
+ //
223
+ // ONLY for the sm83/z80 GB/SMS-family, whose WRAM base is $C000. Flag the
224
+ // low 256 bytes ($C000-$C0FF) where _DATA/_INITIALIZED live (small projects'
225
+ // statics sit here; $C100 is shadow_oam; $C200+ is the documented-safe
226
+ // scratch floor). INFO severity — visible, not "your code is broken": a
227
+ // hardcoded low pointer is occasionally legitimate (e.g. you've checked the
228
+ // map). NEVER critical.
229
+ if (port === "sm83" || port === "z80") {
230
+ // pointer cast/decl/assignment to a $C0xx literal: `(uint8_t*)0xC000`,
231
+ // `uint8_t *p = (uint8_t*)0xc010;`, `p = 0xC0FF;`
232
+ const ptrLit = /\(\s*(?:volatile\s+|const\s+|unsigned\s+|signed\s+)*[A-Za-z_]\w*\s*\*+\s*\)\s*0x(C0[0-9a-fA-F]{2})\b/;
233
+ const seen = new Set();
234
+ for (let i = 0; i < lines.length; i++) {
235
+ const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
236
+ const m = code.match(ptrLit);
237
+ if (!m) continue;
238
+ const addr = parseInt(m[1], 16); // already the full $C0xx address
239
+ const key = i + ":" + addr;
240
+ if (seen.has(key)) continue;
241
+ seen.add(key);
242
+ issues.push({
243
+ severity: "info",
244
+ file,
245
+ line: i + 1,
246
+ stage: "lint",
247
+ message: `hardcoded WRAM pointer $${addr.toString(16).toUpperCase()} overlaps the C static-data segment ($C000-)`,
248
+ details: `${portLabel} links your value- and zero-initialised \`static\` globals (PRNG seeds, grids, scores) at the BOTTOM of WRAM from $C000. A hardcoded pointer into $C000-$C0FF can scribble over them — the classic symptom is a PRNG/array that looks "miscompiled" (e.g. an xorshift whose seed got clobbered so every roll is identical) when the math is actually fine. Prefer a \`static\` array and let the linker place it; if you must hardcode, use $C200+ and verify with the linker map (build with includeSymbols:true → check s__DATA/s__BSS). $C100 is shadow_oam. See ${port === "sm83" ? "GB/GBC" : "SMS/GG"} SDCC_GOTCHAS.md § "sm83 codegen traps in plain game logic".`,
249
+ ref: "wram-static-overlap",
250
+ });
251
+ }
252
+ }
253
+
182
254
  // Mid-block declarations (rough heuristic — flags any `type name [=...] ;`
183
255
  // that appears after a non-decl, non-blank statement at deeper indent
184
256
  // than the function opening brace).
@@ -206,6 +278,90 @@ export function lintSources(sources, opts = {}) {
206
278
 
207
279
  // ─── helpers ───────────────────────────────────────────────────────
208
280
 
281
+ /**
282
+ * Known-VRAM symbol names (GB/GBC/SMS conventions). Case-insensitive;
283
+ * substring "vram" matches vram_ptr / pVRAM / VRAMbase, plus the common
284
+ * GB BG-map / tile-data symbols. A dest with such a name is treated as a
285
+ * VRAM pointer even with no visible declaration.
286
+ * @param {string} n
287
+ * @returns {boolean}
288
+ */
289
+ function isVramName(n) {
290
+ return /vram/i.test(n) || /^(?:bgmap|tilemap|tiledata|chrram|_VRAM)/i.test(n);
291
+ }
292
+
293
+ /**
294
+ * Classify each identifier that is the destination of a copy loop into one
295
+ * of: "vram" (provably a VRAM/__xdata pointer → real crash-class footgun),
296
+ * "array" (declared as a plain `type name[N];` RAM array → provably safe,
297
+ * suppress the warning), or absent (unknown — caller treats as "info").
298
+ *
299
+ * This is a whole-TU pass: a name is classified by scanning the ENTIRE
300
+ * source, so a `uint8_t *dst;` decl far above the loop, or a later
301
+ * `dst = (uint8_t*)0x9800;` assignment, still classifies it as VRAM.
302
+ *
303
+ * Precedence: VRAM wins over array (a name that is BOTH a pointer and,
304
+ * say, shadowed by an array elsewhere should still be treated as the
305
+ * dangerous case — but in practice a single name is one or the other).
306
+ *
307
+ * @param {string[]} lines source split into lines
308
+ * @returns {Map<string,"vram"|"array">}
309
+ */
310
+ function classifyCopyDest(lines) {
311
+ /** @type {Map<string,"vram"|"array">} */
312
+ const klass = new Map();
313
+ const setVram = (n) => { klass.set(n, "vram"); };
314
+ const setArray = (n) => { if (klass.get(n) !== "vram") klass.set(n, "array"); };
315
+
316
+ // A literal/cast value lands in VRAM if it's 0x8000–0x9FFF.
317
+ const inVramRange = (hexOrDec) => {
318
+ const v = /^0x/i.test(hexOrDec) ? parseInt(hexOrDec, 16) : parseInt(hexOrDec, 10);
319
+ return Number.isFinite(v) && v >= 0x8000 && v <= 0x9fff;
320
+ };
321
+ // Type keywords that introduce a declaration (subset is fine — we only
322
+ // need to tell "pointer decl" from "array decl").
323
+ const TYPE = "(?:unsigned\\s+|signed\\s+)?(?:char|short|int|long|void|u?int(?:8|16|32|64)_t|u8|u16|u32|u64|uint8|uint16|uint32|uint64|int8|int16|int32|int64|size_t|[A-Z][A-Za-z0-9_]*_t)";
324
+ const QUAL = "(?:static\\s+|const\\s+|register\\s+|volatile\\s+|extern\\s+|auto\\s+|__xdata\\s+|__at\\s*\\([^)]*\\)\\s*)*";
325
+ // Pointer declaration: `<quals> <type> * name` (one or more `*`).
326
+ const ptrDeclRe = new RegExp(`\\b${QUAL}${TYPE}\\s*\\*+\\s*([A-Za-z_]\\w*)`, "g");
327
+ // Array declaration: `<quals> <type> name[ ... ]` (NOT a pointer).
328
+ const arrDeclRe = new RegExp(`\\b${QUAL}${TYPE}\\s+([A-Za-z_]\\w*)\\s*\\[`, "g");
329
+ // Pointer assigned a VRAM literal/cast: name = (cast?) 0x8xxx/0x9xxx
330
+ // e.g. dst = (uint8_t*)0x9800; p = (void*)0x8000; q = 0x8800;
331
+ const vramAssignRe = /\b([A-Za-z_]\w*)\s*=\s*(?:\([^)]*\)\s*)?(0x[0-9a-fA-F]+|\d{4,})\b/g;
332
+ // Pointer DECL with an inline VRAM initializer:
333
+ // uint8_t *dst = (uint8_t*)0x8000;
334
+ const ptrInitVramRe = new RegExp(`\\b${QUAL}${TYPE}\\s*\\*+\\s*([A-Za-z_]\\w*)\\s*=\\s*(?:\\([^)]*\\)\\s*)?(0x[0-9a-fA-F]+|\\d{4,})`, "g");
335
+
336
+ for (let i = 0; i < lines.length; i++) {
337
+ const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
338
+
339
+ // 1) pointer decl with a VRAM-range initializer → VRAM (strongest).
340
+ ptrInitVramRe.lastIndex = 0;
341
+ for (let m; (m = ptrInitVramRe.exec(code)); ) {
342
+ if (inVramRange(m[2])) setVram(m[1]);
343
+ }
344
+ // 2) any name assigned a VRAM-range literal/cast → VRAM.
345
+ vramAssignRe.lastIndex = 0;
346
+ for (let m; (m = vramAssignRe.exec(code)); ) {
347
+ if (inVramRange(m[2])) setVram(m[1]);
348
+ }
349
+ // 3) pointer declarations → VRAM-candidate (only pointers can alias
350
+ // __xdata; an indexed write through one is the documented bug).
351
+ ptrDeclRe.lastIndex = 0;
352
+ for (let m; (m = ptrDeclRe.exec(code)); ) setVram(m[1]);
353
+ // 4) plain array declarations → safe RAM array (unless already VRAM).
354
+ arrDeclRe.lastIndex = 0;
355
+ for (let m; (m = arrDeclRe.exec(code)); ) setArray(m[1]);
356
+ }
357
+
358
+ // 5) name-based VRAM override: any dest named like VRAM is VRAM even if
359
+ // it was (mis)classified as an array by a same-named decl elsewhere.
360
+ for (const n of klass.keys()) if (isVramName(n)) setVram(n);
361
+
362
+ return klass;
363
+ }
364
+
209
365
  /**
210
366
  * Detect mid-block variable declarations (C89 violation). Simple state
211
367
  * machine: track block depth + whether we've seen a non-decl statement
@@ -1,93 +0,0 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { buildForPlatform } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/toolchains/index.js";
3
- import { resolveCore } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/cores/registry.js";
4
- import { LibretroHost } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/host/LibretroHost.js";
5
-
6
- const LIBDIR = "/home/monteslu/code/cliemu/romdev/packages/romdev/src/platforms/msx/lib/c";
7
- const DIR = "/home/monteslu/code/cliemu/romdev/packages/romdev/examples/msx/catch_game";
8
-
9
- const SRC = readFileSync(`${DIR}/main.c`, "utf8");
10
- const LIB = readFileSync(`${LIBDIR}/msx_vdp.c`, "utf8");
11
- const CRT0 = readFileSync(`${LIBDIR}/msx_crt0.s`, "utf8");
12
- const HDR = readFileSync(`${LIBDIR}/msx_hw.h`, "utf8");
13
-
14
- const PLATFORM = "msx";
15
-
16
- console.log("== building ==");
17
- const build = await buildForPlatform({
18
- platform: PLATFORM,
19
- sources: { "main.c": SRC, "msx_vdp.c": LIB, "msx_crt0.s": CRT0 },
20
- includes: { "msx_hw.h": HDR },
21
- crt0: ".module empty\n",
22
- sourceName: "main.c",
23
- });
24
-
25
- if (!build.binary) {
26
- console.log(build.log || build.stderr || JSON.stringify(build));
27
- console.log("BUILD FAILED stage=", build.stage, "exit=", build.exitCode);
28
- process.exit(1);
29
- }
30
- console.log("build OK, bytes=", build.binary.length);
31
-
32
- const c = resolveCore(PLATFORM);
33
- const h = new LibretroHost();
34
- await h.loadCore(c.jsPath, c.wasmPath);
35
- await h.loadMedia({ platform: PLATFORM, bytes: build.binary });
36
-
37
- // ---- honest pixel helpers over raw RGBA ----
38
- function nonBlackFraction(rgba) {
39
- let nb = 0;
40
- for (let i = 0; i < rgba.length; i += 4) {
41
- if (rgba[i] > 16 || rgba[i + 1] > 16 || rgba[i + 2] > 16) nb++;
42
- }
43
- return nb / (rgba.length / 4);
44
- }
45
- // Find the brightest-pixel X centroid in a horizontal band of rows (the basket
46
- // rides near the bottom; the basket sprite is white). Returns -1 if no bright px.
47
- function brightCentroidX(frame, y0, y1) {
48
- const { width, height, rgba } = frame;
49
- let sx = 0, n = 0;
50
- for (let y = y0; y < Math.min(y1, height); y++) {
51
- for (let x = 0; x < width; x++) {
52
- const i = (y * width + x) * 4;
53
- // white basket: all channels high
54
- if (rgba[i] > 180 && rgba[i + 1] > 180 && rgba[i + 2] > 180) { sx += x; n++; }
55
- }
56
- }
57
- return n ? sx / n : -1;
58
- }
59
-
60
- // boot past the C-BIOS logo + a few game frames
61
- for (let i = 0; i < 320; i++) h.stepFrames(1);
62
-
63
- const sa = h.screenshotRgba();
64
- writeFileSync(`${DIR}/shot_before.png`, Buffer.from(h.screenshot().pngBase64, "base64"));
65
- // basket band: BASKET_Y=160 in a 192-tall internal buffer; band 150..180
66
- const fracA = nonBlackFraction(sa.rgba);
67
- const basketA = brightCentroidX(sa, Math.floor(sa.height * 0.78), Math.floor(sa.height * 0.95));
68
- console.log(`before: ${sa.width}x${sa.height} nonBlack=${(fracA*100).toFixed(2)}% basketX=${basketA.toFixed(1)}`);
69
-
70
- // Drive RIGHT for many frames
71
- for (let i = 0; i < 120; i++) { h.setInput({ ports: [{ right: true }] }); h.stepFrames(1); }
72
- const sb = h.screenshotRgba();
73
- writeFileSync(`${DIR}/shot_after_right.png`, Buffer.from(h.screenshot().pngBase64, "base64"));
74
- const basketB = brightCentroidX(sb, Math.floor(sb.height * 0.78), Math.floor(sb.height * 0.95));
75
- console.log(`after RIGHT: basketX=${basketB.toFixed(1)}`);
76
-
77
- // Drive LEFT for many frames
78
- for (let i = 0; i < 200; i++) { h.setInput({ ports: [{ left: true }] }); h.stepFrames(1); }
79
- const sc = h.screenshotRgba();
80
- writeFileSync(`${DIR}/shot_after_left.png`, Buffer.from(h.screenshot().pngBase64, "base64"));
81
- const basketC = brightCentroidX(sc, Math.floor(sc.height * 0.78), Math.floor(sc.height * 0.95));
82
- console.log(`after LEFT: basketX=${basketC.toFixed(1)}`);
83
-
84
- // ---- verdict ----
85
- const visible = fracA > 0.005; // playfield is not black
86
- const movedRight = basketB > basketA + 5; // basket moved right with input
87
- const movedLeft = basketC < basketB - 5; // basket moved back left
88
- console.log("VISIBLE:", visible, "MOVED_RIGHT:", movedRight, "MOVED_LEFT:", movedLeft);
89
- if (visible && movedRight && movedLeft) {
90
- console.log("VERIFIED_OK");
91
- } else {
92
- console.log("VERIFY_INCOMPLETE");
93
- }
@@ -1,75 +0,0 @@
1
- import fs from "node:fs";
2
- import { buildForPlatform } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/toolchains/index.js";
3
- import { resolveCore } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/cores/registry.js";
4
- import { LibretroHost } from "/home/monteslu/code/cliemu/romdev/packages/romdev/src/host/LibretroHost.js";
5
-
6
- const LIB = "/home/monteslu/code/cliemu/romdev/packages/romdev/src/platforms/pce/lib/c";
7
- const DIR = "/home/monteslu/code/cliemu/romdev/packages/romdev/examples/pce/catch_game";
8
-
9
- const read = (p) => fs.readFileSync(p, "utf8");
10
-
11
- const sources = {
12
- "main.c": read(DIR + "/main.c"),
13
- "pce_video.c": read(LIB + "/pce_video.c"),
14
- "pce_input.c": read(LIB + "/pce_input.c"),
15
- "pce_sound.c": read(LIB + "/pce_sound.c"),
16
- };
17
- const headers = { "pce_hw.h": read(LIB + "/pce_hw.h") };
18
-
19
- console.log("Building catch_game...");
20
- const build = await buildForPlatform({
21
- platform: "pce",
22
- sources,
23
- headers,
24
- includes: headers,
25
- sourceName: "main.c",
26
- });
27
-
28
- if (!build || !build.binary) {
29
- console.log("BUILD FAILED");
30
- console.log(JSON.stringify(build, null, 2));
31
- process.exit(1);
32
- }
33
- console.log("BUILD OK, binary bytes =", build.binary.length);
34
- if (build.log) console.log("log tail:", String(build.log).slice(-400));
35
-
36
- const PLATFORM = "pce";
37
- const h = new LibretroHost();
38
- const c = resolveCore(PLATFORM);
39
- await h.loadCore(c.jsPath, c.wasmPath);
40
- await h.loadMedia({ platform: PLATFORM, bytes: build.binary });
41
-
42
- // boot
43
- for (let i = 0; i < 120; i++) h.stepFrames(1);
44
-
45
- function nonBlack(shot) {
46
- const buf = Buffer.from(shot.pngBase64, "base64");
47
- // crude: count distinct-ish bytes; rely on file size + later pixel scan
48
- return buf.length;
49
- }
50
-
51
- // screenshot BEFORE input
52
- let shotA = h.screenshot();
53
- fs.writeFileSync(DIR + "/shot_before.png", Buffer.from(shotA.pngBase64, "base64"));
54
- console.log("before:", shotA.width, "x", shotA.height, "png bytes", nonBlack(shotA));
55
-
56
- // drive RIGHT for ~60 frames to move catcher and let fruit fall/catch
57
- for (let i = 0; i < 90; i++) {
58
- h.setInput({ ports: [{ right: true }] });
59
- h.stepFrames(1);
60
- }
61
- // then LEFT for a bit
62
- for (let i = 0; i < 60; i++) {
63
- h.setInput({ ports: [{ left: true }] });
64
- h.stepFrames(1);
65
- }
66
-
67
- let shotB = h.screenshot();
68
- fs.writeFileSync(DIR + "/shot_after.png", Buffer.from(shotB.pngBase64, "base64"));
69
- console.log("after:", shotB.width, "x", shotB.height, "png bytes", nonBlack(shotB));
70
-
71
- // pixel diff: decode both PNGs to raw via the host's framebuffer if available
72
- const same = shotA.pngBase64 === shotB.pngBase64;
73
- console.log("identical PNG?", same);
74
-
75
- console.log("done — see shot_before.png / shot_after.png");