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.
- package/AGENTS.md +60 -12
- package/CHANGELOG.md +258 -0
- package/examples/README.md +2 -0
- package/examples/atari2600/templates/platformer.asm +460 -0
- package/examples/atari2600/templates/racing.asm +463 -0
- package/examples/atari2600/templates/shmup.asm +386 -0
- package/examples/atari2600/templates/sports.asm +362 -0
- package/examples/atari7800/templates/default.c +49 -5
- package/examples/atari7800/templates/platformer.c +43 -4
- package/examples/atari7800/templates/puzzle.c +39 -4
- package/examples/atari7800/templates/racing.c +39 -4
- package/examples/atari7800/templates/shmup.c +40 -2
- package/examples/atari7800/templates/sports.c +36 -5
- package/examples/c64/templates/platformer.c +19 -5
- package/examples/c64/templates/puzzle.c +32 -2
- package/examples/c64/templates/shmup.c +28 -2
- package/examples/c64/templates/sports.c +30 -2
- package/examples/gb/templates/default.c +110 -16
- package/examples/gb/templates/platformer.c +25 -4
- package/examples/gb/templates/puzzle.c +32 -2
- package/examples/gb/templates/racing.c +72 -8
- package/examples/gb/templates/shmup.c +38 -1
- package/examples/gb/templates/sports.c +48 -1
- package/examples/gba/templates/gba_hello.c +29 -11
- package/examples/gba/templates/puzzle.c +15 -3
- package/examples/gba/templates/racing.c +65 -3
- package/examples/gba/templates/shmup.c +41 -4
- package/examples/gba/templates/sports.c +36 -2
- package/examples/gba/templates/tonc_hello.c +41 -5
- package/examples/gbc/templates/default.c +103 -26
- package/examples/gbc/templates/platformer.c +25 -4
- package/examples/gbc/templates/puzzle.c +32 -2
- package/examples/gbc/templates/racing.c +85 -19
- package/examples/gbc/templates/shmup.c +34 -1
- package/examples/gbc/templates/sports.c +45 -1
- package/examples/genesis/templates/puzzle.c +37 -3
- package/examples/genesis/templates/racing.c +44 -11
- package/examples/genesis/templates/sgdk_hello.c +34 -1
- package/examples/genesis/templates/shmup.c +31 -1
- package/examples/gg/templates/default.c +56 -18
- package/examples/gg/templates/platformer.c +18 -12
- package/examples/gg/templates/puzzle.c +38 -7
- package/examples/gg/templates/racing.c +51 -5
- package/examples/gg/templates/shmup.c +47 -3
- package/examples/gg/templates/sports.c +46 -3
- package/examples/lynx/templates/default.c +39 -8
- package/examples/lynx/templates/puzzle.c +28 -1
- package/examples/lynx/templates/racing.c +34 -7
- package/examples/lynx/templates/shmup.c +42 -3
- package/examples/lynx/templates/sports.c +29 -2
- package/examples/msx/platformer/main.c +213 -0
- package/examples/msx/puzzle/main.c +250 -0
- package/examples/msx/racing/main.c +249 -0
- package/examples/msx/shmup/main.c +288 -0
- package/examples/msx/sports/main.c +182 -0
- package/examples/nes/templates/default.c +67 -19
- package/examples/nes/templates/platformer.c +65 -6
- package/examples/nes/templates/puzzle.c +67 -6
- package/examples/nes/templates/racing.c +45 -13
- package/examples/nes/templates/shmup.c +51 -2
- package/examples/nes/templates/sports.c +51 -6
- package/examples/pce/platformer/main.c +283 -0
- package/examples/pce/puzzle/main.c +304 -0
- package/examples/pce/racing/main.c +304 -0
- package/examples/pce/shmup/main.c +346 -0
- package/examples/pce/sports/main.c +254 -0
- package/examples/sms/main.c +35 -6
- package/examples/sms/templates/puzzle.c +34 -5
- package/examples/sms/templates/racing.c +39 -2
- package/examples/sms/templates/shmup.c +41 -2
- package/examples/sms/templates/sports.c +43 -2
- package/examples/snes/templates/default.c +50 -28
- package/examples/snes/templates/platformer-data.asm +22 -0
- package/examples/snes/templates/platformer.c +16 -1
- package/examples/snes/templates/puzzle-data.asm +22 -0
- package/examples/snes/templates/puzzle.c +17 -1
- package/examples/snes/templates/racing-data.asm +22 -0
- package/examples/snes/templates/racing.c +17 -1
- package/examples/snes/templates/shmup-data.asm +22 -0
- package/examples/snes/templates/shmup.c +20 -1
- package/examples/snes/templates/sports-data.asm +22 -0
- package/examples/snes/templates/sports.c +16 -1
- package/package.json +1 -1
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +122 -1
- package/src/host/callbacks.js +9 -1
- package/src/host/types.js +15 -8
- package/src/http/tool-registry.js +26 -1
- package/src/mcp/tools/cart-parts.js +75 -3
- package/src/mcp/tools/disasm-rebuild.js +507 -0
- package/src/mcp/tools/disasm.js +95 -6
- package/src/mcp/tools/frame.js +168 -3
- package/src/mcp/tools/lifecycle.js +4 -2
- package/src/mcp/tools/project.js +54 -9
- package/src/mcp/tools/state.js +201 -14
- package/src/mcp/tools/toolchain.js +76 -3
- package/src/mcp/tools/watch-memory.js +125 -14
- package/src/platforms/c64/MENTAL_MODEL.md +45 -1
- package/src/platforms/c64/d64.js +281 -0
- package/src/platforms/gb/MENTAL_MODEL.md +10 -0
- package/src/platforms/msx/MENTAL_MODEL.md +10 -6
- package/src/platforms/nes/MENTAL_MODEL.md +63 -2
- package/src/platforms/pce/MENTAL_MODEL.md +9 -4
- package/src/platforms/pce/lib/c/pce_video.c +1 -1
- package/src/rom-id/identifier.js +15 -0
- package/src/toolchains/cc65/ines.js +145 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
- 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:'
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 —
|
|
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:'
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
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,
|
|
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 */
|
package/src/rom-id/identifier.js
CHANGED
|
@@ -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
|
+
}
|