romdevtools 0.16.0 → 0.21.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 (110) hide show
  1. package/AGENTS.md +60 -12
  2. package/CHANGELOG.md +258 -0
  3. package/examples/README.md +2 -0
  4. package/examples/atari2600/templates/platformer.asm +460 -0
  5. package/examples/atari2600/templates/racing.asm +463 -0
  6. package/examples/atari2600/templates/shmup.asm +386 -0
  7. package/examples/atari2600/templates/sports.asm +362 -0
  8. package/examples/atari7800/templates/default.c +49 -5
  9. package/examples/atari7800/templates/platformer.c +43 -4
  10. package/examples/atari7800/templates/puzzle.c +39 -4
  11. package/examples/atari7800/templates/racing.c +39 -4
  12. package/examples/atari7800/templates/shmup.c +40 -2
  13. package/examples/atari7800/templates/sports.c +36 -5
  14. package/examples/c64/templates/platformer.c +19 -5
  15. package/examples/c64/templates/puzzle.c +32 -2
  16. package/examples/c64/templates/shmup.c +28 -2
  17. package/examples/c64/templates/sports.c +30 -2
  18. package/examples/gb/templates/default.c +110 -16
  19. package/examples/gb/templates/platformer.c +25 -4
  20. package/examples/gb/templates/puzzle.c +32 -2
  21. package/examples/gb/templates/racing.c +72 -8
  22. package/examples/gb/templates/shmup.c +38 -1
  23. package/examples/gb/templates/sports.c +48 -1
  24. package/examples/gba/templates/gba_hello.c +29 -11
  25. package/examples/gba/templates/puzzle.c +15 -3
  26. package/examples/gba/templates/racing.c +65 -3
  27. package/examples/gba/templates/shmup.c +41 -4
  28. package/examples/gba/templates/sports.c +36 -2
  29. package/examples/gba/templates/tonc_hello.c +41 -5
  30. package/examples/gbc/templates/default.c +103 -26
  31. package/examples/gbc/templates/platformer.c +25 -4
  32. package/examples/gbc/templates/puzzle.c +32 -2
  33. package/examples/gbc/templates/racing.c +85 -19
  34. package/examples/gbc/templates/shmup.c +34 -1
  35. package/examples/gbc/templates/sports.c +45 -1
  36. package/examples/genesis/templates/puzzle.c +37 -3
  37. package/examples/genesis/templates/racing.c +44 -11
  38. package/examples/genesis/templates/sgdk_hello.c +34 -1
  39. package/examples/genesis/templates/shmup.c +31 -1
  40. package/examples/gg/templates/default.c +56 -18
  41. package/examples/gg/templates/platformer.c +18 -12
  42. package/examples/gg/templates/puzzle.c +38 -7
  43. package/examples/gg/templates/racing.c +51 -5
  44. package/examples/gg/templates/shmup.c +47 -3
  45. package/examples/gg/templates/sports.c +46 -3
  46. package/examples/lynx/templates/default.c +39 -8
  47. package/examples/lynx/templates/puzzle.c +28 -1
  48. package/examples/lynx/templates/racing.c +34 -7
  49. package/examples/lynx/templates/shmup.c +42 -3
  50. package/examples/lynx/templates/sports.c +29 -2
  51. package/examples/msx/platformer/main.c +213 -0
  52. package/examples/msx/puzzle/main.c +250 -0
  53. package/examples/msx/racing/main.c +249 -0
  54. package/examples/msx/shmup/main.c +288 -0
  55. package/examples/msx/sports/main.c +182 -0
  56. package/examples/nes/templates/default.c +67 -19
  57. package/examples/nes/templates/platformer.c +65 -6
  58. package/examples/nes/templates/puzzle.c +67 -6
  59. package/examples/nes/templates/racing.c +45 -13
  60. package/examples/nes/templates/shmup.c +51 -2
  61. package/examples/nes/templates/sports.c +51 -6
  62. package/examples/pce/platformer/main.c +283 -0
  63. package/examples/pce/puzzle/main.c +304 -0
  64. package/examples/pce/racing/main.c +304 -0
  65. package/examples/pce/shmup/main.c +346 -0
  66. package/examples/pce/sports/main.c +254 -0
  67. package/examples/sms/main.c +35 -6
  68. package/examples/sms/templates/puzzle.c +34 -5
  69. package/examples/sms/templates/racing.c +39 -2
  70. package/examples/sms/templates/shmup.c +41 -2
  71. package/examples/sms/templates/sports.c +43 -2
  72. package/examples/snes/templates/default.c +50 -28
  73. package/examples/snes/templates/platformer-data.asm +22 -0
  74. package/examples/snes/templates/platformer.c +16 -1
  75. package/examples/snes/templates/puzzle-data.asm +22 -0
  76. package/examples/snes/templates/puzzle.c +17 -1
  77. package/examples/snes/templates/racing-data.asm +22 -0
  78. package/examples/snes/templates/racing.c +17 -1
  79. package/examples/snes/templates/shmup-data.asm +22 -0
  80. package/examples/snes/templates/shmup.c +20 -1
  81. package/examples/snes/templates/sports-data.asm +22 -0
  82. package/examples/snes/templates/sports.c +16 -1
  83. package/package.json +1 -1
  84. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  85. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  86. package/src/host/LibretroHost.js +122 -1
  87. package/src/host/callbacks.js +9 -1
  88. package/src/host/types.js +15 -8
  89. package/src/http/tool-registry.js +26 -1
  90. package/src/mcp/tools/cart-parts.js +75 -3
  91. package/src/mcp/tools/disasm-rebuild.js +507 -0
  92. package/src/mcp/tools/disasm.js +95 -6
  93. package/src/mcp/tools/frame.js +168 -3
  94. package/src/mcp/tools/lifecycle.js +4 -2
  95. package/src/mcp/tools/project.js +54 -9
  96. package/src/mcp/tools/state.js +201 -14
  97. package/src/mcp/tools/toolchain.js +76 -3
  98. package/src/mcp/tools/watch-memory.js +125 -14
  99. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  100. package/src/platforms/c64/d64.js +281 -0
  101. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  102. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  103. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  104. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  105. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  106. package/src/rom-id/identifier.js +15 -0
  107. package/src/toolchains/cc65/ines.js +145 -0
  108. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  109. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  110. package/src/toolchains/common/reassemble.js +10 -2
