description: "Audit NES mappers, project builder, and CC65 ROM compilation" argument-hint: "[--focus <dims>]"
Mapper / Project-Builder / Compiler Audit
Audit the subsystem that turns generated music data into a buildable, bootable NES
ROM: the mapper abstraction (mappers/), the project builder (nes/project_builder.py),
and the CC65 compile path (compiler/). This is a correctness audit — the output is a
binary that must boot on hardware, so the bar is high: a wrong header byte, a stale
vector, or an undetected PRG overrun ships a broken ROM.
Shared protocol (layout, dedup, finding format): .claude/commands/_audit-common.md.
Severity definitions and the NES-hardware floors: .claude/commands/_audit-severity.md.
Do not restate them here. For any claim about mapper registers, bank windows, or iNES
header bytes, cite docs/MAPPER_MMC1_REFERENCE.md or docs/MAPPER_MMC3_REFERENCE.md
rather than asserting from memory — re-read the relevant section before reporting.
Reminder from _audit-common.md: the prepare stage writes main.asm/music.asm/nes.cfg
plus a build script, and compile runs ca65/ld65 then checks a minimum ROM size.
Per CLAUDE.md, prepare and run_full_pipeline default to MMC3 — but verify that
against the code, and treat any doc that still says MMC1 as drift (Dimension 10).
This subsystem went through a heavy bug-fixing pass recently (mapper default resolution, capacity pre-flight, header/segment fixes, CC65 subprocess hardening, build-script routing). Several dimensions below now describe fixed behavior — the instruction in each case is to verify the fix is complete and hunt for edge cases it doesn't cover, not to re-report the original bug.
Parameters (from $ARGUMENTS)
--focus <dims>— comma-separated dimension numbers (e.g.--focus 4,8). Default: all.
Extra Per-Finding Field
- Dimension: one of the 10 below.
- Hardware ref: the
docs/MAPPER_*.mdsection backing any header/bank/register claim.
Dimensions
Dimension 1: iNES header ↔ nes.cfg consistency
For each of mappers/nrom.py, mappers/mmc1.py, mappers/mmc3.py, cross-check
generate_header_asm() against generate_linker_config() and the prg_rom_size /
prg_bank_size / prg_bank_count properties from mappers/base.py:
- PRG-ROM count in the header byte must equal the total PRG region size in the linker
MEMORYblock. NROM declares$02(2×16KB = 32KB) and a singlePRGof$8000; MMC1 declares$08(8×16KB = 128KB) as 7 switchable 16KB windows (PRG_BANK_00..06,$4000each) plus a fixedPRGFIXED$4000(112KB + 16KB) — #255 replaced the old single linearPRGSWAPregion so ld65 can't alias the fixed bank; MMC3 declares32(×16KB = 512KB) across 60 swap banks (PRG_BANK_00..59,$2000each) plus the fixed last 4×8KB, declared in physical-bank orderPRG_A0/PRG_C0/PRG_80/PRG_FIXsoPRG_80(the$8000window = mode-1 second-to-last, physical bank 62) andPRG_FIX(bank 63) land on the right physical banks (#291 — a wrong order silenced every MMC3 ROM). Add theMEMORYregion sizes and confirm they equalprg_rom_size. - The mapper-number nibble in the header flags byte must equal
mapper_number(NROM$00→0, MMC1$10→1, MMC3$40→4; the mapper low nibble lives in the high nibble of flags-6 — confirm againstdocs/MAPPER_MMC3_REFERENCE.md). - A header that claims a different mapper or PRG size than
nes.cfgis HIGH (_audit-severity.md: "Mapper header /nes.cfgmismatch").
Dimension 2: reset/NMI/IRQ vectors and the 60Hz NMI music call
The reset/NMI/IRQ vectors at $FFFA–$FFFF must point at real code. Read the
.segment "VECTORS" block emitted by _generate_main_asm() in
nes/project_builder.py (the .word nmi / .word reset / .word irq triple, currently
around lines 642–646) and confirm:
- All three labels (
nmi,reset,irq) are defined in the samemain.asm. resetends by enabling NMI (lda #$80 / sta $2000,nes/project_builder.py:613-614) so the handler actually fires, and thenmihandler callsjsr update_music(nes/project_builder.py:629) once per frame (the 60Hz tick — see_audit-common.md"Playback runs at 60 FPS via NMI").- In the linker config the
VECTORSsegment loads at exactlystart = $FFFAand the preceding code region (NROM/MMC1PRGFIXED, MMC3PRG_FIXending$FFF9) does not overlap it. A missing label, an NMI that never callsupdate_music, or vectors that don't land at$FFFAis CRITICAL (bad vectors). Commit2d9c8dc("make the default pipeline assemble, link, and boot", #5/#7/#39) fixed the default (MMC3, patterns-on) path specifically — verify it still boots and hasn't regressed. - MMC1 only: MMC1 has no post-link vector fixup anymore.
generate_linker_config()emitsVECTORS: load = PRGFIXED, start = $FFFA(mappers/mmc1.py:103), telling ld65 to place the vectors at CPU$FFFAinside the fixed bank (file offset0x2000A) directly. A previousgenerate_post_process_commands()step copied 6 bytes from file offset0xFFFA(inside the switchable window, not the vectors) over the correctly placed vectors, bricking every MMC1 ROM built viabuild.sh— it was removed (#213), soMMC1Mappernow inheritsBaseMapper.generate_post_process_commands()(a no-op). Verify ld65 still lands the vectors at0x2000Aunassisted, and that no mapper reintroduces a fixup that overwrites them.
Dimension 3: APU initialization in the boot path
A ROM whose APU is never initialized produces no/garbage sound and can leave channels in
an undefined state. Trace the boot path: reset → jsr init_music. Both export paths
now write $4015 and $4017 before playback (fixed by #7, part of 2d9c8dc) —
verify this still holds and check for paths that might bypass it:
- Direct/table export (
--no-patterns):exporter/exporter_ca65.py'sinit_music(around lines 756-768) writeslda #$40 / sta $4017(frame counter mode 1, IRQ off) andlda #$0F / sta $4015(enable all 4 channels), plus disables the pulse sweep units ($4001/$4005). - Bytecode export:
init_musicjumps toaudio_init(nes/audio_engine.asm, around lines 90-138), which performs the equivalent$4017/$4015writes before returning. - The DPCM play path (
@write_dpcminnes/audio_engine.asm, ~line 512) writes$4010/$4012/$4013then toggles$4015(disable-then-enable-with-DMC) to trigger playback — verify channel enables aren't left disabled after the trigger. (The oldseq_cmd_dpcm_playcopy innes/project_builder.pywas deleted as dead code — #314/EXP-12.) - Missing APU init on any of these paths is CRITICAL per
_audit-severity.md. Citedocs/NES_APU_REFERENCE.mdfor the register map anddocs/APU_FRAME_COUNTER_REFERENCE.mdfor $4017.
Dimension 4: PRG capacity / overrun detection (the central risk)
This is now a wired pre-flight, not an open question — verify completeness and look for gaps it doesn't cover (#11, #126, #127, all fixed).
main.py:check_mapper_capacity()(~lines 145-163) callsmapper.validate_segment_sizes(estimate_segment_sizes(music_asm_path))and raisesValueError(caught and turned into a clean exit) before any project files are written. It is invoked fromrun_prepare()(~line 246) and from the full pipeline (~line 690), in both cases beforeNESProjectBuilder.prepare_project()runs.BaseMapper.validate_segment_sizes()(mappers/base.py:161-178) is a flat total-vs-get_data_capacity()check — correct for NROM/MMC1, which don't distribute data across banks.MMC3Mapper.validate_segment_sizes()(mappers/mmc3.py:171-222) overrides it to size each region separately:RODATA+CODEagainst thePRG_FIXbudget,CODE_8000against the 8KB$8000window, and each bank index — summingBANK_NN+DPCM_NNthat share the same physicalPRG_BANK_NNregion (#212) — against the 8KB bank size, plus a check that no bank index exceedsSWAP_BANK_COUNT(60) — this closes the "no cap on bank count" gap (#127).- Remaining things to verify on each audit:
estimate_segment_sizes()(main.py:93-...) is a text-scan heuristic (regex counts over.byte/.word/.incbinper active.segment), not a real assembly. Check it can't systematically under-count (e.g. multi-directive lines, macros that expand to more bytes than one.byte/.wordper line) in a way that lets an oversized song pass the pre-flight and hit a rawld65region-overflow instead —ld65remains the correctness backstop, but a misleading pre-flight message is at least MEDIUM.- The capacity gate lives entirely in
main.py(the CLI layer). ConfirmNESProjectBuilder.prepare_project()itself (nes/project_builder.py) does not callvalidate_segment_sizes/check_mapper_capacity— a caller usingNESProjectBuilderas a library directly (bypassingmain.py) gets no pre-flight and relies solely onld65erroring at link time. Flag as a defense-in-depth gap (MEDIUM) rather than a silent-overrun risk, sinceld65still errors on overflow. - Sanity-check the capacity numbers themselves: NROM
get_data_capacity()(mappers/nrom.py:67-69) returns 30KB against a 32KB ROM;BaseMapper's default (mappers/base.py:140-147) subtracts a flat 2048 bytes for code+vectors. Flag a capacity that doesn't leave room for the actual code/engine size as MEDIUM.
- Do not confuse this pre-flight with
can_fit_data()/auto_select()— see Dimension 6; those are a separate mechanism not called from this path.
Dimension 5: bank-switching correctness (MMC1 / MMC3)
Re-derive the bank-switch sequences against the reference docs:
- MMC1
generate_init_code()(mappers/mmc1.py:75-100) uses the 5-write serial load (sta $8000…) withlsr ashifting one bit per write into the control/bank registers. Confirm the write count, the target register address, and the control value ($0C= 16KB PRG mode, fixed high bank) againstdocs/MAPPER_MMC1_REFERENCE.md. A wrong write count or address leaves the mapper in an undefined state (CRITICAL if it affects the bank holding running code). - MMC3
generate_init_code()(mappers/mmc3.py:103-123) selects bank registers via$8000/$8001(R6$46, R7$47) andgenerate_bank_switch_code()(lines 125-141) definesswitch_dpcm_bank. Confirm the PRG mode bit, that R6/R7 map the windows the engine actually reads ($C000-$DFFFDPCM,$A000-$BFFFsequence — seefetch_sequence_byteinnes/project_builder.py~lines 199-231), and thatsta $E000disables the MMC3 IRQ. Cross-check againstdocs/MAPPER_MMC3_REFERENCE.md. - Verify the
nes.cfgbank layout matches (mappers/mmc3.py:50-109): MMC3 maps banks 0–59 all atstart = $C000(so addresses resolve in the swap window) and the last four in physical-bank declaration orderPRG_A0($A000),PRG_C0($C000),PRG_80($8000),PRG_FIX($E000). This order is load-bearing: in PRG mode 1 the$8000window is hardwired to the second-to-last physical bank, soPRG_80(which hostsCODE_8000's period/instrument/macro tables the engine reads with absolute addressing) must be bank 62 andPRG_FIXbank 63. A prior order that putPRG_80earlier made every note read$FFfill — silent ROM, green screen, no crash (#291). Confirm this matches how the engine swaps and reads.
Dimension 6: MapperFactory auto-selection
In mappers/factory.py, auto_select(data_size) (lines 83-114) walks _default_mappers
(nrom→mmc1→mmc3) and returns the first whose can_fit_data() is true; the
module-level get_mapper("auto", data_size=0) (lines 161-177) falls back to MMC3 when
no size is given, deliberately matching the pipeline's hardcoded default (fixed by #25,
commit 573890e; remaining doc-rot cleaned up by #43/#44, commit ab6f95d).
Check on each audit:
- The ordering is genuinely smallest-first by capacity; the "nothing fits" branch raises with the largest mapper's capacity.
auto_select()is now reached from the CLI (#217/MAP-6).resolve_mapper()(main.py:239) — called fromrun_prepare(),run_compile(), and the full pipeline — maps--mapper autotoMapperFactory.auto_select(estimate_music_data_size(...)), picking the smallest mapper that fits. The size-based auto-selection machinery is no longer test-only, so verifyauto_select's ordering and the forced-mapper overrides below actually agree with what links.- Beyond size,
--mapperresolution enforces engine/mapper compatibility (verify each raises a cleanValueError, not a rawld65failure):resolve_mapper()forces MMC3 for a music.asm built by the MMC3 macro-bytecode (pattern) exporter; a direct (--no-patterns) export bin-packed for a banked mapper stamps; Direct export bank-packed for <name>and is honored underauto/ rejected on a mismatch (#283/#285); andenforce_direct_export_dpcm_mapper()forces MMC3 (or rejects an explicitmmc1/nrom) when a--no-patternssong has a DPCM channel, because the direct-export DPCM trigger andDPCM_NNsegments are MMC3-only (#281/#282). - A threshold that picks a mapper too small for the data (so it overruns) ties back to
Dimension 4 and is CRITICAL, and is now reachable via
--mapper auto— confirm the capacity pre-flight (Dimension 4) still catches any such pick beforeld65.
Dimension 7: project builder writes a consistent, buildable project
NESProjectBuilder.prepare_project() (nes/project_builder.py:75-544) must emit a set
of files ld65 can actually link with the chosen mapper:
nes.cfgcomes fromself.mapper.generate_linker_config();main.asminterpolatesself.mapper.generate_header_asm()/generate_init_code()/generate_bank_switch_code(). Confirm every segment the asm uses (HEADER,ZEROPAGE,CODE,RODATA,BSS,VECTORS,CODE_8000, theDPCM_*/BANK_*segments) exists in that mapper'snes.cfg, and vice-versa (#215 removed MMC3's unusedOAMregion/segment — a strayOAMon either side is now drift). The default (MMC3, patterns-on) pipeline now assembles, links, and boots end-to-end (#5/#7/#39,2d9c8dc) — re-verify this holds rather than re-deriving it from scratch each time.mappers/mmc3.py'sgenerate_header_asm()(lines 38-48) emits bare.bytedirectives only, matching the NROM/MMC1 contract — the previous double.segment "HEADER"declaration is fixed (#22, commit007f5c4). The standalone-export path inexporter/exporter_ca65.py(around lines 210-222) is now the sole owner of.segment "HEADER"for every mapper, and the stale comment that used to claim MMC3 embedded its own segment was corrected (#216). Verify the header segment is still emitted exactly once per build.- ZP/BSS variable definitions vs
.importzp/.globaldeclarations must match betweenmain.asmandmusic.asm(e.g.sequence_ptr,sequence_bank,frame_counter,switch_dpcm_bank). An undefined symbol surfaces only at link time. A project that cannot link is at least HIGH.
Dimension 8: compiler validation & CC65 error surfacing
compiler/compiler.py (ROMCompiler.validate_project / compile) and
compiler/cc65_wrapper.py (CC65Wrapper.assemble / link / check_toolchain /
get_version):
validate_project()(compiler/compiler.py:39-65) requiresmain.asm,music.asm,nes.cfg. Confirm it actually runs before assembly and that the missing-file list is accurate.assemble()(compiler/cc65_wrapper.py:119-173) andlink()(lines 175-236) checkresult.returncode != 0and raiseCompilationErrorcarryingstderr. This is correct today — verify it stays that way.check_toolchain()(compiler/cc65_wrapper.py:34-81) andget_version()(lines 83-117) now resolveca65/ld65viashutil.which()first and probe--versionon the resolved path, not the bare command name, with atry/except (FileNotFoundError, subprocess.TimeoutExpired)guard around eachsubprocess.run(fixed by #14, commit48da1ea) — verify a vanished/renamed binary between thewhich()check and the probe still raisesToolchainErrorcleanly rather than an uncaught exception.compile_rom()'s broadexcept Exception(compiler/compiler.py:252-260) printsf"[ERROR] Compilation failed: {e}", returnsFalse, and now callstraceback.print_exc()under--verbose(#32, fixed). Both callers thread the flag:run_compile()(main.py:472) and the full pipeline (main.py:1057) passverbose=...tocompile_rom(). Verify the traceback actually surfaces under--verbose, and that the typedCompilationError/ValidationErrorpaths still print a clean one-liner without a stack dump.- Build-script routing is fixed (#18, commit
e68866a):_create_build_script()callsself.mapper.generate_build_script(is_windows)for every mapper. The post-link fixup gap is also closed (#214):ROMCompiler.compile()(compiler/compiler.py:113-222) now callsmapper.generate_post_process_commands()after linking (via_run_post_process) when amapperis passed, socompiler.compile_rom()/main.py compileandbuild.shrun the same fixups. MMC1 no longer has a fixup at all (#213, Dimension 2), so the remaining things to verify are: any future mapper that adds one is exercised on both paths, and_run_post_process'sshell=Trueonly ever runs the static mapper-constant text it documents, never caller-derived strings. - The
--mapperflag now exists (export/prepareacceptauto|nrom|mmc1|mmc3,compileacceptsnrom|mmc1|mmc3; all defaultmmc3).preparestamps the built mapper intones.cfgas a leading ld65 comment (NES_CFG_MAPPER_MARKER,nes/project_builder.py:20, written atnes/project_builder.py:320), andcompilerecovers it authoritatively via_prepared_mapper_name_from_cfg()(main.py:218-236), falling back to--mapperonly for older marker-less projects — so a marker-less NROM/MMC1 project no longer defaults tommc3and gets mis-sized (#297, fixed — verify it holds), and aprepare --mapper autoproject now compiles (#269, fixed — verify it holds). The recovered name is still threaded throughresolve_mapper()with the project's ownmusic.asmso a mapper that can't run this project's bytecode engine is rejected cleanly (run_compile,main.py:460-463). - Cross-reference (not owned by this audit): REG-10 (#128) — the ROM-compile integration
tests in
tests/test_rom_validation_integration.pyused topytest.skip()on a realcompile_rom()failure instead of failing; this is now closed (#128). Re-verify the tests fail (not skip) on a compile regression rather than trusting the fix is permanent. See/audit-regression.
Dimension 9: ROM size check
compile() now takes a mapper argument (#28, fixed): when one is passed it checks the
linked ROM's size against the mapper's exact declared size, mapper.prg_rom_size + INES_HEADER_SIZE (16), raising CompilationError on any mismatch
(compiler/compiler.py:199-214). MIN_ROM_SIZE = 32768 (compiler/compiler.py:32) is
now only the fallback floor used when mapper is None. Both CLI callers pass the resolved
mapper, so the truncated-512KB-image gap is closed on the CLI path. Verify:
- The exact check uses the right expected size per mapper (NROM 32KB+16, MMC1 128KB+16, MMC3 512KB+16) and that a correctly linked ROM matches it exactly (ld65 fills every declared region, so the file should equal the declared size).
- The
mapper is Nonefallback (library callers ofcompile_rom()that pass no mapper) still only enforces the flat 32768 floor — flag reliance on it as a defense-in-depth gap (MEDIUM), since a truncated large image ≥32768 bytes would slip past it.
Dimension 10: default-mapper doc drift
The codebase's "defaults" agree on MMC3: main.py:run_prepare (line 244) and
run_full_pipeline (line 685) instantiate MMC3Mapper() explicitly; NESProjectBuilder.__init__
defaults mapper_name="auto"; and get_mapper("auto", data_size=0) falls back to MMC3
(mappers/factory.py:172-177). This conflict was resolved by #25 (commit 573890e) and
remaining doc mentions cleaned up by #43/#44 (commit ab6f95d). Re-check on each audit
rather than trusting this is permanent:
grep -niE 'always use mmc1|default.*mapper|mmc1' README.md CLAUDE.md docs/*.md. As of this pass,CLAUDE.mdandREADME.mdconsistently describe MMC3 as the pipeline default with MMC1/NROM as selectable; the only othermmc1hits are legitimate (MMC1 register/bank-switch reference docs, an SRAM aside indocs/2A03_CPU_REFERENCE.md, and DPCM docs noting MMC1 as a capable mapper choice) — none reassert MMC1 as the default.- Any code path or
docs/*.mdthat reasserts MMC1 as the default is doc-rot (LOW); a real auto-vs-pipeline default disagreement (were one reintroduced — e.g. ifmain.pystopped passing an explicit mapper, orget_mapper("auto", 0)changed its fallback) would be MEDIUM.
Skeptical checklist (run before writing each finding)
- [ ] Sum the
nes.cfgMEMORYregions — do they equalprg_rom_size? Does the header PRG byte agree? - [ ] Does the mapper-number nibble in the header equal
mapper_number? (cite the doc) - [ ] Are
nmi/reset/irqall defined, and doesnmijsr update_music? - [ ] Does
resetenable NMI (sta $2000) and init the APU ($4015/$4017) viainit_music/audio_init? - [ ] Is
check_mapper_capacity()/validate_segment_sizes()actually reached beforeld65runs on both theprepareand full-pipeline paths? Does it run at all whenNESProjectBuilderis used directly, bypassingmain.py? - [ ] Is
auto_select()reached via--mapper auto(throughresolve_mapper), and do the forced/rejected--mapperguards (bytecode, direct-export bank-pack, direct-export DPCM) raise cleanly? (see Dimension 6) - [ ] On a capacity overflow, does the pre-flight message name the right region, and does
ld65still error if the heuristic under-counts? - [ ] Do MMC1's 5-write loads and MMC3's R6/R7 selects match
docs/MAPPER_*.md? - [ ] Do
assemble/linkraise on nonzero return code with stderr attached? Doescompile_rom()'s broadexcept Exceptionprint a traceback under--verbose(#32)? - [ ] Does every segment used in
main.asm/music.asmexist in the active mapper'snes.cfg? - [ ] Does
ROMCompiler.compile()invokegenerate_post_process_commands()when passed a mapper (#214), matchingbuild.sh? (MMC1 no longer needs a fixup — #213.) - [ ] Did I try to disprove the finding by re-reading the code path?
Output
Write the report to docs/audits/AUDIT_MAPPERS_<TODAY>.md (replace <TODAY> with
today's date, YYYY-MM-DD). Structure:
- Summary — finding counts by severity, the highest-leverage fix, and a one-line verdict on whether the default-mapper pipeline produces a bootable ROM.
- Findings — base format from
_audit-common.mdplusDimensionandHardware ref, ordered by severity (CRITICAL first).
Then suggest:
/audit-publish docs/audits/AUDIT_MAPPERS_<TODAY>.md
TDD Red-Green-Refactor
Testing
Skill qui guide Claude a travers le cycle TDD complet.
Audit d'Accessibilité Web
Testing
Réalise un audit d'accessibilité web complet selon les normes WCAG.
Générateur de Tests UAT
Testing
Génère des cas de test d'acceptation utilisateur structurés et complets.