@@ -0,0 +1,281 @@
1
+ // C64 1541 disk image (.d64) codec — pure JS, no external tools.
2
+ //
3
+ // Why this exists: romdev builds C64 homebrew as a bare `.prg` (cc65's output),
4
+ // but the real C64 world — the new Commodore 64 Ultimate / C64C Ultimate FPGA
5
+ // hardware and the entire homebrew/demo scene — loads games as `.d64` disk
6
+ // images (and saves by writing files back INTO the disk). A `.prg` with no
7
+ // drive can't save and isn't how anything ships. This module is the bridge:
8
+ //
9
+ // prgToD64(prg, {name}) — pack a .prg into a fresh, autostart-able .d64
10
+ // readDirectory(d64) — list the files on a disk image
11
+ // extractFile(d64, name) — pull a file's bytes back out (post-save read)
12
+ //
13
+ // Format reference: the standard 35-track 1541 image (174848 bytes). 256-byte
14
+ // sectors, variable sectors per track. Track 18 holds the BAM (sector 0) and
15
+ // the directory (sectors 1+). Files are PETSCII-named, stored as linked sector
16
+ // chains where each sector's first two bytes are (nextTrack, nextSector) — or
17
+ // (0x00, lastByteIndex) on the final sector. This is the well-documented "D64"
18
+ // layout used by VICE's c1541 and every C64 emulator.
19
+
20
+ const SECTOR_SIZE = 256;
21
+ const NUM_TRACKS = 35;
22
+ const DIR_TRACK = 18;
23
+ const BAM_SECTOR = 0;
24
+ const DIR_START_SECTOR = 1;
25
+
26
+ // Sectors per track for a 35-track 1541 disk (zones 1-4).
27
+ // Tracks 1-17: 21, 18-24: 19, 25-30: 18, 31-35: 17.
28
+ const SECTORS_PER_TRACK = (() => {
29
+ const a = new Array(NUM_TRACKS + 1).fill(0); // 1-indexed
30
+ for (let t = 1; t <= NUM_TRACKS; t++) {
31
+ if (t <= 17) a[t] = 21;
32
+ else if (t <= 24) a[t] = 19;
33
+ else if (t <= 30) a[t] = 18;
34
+ else a[t] = 17;
35
+ }
36
+ return a;
37
+ })();
38
+
39
+ const TOTAL_SECTORS = (() => {
40
+ let n = 0;
41
+ for (let t = 1; t <= NUM_TRACKS; t++) n += SECTORS_PER_TRACK[t];
42
+ return n; // 683
43
+ })();
44
+
45
+ const IMAGE_SIZE = TOTAL_SECTORS * SECTOR_SIZE; // 174848
46
+
47
+ /** Byte offset of (track, sector) within the flat image. track is 1-indexed. */
48
+ function offsetOf(track, sector) {
49
+ let off = 0;
50
+ for (let t = 1; t < track; t++) off += SECTORS_PER_TRACK[t] * SECTOR_SIZE;
51
+ return off + sector * SECTOR_SIZE;
52
+ }
53
+
54
+ /** Convert an ASCII string to PETSCII-ish bytes, padded/truncated to `len` with 0xA0 (shifted space). */
55
+ function petsciiName(name, len = 16) {
56
+ const out = new Uint8Array(len).fill(0xa0);
57
+ const s = String(name || "").toUpperCase();
58
+ for (let i = 0; i < len && i < s.length; i++) {
59
+ const c = s.charCodeAt(i);
60
+ // ASCII A-Z, 0-9, space, and common punctuation map ~1:1 to PETSCII for
61
+ // these ranges; anything exotic falls back to a space.
62
+ out[i] = c >= 0x20 && c <= 0x5f ? c : 0x20;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Convert a PETSCII directory name (as stored on disk) back to a trimmed ASCII
69
+ * string. Filenames written by the C64 KERNAL SAVE use the DEFAULT uppercase
70
+ * charset, where letters A–Z are 0xC1–0xDA (high bit set), not 0x41–0x5A — so we
71
+ * must translate that range, otherwise an emulator-written "SCORE" reads as
72
+ * empty. (Our own prgToD64 writes plain 0x41–0x5A; both must decode.)
73
+ */
74
+ function asciiFromPetscii(bytes) {
75
+ let s = "";
76
+ for (const b of bytes) {
77
+ if (b === 0xa0 || b === 0x00) break; // shifted-space pad / terminator
78
+ if (b >= 0xc1 && b <= 0xda) {
79
+ s += String.fromCharCode(b - 0x80); // PETSCII upper A–Z (0xC1..) → ASCII
80
+ } else if (b >= 0x20 && b <= 0x5f) {
81
+ s += String.fromCharCode(b); // plain ASCII / digits / punctuation
82
+ } else if (b >= 0x61 && b <= 0x7a) {
83
+ s += String.fromCharCode(b - 0x20); // PETSCII lower-as-upper → ASCII upper
84
+ }
85
+ // anything else (graphics chars etc.) is dropped from the readable name
86
+ }
87
+ return s.trim();
88
+ }
89
+
90
+ /**
91
+ * Pack a `.prg` (2-byte little-endian load address + body) into a fresh,
92
+ * autostart-able 1541 `.d64` image. The file is written as a single PRG-type
93
+ * directory entry named `name` (default "GAME"). The disk is otherwise empty.
94
+ *
95
+ * @param {Uint8Array|Buffer} prg the raw .prg bytes (load addr + program)
96
+ * @param {object} [opts]
97
+ * @param {string} [opts.name] file name (PETSCII, ≤16 chars) — default "GAME"
98
+ * @param {string} [opts.diskName] disk label (≤16 chars) — default = name
99
+ * @param {string} [opts.diskId] 2-char disk id — default "RD"
100
+ * @returns {Uint8Array} a 174848-byte .d64 image
101
+ */
102
+ export function prgToD64(prg, opts = {}) {
103
+ const body = prg instanceof Uint8Array ? prg : new Uint8Array(prg);
104
+ if (body.length < 2) throw new Error("prgToD64: .prg too small (need ≥2 bytes load address)");
105
+ const fileName = opts.name || "GAME";
106
+ const diskName = opts.diskName || fileName;
107
+ const diskId = (opts.diskId || "RD").slice(0, 2).padEnd(2, " ");
108
+
109
+ const img = new Uint8Array(IMAGE_SIZE);
110
+
111
+ // ---- Lay the file out as a linked sector chain ----------------------------
112
+ // Files conventionally start on track 1; we walk forward, skipping the
113
+ // directory track (18). 254 data bytes per sector (2 bytes are the link).
114
+ const dataPerSector = SECTOR_SIZE - 2;
115
+ const numSectors = Math.ceil(body.length / dataPerSector) || 1;
116
+
117
+ // Pick a sector list (track, sector) for the file, skipping the dir track.
118
+ const chain = [];
119
+ let track = 1;
120
+ let sector = 0;
121
+ for (let i = 0; i < numSectors; i++) {
122
+ // advance to a free (track,sector), skipping the directory track
123
+ while (track === DIR_TRACK || sector >= SECTORS_PER_TRACK[track]) {
124
+ if (sector >= SECTORS_PER_TRACK[track]) { track++; sector = 0; }
125
+ if (track === DIR_TRACK) { track++; sector = 0; }
126
+ if (track > NUM_TRACKS) throw new Error("prgToD64: file too large for a 35-track disk");
127
+ }
128
+ chain.push([track, sector]);
129
+ sector++;
130
+ }
131
+
132
+ // Write the chain.
133
+ for (let i = 0; i < chain.length; i++) {
134
+ const [t, s] = chain[i];
135
+ const base = offsetOf(t, s);
136
+ const isLast = i === chain.length - 1;
137
+ const sliceStart = i * dataPerSector;
138
+ const sliceEnd = Math.min(sliceStart + dataPerSector, body.length);
139
+ const chunk = body.subarray(sliceStart, sliceEnd);
140
+ if (isLast) {
141
+ img[base] = 0x00; // next track = 0 → end of file
142
+ img[base + 1] = chunk.length + 1; // bytes-used-in-this-sector index
143
+ } else {
144
+ const [nt, ns] = chain[i + 1];
145
+ img[base] = nt;
146
+ img[base + 1] = ns;
147
+ }
148
+ img.set(chunk, base + 2);
149
+ }
150
+
151
+ const fileBlocks = chain.length;
152
+
153
+ // ---- BAM (track 18, sector 0) --------------------------------------------
154
+ const bam = offsetOf(DIR_TRACK, BAM_SECTOR);
155
+ img[bam + 0] = DIR_TRACK; // first directory track
156
+ img[bam + 1] = DIR_START_SECTOR; // first directory sector
157
+ img[bam + 2] = 0x41; // 'A' = 1541 disk format
158
+ img[bam + 3] = 0x00;
159
+
160
+ // Per-track free-sector bitmap: 4 bytes each for tracks 1..35 at +4.
161
+ // byte0 = free count, bytes1-3 = bitmap (bit set = sector free).
162
+ for (let t = 1; t <= NUM_TRACKS; t++) {
163
+ const e = bam + 4 + (t - 1) * 4;
164
+ const spt = SECTORS_PER_TRACK[t];
165
+ let freeMask = 0;
166
+ for (let s = 0; s < spt; s++) freeMask |= (1 << s);
167
+ // mark used: any sector in our file chain, plus track 18 sectors 0 & 1
168
+ let used = new Set();
169
+ if (t === DIR_TRACK) { used.add(BAM_SECTOR); used.add(DIR_START_SECTOR); }
170
+ for (const [ct, cs] of chain) if (ct === t) used.add(cs);
171
+ for (const s of used) freeMask &= ~(1 << s);
172
+ let freeCount = 0;
173
+ for (let s = 0; s < spt; s++) if (freeMask & (1 << s)) freeCount++;
174
+ img[e + 0] = freeCount;
175
+ img[e + 1] = freeMask & 0xff;
176
+ img[e + 2] = (freeMask >> 8) & 0xff;
177
+ img[e + 3] = (freeMask >> 16) & 0xff;
178
+ }
179
+
180
+ // Disk name (+0x90, 16 bytes, 0xA0 padded), then id + dos type.
181
+ img.set(petsciiName(diskName, 16), bam + 0x90);
182
+ img[bam + 0xa0] = 0xa0;
183
+ img[bam + 0xa1] = 0xa0;
184
+ img[bam + 0xa2] = diskId.charCodeAt(0);
185
+ img[bam + 0xa3] = diskId.charCodeAt(1);
186
+ img[bam + 0xa4] = 0xa0;
187
+ img[bam + 0xa5] = 0x32; // '2' DOS version
188
+ img[bam + 0xa6] = 0x41; // 'A'
189
+ for (let i = 0xa7; i <= 0xaa; i++) img[bam + i] = 0xa0;
190
+
191
+ // ---- Directory entry (track 18, sector 1, first slot) --------------------
192
+ const dir = offsetOf(DIR_TRACK, DIR_START_SECTOR);
193
+ img[dir + 0] = 0x00; // next dir track = 0 (only one dir sector)
194
+ img[dir + 1] = 0xff; // next dir sector = 0xff (last in chain)
195
+ // entry: file type (0x82 = closed PRG), then first (track,sector)
196
+ img[dir + 2] = 0x82;
197
+ img[dir + 3] = chain[0][0];
198
+ img[dir + 4] = chain[0][1];
199
+ img.set(petsciiName(fileName, 16), dir + 5);
200
+ // bytes 0x15-0x1d: REL side info / unused for PRG → 0
201
+ img[dir + 0x1e] = fileBlocks & 0xff; // block count low
202
+ img[dir + 0x1f] = (fileBlocks >> 8) & 0xff; // block count high
203
+
204
+ return img;
205
+ }
206
+
207
+ /**
208
+ * Read the directory of a `.d64` image.
209
+ * @param {Uint8Array|Buffer} d64
210
+ * @returns {Array<{name:string, type:string, track:number, sector:number, blocks:number}>}
211
+ */
212
+ export function readDirectory(d64) {
213
+ const img = d64 instanceof Uint8Array ? d64 : new Uint8Array(d64);
214
+ const TYPES = ["DEL", "SEQ", "PRG", "USR", "REL"];
215
+ const out = [];
216
+ let t = DIR_TRACK, s = DIR_START_SECTOR;
217
+ const seen = new Set();
218
+ while (t !== 0 && !seen.has(`${t},${s}`)) {
219
+ seen.add(`${t},${s}`);
220
+ const base = offsetOf(t, s);
221
+ const nextT = img[base + 0];
222
+ const nextS = img[base + 1];
223
+ // 8 entries per sector, 32 bytes each, first entry at +2 then every +32.
224
+ for (let e = 0; e < 8; e++) {
225
+ const eb = base + 2 + e * 32 - (e === 0 ? 0 : 0);
226
+ const entryBase = base + (e === 0 ? 2 : 2 + e * 32);
227
+ const typeByte = img[entryBase + 0];
228
+ if ((typeByte & 0x0f) === 0 && typeByte === 0) continue; // empty slot
229
+ const ft = img[entryBase + 0];
230
+ const ftrack = img[entryBase + 1];
231
+ const fsector = img[entryBase + 2];
232
+ const nameBytes = img.subarray(entryBase + 3, entryBase + 3 + 16);
233
+ const name = asciiFromPetscii(nameBytes);
234
+ if (!name) continue;
235
+ const blocks = img[entryBase + 0x1c] | (img[entryBase + 0x1d] << 8);
236
+ out.push({ name, type: TYPES[ft & 0x07] || "DEL", track: ftrack, sector: fsector, blocks });
237
+ }
238
+ t = nextT; s = nextS;
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * Extract a file's raw bytes (including its 2-byte load address for PRG) from a
245
+ * `.d64`, by following its sector chain. If `name` is omitted, the first file
246
+ * is returned.
247
+ * @param {Uint8Array|Buffer} d64
248
+ * @param {string} [name]
249
+ * @returns {Uint8Array|null} file bytes, or null if not found
250
+ */
251
+ export function extractFile(d64, name) {
252
+ const img = d64 instanceof Uint8Array ? d64 : new Uint8Array(d64);
253
+ const dir = readDirectory(img);
254
+ const entry = name
255
+ ? dir.find((d) => d.name.toUpperCase() === String(name).toUpperCase())
256
+ : dir[0];
257
+ if (!entry) return null;
258
+
259
+ const bytes = [];
260
+ let t = entry.track, s = entry.sector;
261
+ const seen = new Set();
262
+ while (t !== 0 && !seen.has(`${t},${s}`)) {
263
+ seen.add(`${t},${s}`);
264
+ const base = offsetOf(t, s);
265
+ const nextT = img[base + 0];
266
+ const nextS = img[base + 1];
267
+ if (nextT === 0) {
268
+ // last sector: nextS is the index of the last valid byte (+1 over data)
269
+ const used = nextS; // bytes 2..used hold data
270
+ for (let i = 2; i <= used && base + i < img.length; i++) bytes.push(img[base + i]);
271
+ break;
272
+ } else {
273
+ for (let i = 2; i < SECTOR_SIZE; i++) bytes.push(img[base + i]);
274
+ }
275
+ t = nextT; s = nextS;
276
+ }
277
+ return new Uint8Array(bytes);
278
+ }
279
+
280
+ export const D64_IMAGE_SIZE = IMAGE_SIZE;
281
+ export const D64_DISK_EXTENSIONS = [".d64", ".d71", ".d81", ".g64", ".t64", ".tap", ".crt", ".p00"];
@@ -4,6 +4,16 @@ One page. Read once before you write your first game. The
4
4
  TROUBLESHOOTING.md alongside this file is for when something's broken;
5
5
  this is the "what's going on" version.
6
6
 
7
+ ## Blank screen? Verify rendering first (no vision needed)
8
+
9
+ Compiles clean but nothing on screen? Call **`frame({op:'verify', frames:60})`** —
10
+ one call fuses a framebuffer pixel scan with the live LCDC and returns
11
+ `{verified:true|false|null, issues[]}`. `renderDisabled` = LCD off (LCDC.7 clear);
12
+ `blankScreen`/`nearlyBlank` with LCD on = nothing in the BG map / OAM / palette
13
+ (check the footguns below + read `memory({op:'read', region:'gb_vram'})`);
14
+ `verified:null` = step a frame first. Zero image tokens, frame-0-guarded — use it
15
+ as the first move when a change "did nothing."
16
+
7
17
  ## Five silent-failure footguns to know before you start (R26 + R27)
8
18
 
9
19
  If your ROM compiles cleanly but doesn't render — or sprites land in
@@ -12,12 +12,16 @@ romdev ships a **hardware helper library** (`src/platforms/msx/lib/c/`:
12
12
  `msx_psg_tone()` in plain C. It uses DIRECT Z80 I/O ports (the reliable path —
13
13
  NOT fragile inline-asm BIOS wrappers).
14
14
 
15
- The fastest way to a working game: **`scaffold({op:'project', platform: "msx", template:
16
- "sprite_move"})`** (also `music_sfx`, `catch_game`). It drops a complete,
17
- *building* project a verified playable example + the helper lib + the cart
18
- crt0 + docs. Read the example's `main.c`, then change it. Examples live in
19
- `examples/msx/`. **Gotcha:** read joystick **port 1** (`msx_read_joystick(1)`)
20
- port 0 is the keyboard, which an emulator's gamepad doesn't drive.
15
+ The fastest way to a working game: **`scaffold({op:'game', platform: "msx", genre:
16
+ "shmup"})`** or any of `platformer` / `puzzle` / `sports` / `racing`, the full
17
+ genre set. For a smaller starting point use **`scaffold({op:'project', platform:
18
+ "msx", template: "sprite_move"})`** (also `music_sfx`, `catch_game`). Either drops
19
+ a complete, *building* project a verified playable example + the helper lib +
20
+ the cart crt0 + docs. Read the example's `main.c`, then change it. Examples live in
21
+ `examples/msx/`. The `platformer` scaffold column-streams the SCREEN 2 name table
22
+ for a tile-by-tile side-scroll. **Gotcha:** read joystick **port 1**
23
+ (`msx_read_joystick(1)`) — port 0 is the keyboard, which an emulator's gamepad
24
+ doesn't drive.
21
25
 
22
26
  ## CPU — Z80 (16-bit address space, paged in 16 KB slots)
23
27
 
@@ -211,6 +211,21 @@ Build calls explicitly point at these files via `sourcesPaths` /
211
211
  `includePaths` + `linkerConfig: <contents of chr-ram-runtime.cfg>`. The
212
212
  project README shows the exact incantation.
213
213
 
214
+ ## Blank screen? Verify rendering before you guess (no vision needed)
215
+
216
+ If the screen looks black/blank, don't iterate blind — call
217
+ **`frame({op:'verify', frames:60})`**. One call fuses a framebuffer pixel scan
218
+ with the live PPU registers and tells you `{verified:true|false|null, issues[]}`:
219
+ - `renderDisabled` → PPUMASK has BG+sprites off (footgun, see below) — set
220
+ PPUMASK bits 3/4.
221
+ - `blankScreen`/`nearlyBlank` but render IS enabled → the PPU is on but nothing's
222
+ in the nametable/OAM/palette: check the loop-order + OAM-DMA footguns below, and
223
+ read the raw regions (`memory({op:'read', region:'nes_nametables'/'nes_oam'/'nes_palette'})`).
224
+ - `verified:null` (unsettled) → you haven't stepped a frame yet; step first.
225
+
226
+ It won't false-fire on boot, and it costs zero image tokens. Use it as the first
227
+ move whenever a change "did nothing" on screen.
228
+
214
229
  ## Five footguns to know before you start
215
230
 
216
231
  Read these BEFORE writing your game-loop. Each one cost a previous
@@ -304,14 +319,60 @@ incorrectly aligned."
304
319
  onChange:"reset", outputPath:...})` logs each note onset, or
305
320
  `recordSession({memorySamples:[{region:"nes_apu_regs",...}], sampleEvery:1,
306
321
  memoryOutputPath:...})` streams per-frame samples to disk.
307
- - Mapper support — only NROM-256 (32 KB PRG, no banks) is wired. For
308
- MMC1/MMC3/UNROM you'll need a different linker config.
322
+ - Mapper support — the homebrew presets target NROM (no PRG banking). For
323
+ MMC1/MMC3/UNROM you'll need a different linker config. (For *rebuilding* an
324
+ existing CHR-ROM NROM game byte-identical, see "Rebuilding a CHR-ROM NROM
325
+ image" below — `inesHeader` / the `chr-rom` preset / `disasm({target:'project'})`.)
309
326
  - IRQ — the IRQ vector returns. Most NES games use a custom IRQ
310
327
  handler for mid-frame scroll splits; you'll need to write that asm.
311
328
  - Multi-screen scrolling — the runtime sets one nametable; for big
312
329
  scrolling worlds you need to manage the nametable buffer + bank
313
330
  switching yourself.
314
331
 
332
+ ## Rebuilding a CHR-ROM NROM image (reverse-engineering)
333
+
334
+ The homebrew presets above are CHR-**RAM** (the CPU uploads tiles at runtime).
335
+ Most *commercial* games are CHR-**ROM**: an 8 KB (or more) bank of fixed tile
336
+ data the PPU reads pattern tables from directly. When you rebuild a commercial
337
+ game from its disassembly into a byte-identical `.nes`, you need the iNES
338
+ header + the CHR-ROM blob + a linker config that concatenates HEADER + PRG +
339
+ CHR. romdev has three ways to do this so you never hand-derive header bytes or
340
+ write glue `.s`/`.cfg` files.
341
+
342
+ **The iNES header** (16 bytes at the very start of a `.nes`): `4E 45 53 1A`
343
+ ("NES"+EOF), then byte 4 = PRG-ROM 16 KB bank count, byte 5 = CHR-ROM 8 KB bank
344
+ count (**0 = CHR-RAM**), byte 6 = flags6 (bit0 mirroring 0=horizontal/1=vertical,
345
+ bit1 battery, high nibble = mapper low nibble), byte 7 = flags7 (high nibble =
346
+ mapper high nibble), bytes 8-15 = 0. NROM is mapper 0; NROM-128 = 1 PRG bank
347
+ (maps at $C000, mirrored to $8000), NROM-256 = 2 PRG banks (maps at $8000).
348
+
349
+ **1. `build({inesHeader:{...}})` — the parametric, no-glue path (recommended).**
350
+ Pass `inesHeader: {prgBanks, chrBanks, mapper, mirroring}` and the build
351
+ auto-emits the HEADER segment, wires your CHR blob (from `binaryIncludePaths`)
352
+ into a CHARS segment, and uses a flat NROM `.cfg`. You supply only the PRG
353
+ source(s) + the CHR blob:
354
+ ```
355
+ build({ output:'rom', platform:'nes',
356
+ sourcesPaths:{ "prg.asm": "bank0.asm" }, // the PRG disassembly
357
+ binaryIncludePaths:{ "chr.bin": "chr.bin" }, // extracted CHR-ROM
358
+ inesHeader:{ prgBanks:2, chrBanks:1, mapper:0, mirroring:"vertical" } })
359
+ ```
360
+ Mutually exclusive with `linkerConfig`. Works for any NROM (mapper 0, ≤2 PRG
361
+ banks); for a banked mapper supply a linker `.cfg` that places each bank.
362
+
363
+ **2. `linkerConfig:"chr-rom"` — for homebrew C that ships FIXED tile art.**
364
+ A cc65-C preset (segment split + a CHARS segment in an 8 KB ROM2 bank). Put your
365
+ tiles in `.segment "CHARS"` (`.incbin "tiles.chr"`) + pass the blob via
366
+ `binaryIncludePaths`. It ships a companion crt0 with an 8 KB-CHR-ROM header. For
367
+ other bank configs, prefer `inesHeader`.
368
+
369
+ **3. `disasm({target:'project'})` — disassemble → rebuild, in two calls.**
370
+ For NES it now extracts the CHR-ROM to `chr.bin`, writes a `rebuild.json` (the
371
+ exact `build({inesHeader})` call, with absolute paths) and a `BUILD.md`. Feed
372
+ `rebuild.json` straight back to `build` and you get a byte-identical ROM. This
373
+ is the RE workhorse loop: `disasm({target:'project'})` → edit the `.asm` →
374
+ rebuild → `diffRoms` to confirm your patch landed.
375
+
315
376
  ## When to drop to asm
316
377
 
317
378
  Game-loop in C is fine for ~80% of homebrew. Drop to asm when:
@@ -12,10 +12,15 @@ romdev ships a **hardware helper library** (`src/platforms/pce/lib/c/`:
12
12
  `psg_tone()` instead of poking VDC/VCE registers by hand. cc65 has **no** sprite
13
13
  library, so this lib is how you get pixels on screen.
14
14
 
15
- The fastest way to a working game: **`scaffold({op:'project', platform: "pce", template:
16
- "sprite_move"})`** (also `music_sfx`, `catch_game`). It drops a complete,
17
- *building* project a verified playable example + the helper lib + docs. Read
18
- the example's `main.c`, then change it. The examples live in `examples/pce/`.
15
+ The fastest way to a working game: **`scaffold({op:'game', platform: "pce", genre:
16
+ "shmup"})`** or any of `platformer` / `puzzle` / `sports` / `racing`, the full
17
+ genre set. For a smaller starting point use **`scaffold({op:'project', platform:
18
+ "pce", template: "sprite_move"})`** (also `music_sfx`, `catch_game`). Either drops
19
+ a complete, *building* project — a verified playable example + the helper lib +
20
+ docs. Read the example's `main.c`, then change it. The examples live in
21
+ `examples/pce/`. The genre scaffolds fill the BAT (32×32 virtual screen); the
22
+ `platformer` smooth-scrolls the background via the VDC BXR (R7) register.
23
+ **Gotcha:** `#include <stdint.h>` for int8/16/32_t — `pce.h` only typedefs u8/u16.
19
24
 
20
25
  ## CPU — HuC6280 (a 65C02 superset)
21
26
 
@@ -83,7 +83,7 @@ static u8 _pce_vdc_inited = 0;
83
83
  void vdc_init(void) {
84
84
  if (_pce_vdc_inited) return;
85
85
  _pce_vdc_inited = 1;
86
- vdc_set_reg(VDC_MWR, 0x0010); /* 32x32 virtual map, 256px BAT */
86
+ vdc_set_reg(VDC_MWR, 0x0000); /* 32x32 virtual map (SCREEN field=000); 256px BAT. (0x10 was 64x32 — its 64-wide stride left the bottom rows as uninitialized VRAM = vertical-stripe garbage.) */
87
87
  vdc_set_reg(VDC_BXR, 0x0000); /* BG X scroll = 0 */
88
88
  vdc_set_reg(VDC_BYR, 0x0000); /* BG Y scroll = 0 */
89
89
  vdc_set_reg(VDC_HSR, 0x0202); /* horizontal sync width/start */
@@ -107,6 +107,7 @@ function parseINes(b) {
107
107
  if (f6 & 0x04) notes.push("trainer present");
108
108
  if (f6 & 0x08) notes.push("four-screen VRAM");
109
109
 
110
+ const hasBattery = !!(f6 & 0x02);
110
111
  return {
111
112
  platform: "nes",
112
113
  format: ".nes",
@@ -117,6 +118,10 @@ function parseINes(b) {
117
118
  chr: chrBanks * 8192,
118
119
  total: b.length,
119
120
  },
121
+ // Battery-backed cartridge SAVE RAM: present iff the iNES battery flag is set
122
+ // (8 KB is the standard PRG-RAM window). Read/write live via
123
+ // memory({region:'save_ram'}); persist with state({op:'exportSram'/'importSram'}).
124
+ saveRam: { hasBattery, bytes: hasBattery ? 8192 : 0 },
120
125
  notes: [...notes, `mirroring: ${mirroring}`],
121
126
  confidence: 1,
122
127
  };
@@ -208,6 +213,13 @@ function parseGameBoy(b) {
208
213
  0x1E: "MBC5+RUMBLE+RAM+BATTERY", 0xFC: "Pocket Camera", 0xFE: "HuC3", 0xFF: "HuC1+RAM+BATTERY",
209
214
  };
210
215
 
216
+ // Battery save = cart type whose name carries BATTERY. MBC2 has 512×4 bits of
217
+ // internal RAM (ramSizeCode is 0 but it still saves), so treat it specially.
218
+ const typeName = cartTypeNames[cartType] ?? "";
219
+ const hasBattery = /BATTERY/.test(typeName);
220
+ const isMbc2 = cartType === 0x05 || cartType === 0x06;
221
+ const saveBytes = hasBattery ? (isMbc2 ? 512 : Math.max(ramSize, 0)) : 0;
222
+
211
223
  return {
212
224
  platform,
213
225
  format: isGbc ? ".gbc" : ".gb",
@@ -215,6 +227,9 @@ function parseGameBoy(b) {
215
227
  region,
216
228
  mapper: cartTypeNames[cartType] ?? `0x${cartType.toString(16)}`,
217
229
  sizes: { rom: romSize, ram: ramSize, total: b.length },
230
+ // Battery-backed cartridge SAVE RAM (the .sav). Read/write live via
231
+ // memory({region:'save_ram'}); persist with state({op:'exportSram'/'importSram'}).
232
+ saveRam: { hasBattery, bytes: saveBytes },
218
233
  notes: [
219
234
  isGbc ? "Game Boy Color cartridge" : "Original Game Boy cartridge",
220
235
  sgbFlag === 0x03 && "supports Super Game Boy enhancements",
@@ -0,0 +1,145 @@
1
+ // iNES header + NROM linker-config synthesis for cc65/ca65 NES builds.
2
+ //
3
+ // The most common NES *reverse-engineering* build shape — rebuilding a
4
+ // commercial NROM game from its disassembly (e.g. SMBDIS) into a byte-identical
5
+ // `.nes` — needs three pieces of pure boilerplate that are identical for every
6
+ // NROM CHR-ROM cart:
7
+ // 1. the 16-byte iNES header (`.segment "HEADER"`),
8
+ // 2. a CHARS segment fed from the extracted CHR-ROM blob (`.incbin`),
9
+ // 3. a 3-region MEMORY/SEGMENTS .cfg concatenating HEADER + PRG + CHARS into
10
+ // one output file.
11
+ //
12
+ // `build({inesHeader:{prgBanks, chrBanks, mapper, mirroring}})` and
13
+ // `disasm({target:'project'})` both use this so the agent never hand-derives
14
+ // header bytes or writes glue `.s`/`.cfg` files. Proven byte-identical against
15
+ // nestest.nes (NROM-128, 16K PRG + 8K CHR) and 32K-PRG NROM-256 carts.
16
+
17
+ /**
18
+ * @typedef {Object} InesHeaderSpec
19
+ * @property {number} prgBanks - 16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256).
20
+ * @property {number} [chrBanks] - 8KB CHR-ROM banks (0 = CHR-RAM, no CHARS). Default 0.
21
+ * @property {number} [mapper] - iNES mapper number. Default 0 (NROM).
22
+ * @property {"horizontal"|"vertical"} [mirroring] - nametable mirroring. Default "horizontal".
23
+ * @property {boolean} [battery] - PRG-RAM battery (flags6 bit 1). Default false.
24
+ */
25
+
26
+ /**
27
+ * Build the 16 raw iNES header bytes for a spec.
28
+ * @param {InesHeaderSpec} spec
29
+ * @returns {Uint8Array} exactly 16 bytes
30
+ */
31
+ export function inesHeaderBytes(spec) {
32
+ const prg = spec.prgBanks;
33
+ const chr = spec.chrBanks ?? 0;
34
+ const mapper = spec.mapper ?? 0;
35
+ const mirroring = spec.mirroring ?? "horizontal";
36
+ if (!Number.isInteger(prg) || prg < 1 || prg > 255) {
37
+ throw new Error(`inesHeader.prgBanks must be an integer 1..255 (16KB each); got ${spec.prgBanks}`);
38
+ }
39
+ if (!Number.isInteger(chr) || chr < 0 || chr > 255) {
40
+ throw new Error(`inesHeader.chrBanks must be an integer 0..255 (8KB each; 0 = CHR-RAM); got ${spec.chrBanks}`);
41
+ }
42
+ if (!Number.isInteger(mapper) || mapper < 0 || mapper > 255) {
43
+ throw new Error(`inesHeader.mapper must be an integer 0..255; got ${spec.mapper}`);
44
+ }
45
+ if (mirroring !== "horizontal" && mirroring !== "vertical") {
46
+ throw new Error(`inesHeader.mirroring must be "horizontal" or "vertical"; got ${JSON.stringify(spec.mirroring)}`);
47
+ }
48
+ const flags6 = (mirroring === "vertical" ? 0x01 : 0x00) | (spec.battery ? 0x02 : 0x00) | ((mapper & 0x0f) << 4);
49
+ const flags7 = mapper & 0xf0;
50
+ const h = new Uint8Array(16);
51
+ h[0] = 0x4e; h[1] = 0x45; h[2] = 0x53; h[3] = 0x1a; // "NES\x1a"
52
+ h[4] = prg;
53
+ h[5] = chr;
54
+ h[6] = flags6;
55
+ h[7] = flags7;
56
+ // bytes 8..15 stay 0 (iNES 1.0 padding)
57
+ return h;
58
+ }
59
+
60
+ /**
61
+ * ca65 source that emits the iNES header as `.segment "HEADER"`.
62
+ * @param {InesHeaderSpec} spec
63
+ * @returns {string}
64
+ */
65
+ export function inesHeaderSource(spec) {
66
+ const h = inesHeaderBytes(spec);
67
+ const prg = spec.prgBanks;
68
+ const chr = spec.chrBanks ?? 0;
69
+ const mapper = spec.mapper ?? 0;
70
+ const mirroring = spec.mirroring ?? "horizontal";
71
+ const hex = (b) => "$" + b.toString(16).padStart(2, "0").toUpperCase();
72
+ return [
73
+ "; iNES header — auto-generated by build({inesHeader:{...}}).",
74
+ `; prgBanks=${prg} (${prg * 16}KB) chrBanks=${chr} (${chr * 8}KB${chr === 0 ? " = CHR-RAM" : ""})` +
75
+ ` mapper=${mapper} mirroring=${mirroring}`,
76
+ '.segment "HEADER"',
77
+ ` .byte ${hex(h[0])},${hex(h[1])},${hex(h[2])},${hex(h[3])} ; "NES"+EOF`,
78
+ ` .byte ${h[4]} ; PRG-ROM 16KB banks`,
79
+ ` .byte ${h[5]} ; CHR-ROM 8KB banks${chr === 0 ? " (0 = CHR-RAM)" : ""}`,
80
+ ` .byte ${hex(h[6])} ; flags6 (mapper lo nibble + mirroring${spec.battery ? " + battery" : ""})`,
81
+ ` .byte ${hex(h[7])} ; flags7 (mapper hi nibble)`,
82
+ " .byte 0,0,0,0,0,0,0,0 ; padding (iNES 1.0)",
83
+ "",
84
+ ].join("\n");
85
+ }
86
+
87
+ /**
88
+ * ca65 source that pulls the CHR-ROM blob into the CHARS segment.
89
+ * @param {string} incbinName - the binaryInclude file name to `.incbin`.
90
+ * @returns {string}
91
+ */
92
+ export function charsSource(incbinName) {
93
+ return [
94
+ "; CHR-ROM data — auto-generated by build({inesHeader:{...}}).",
95
+ '.segment "CHARS"',
96
+ ` .incbin "${incbinName}"`,
97
+ "",
98
+ ].join("\n");
99
+ }
100
+
101
+ /**
102
+ * A flat NROM linker .cfg: HEADER(16B) + PRG + (optional) CHARS, all concatenated
103
+ * into one %O output file. "Flat" = one CODE segment carrying the whole PRG image
104
+ * with its OWN embedded reset/NMI/IRQ vectors (the shape a disassembly produces),
105
+ * NOT cc65's STARTUP/VECTORS/CONDES split. Use `chr-rom` (the named preset) for
106
+ * cc65-C-with-segments builds instead.
107
+ *
108
+ * - prgBanks=1 (NROM-128): the 16KB image maps at $C000 (mirrored to $8000).
109
+ * - prgBanks=2 (NROM-256): the 32KB image maps at $8000.
110
+ *
111
+ * @param {InesHeaderSpec} spec
112
+ * @returns {string} ld65 config text
113
+ */
114
+ export function nromFlatCfg(spec) {
115
+ const prg = spec.prgBanks;
116
+ const chr = spec.chrBanks ?? 0;
117
+ const prgSize = prg * 0x4000;
118
+ // NROM-128's single 16KB bank lives in the upper half so $FFFA vectors land;
119
+ // anything larger fills from $8000 down.
120
+ const prgStart = prg === 1 ? 0xc000 : 0x10000 - prgSize;
121
+ const lines = [
122
+ "# Flat NROM linker config — auto-generated by build({inesHeader:{...}}).",
123
+ "# HEADER(16B) + PRG + " + (chr > 0 ? "CHARS(CHR-ROM)" : "(no CHARS — CHR-RAM)") +
124
+ ", concatenated into one .nes.",
125
+ "# 'Flat' CODE segment = the whole PRG image with its own embedded vectors",
126
+ "# (disassembly shape). For cc65-C with segment split, use linkerConfig:'chr-rom'.",
127
+ "MEMORY {",
128
+ " HEADER: file = %O, start = $0000, size = $0010, fill = yes;",
129
+ ` PRG: file = %O, start = $${prgStart.toString(16).toUpperCase()}, size = $${prgSize.toString(16).toUpperCase()}, fill = yes, fillval = $FF;`,
130
+ ];
131
+ if (chr > 0) {
132
+ lines.push(` CHARS: file = %O, start = $0000, size = $${(chr * 0x2000).toString(16).toUpperCase()}, fill = yes;`);
133
+ }
134
+ lines.push(
135
+ "}",
136
+ "SEGMENTS {",
137
+ " HEADER: load = HEADER, type = ro;",
138
+ " CODE: load = PRG, type = ro;",
139
+ );
140
+ if (chr > 0) {
141
+ lines.push(" CHARS: load = CHARS, type = ro;");
142
+ }
143
+ lines.push("}", "");
144
+ return lines.join("\n");
145
+ }