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 488–491) 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:458-459) so the handler actually fires, and thenmihandler callsjsr update_music(nes/project_builder.py:474) once per frame (the 60Hz tick — see_audit-common.md"Playback runs at 60 FPS via NMI").- Jukebox builds add work inside the NMI (#30/F-13):
_generate_main_asminjects edge-triggered Start-button polling and ajsr audio_advance_song(nes/project_builder.py:365-386) into the same handler. Verify the added controller read and song switch still fit the NMI budget and cannot leave the APU mid-write, and that the$4016strobe is the DPCM-conflict-safe read already in this file (nes/project_builder.py:276-300) rather than a second, naive one. - 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).
check_mapper_capacity()(mappers/capacity.py:84-102, re-exported frommain.pyfor existingfrom main import check_mapper_capacitycallers — #363/MAP-2026-07-19-3) callsmapper.validate_segment_sizes(estimate_segment_sizes(music_asm_path))and raisesValueError(caught and turned into a clean exit at the CLI layer). It is invoked fromrun_prepare()/the full pipeline inmain.py(main.py:491,:1070, against the raw exporter output, beforeNESProjectBuilderis even constructed — kept for its fast, clean early-exit UX) AND, independently, fromNESProjectBuilder.prepare_project()(nes/project_builder.py, aftermusic.asmis written toself.project_path— #389/ MAP-2026-08-05-2) — so a library consumer callingNESProjectBuilder(...).prepare_project(...)directly now gets the same pre-flight the CLI gets, sized against the actual finalmusic.asm(debug overlay /fetch_sequence_byte/ DPCM-stub content all folded in), not just an eventual rawld65overflow. Separately,ROMCompiler.compile()(compiler/compiler.py:188-194) recovers the mapper from thenes.cfgmarker via_recover_mapper_from_cfgwhen none is passed, and uses it for the post-link exact-size check (:242-252,rom_size == mapper.prg_rom_size + header) instead of the flatMIN_ROM_SIZEfloor — a different check thancheck_mapper_capacity, but the same "library caller gets CLI-quality diagnostics" motivation.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()(mappers/capacity.py:20-..., re-exported frommain.py) is a text-scan heuristic (regex/token 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.- #390 (MAP-2026-08-05-3) is CLOSED:
.byte/.wordoperand counting used to split on every comma and treat each resulting token as exactly one byte — correct for a numeric literal, wrong for a quoted string token (undercounted its real character length, e.g..byte "NES", $1Acounted as 2 bytes instead of 4) and wrong the other way for a comma embedded inside a string (over-split into extra tokens).mappers/capacity.py's_split_operands()/_byte_operand_length()now quote-aware split and count a string token's actual length. Verify-the-fix: a.byte "some, string", $00line must size as 13 bytes (12-char string + 1), not 3 tokens' worth; a plain numeric.byteline's count must be unchanged. - #363 (MAP-2026-07-19-3) is CLOSED: the capacity gate used to live entirely in
main.py(the CLI layer), so a caller usingNESProjectBuilderas a library directly (bypassingmain.py) got no pre-flight and relied solely onld65erroring at link time — flagged as a defense-in-depth gap.check_mapper_capacityis nowNESProjectBuilder.prepare_project()'s own call, not something only the CLI does on its behalf. Verify-the-fix: it fires unconditionally (not just when invoked viamain.py) — e.g. by constructingNESProjectBuilderdirectly in a test and confirming an oversized song raises there too, not just through the CLI. - #389 (MAP-2026-08-05-2) is CLOSED:
NESProjectBuilder.prepare_project()'s own capacity check used to run on the pre-transform sourcemusic.asm, strictly before the--debugoverlay /fetch_sequence_byte/ DPCM-stub content were appended — so a song that only overflowed once that ~800+ bytes of extra content was added slipped past the pre-flight (surfacing as a rawld65overflow, or, on a mapper with a switchable direct-export bank, not failing cleanly at all — see #388). The call now runs on the final writtenmusic.asm, after every transform is folded in.main.py's own earlier CLI-layer call is unchanged (still sizes the raw exporter output, beforeNESProjectBuilderis constructed) and is not the last word for a--debugbuild. Verify-the-fix: a song sized to fit within capacity on its own, but not once the debug overlay is added, must raise fromNESProjectBuilder.prepare_project(debug_mode=True). - 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, direct=False) (lines 84-114) walks
_default_mappers (nrom→mmc1→mmc3) and returns the first whose capacity check is
true; the module-level get_mapper("auto", data_size=0) 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(...), direct=...), 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.- #361 (MAP-2026-07-19-1) is CLOSED:
auto_selectused to rank every mapper by the flat bankedget_data_capacity()(NROM 30K < MMC1 112K < MMC3 522K) even for a direct (--no-patterns) export — but MMC3's direct export cannot bank-pack (every frame table lands in the single ~6KPRG_FIXbank), so for a direct song over ~112K, auto "picked" MMC3 and the direct pre-flight then immediately rejected it — a contradictory pick, not an overrun.BaseMapper.direct_export_capacity()(mappers/base.py:171-184, default =get_data_capacity();MMC3Mapperoverrides to thePRG_FIXbudget,mappers/mmc3.py) andauto_select(direct=True)(ranks bydirect_export_capacity()instead —mappers/factory.py:108) now exist, and all three direct-export call sites (resolve_mapper,run_export,run_full_pipelineinmain.py) passdirect=True. MMC3 is now effectively excluded from direct-export auto-selection; an oversized direct song raises a clear "enable pattern compression" error. Verify-the-fix: confirm every direct-export call site still passesdirect=Trueand that the bytecode (patterns-on) call sites do not (they should keep ranking by the full banked capacity). - 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). - #362 (MAP-2026-07-19-2) is CLOSED: unlike the bytecode and MMC1 bank-pack paths, a
direct-export DPCM
music.asm(necessarily MMC3, sinceplay_dpcmwrites MMC3's$8000/$8001ports andDpcmPackeremits MMC3-onlyDPCM_NNsegments) carried no marker — so the split prepare/compile flow, which only sees the finishedmusic.asm, would honor a stray--mapper nromand fail atld65with a cryptic "Missing memory area assignment for DPCM_00" instead of a clean error.export_direct_frames(exporter/exporter_ca65.py) now stamps a"; Direct export DPCM (MMC3-only)"marker when a DPCM channel is present, andresolve_mapper()forces MMC3 forauto/ rejects a non-MMC3 explicit--mapperon that marker, mirroring the existing bytecode and bank-pack markers above. Verify-the-fix: confirm the marker is present in every direct-export music.asm that has a DPCM channel, and thatresolve_mapperchecks for it before the bank-pack marker (a song can't be both). - 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(music_asm_path, song_count=None)
(nes/project_builder.py:83) 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. - The
JUKEBOX_BUILDgate (#30/F-13) — the one condition that decides whether asong buildROM links at all.prepare_projectwritesJUKEBOX_BUILD = 1ahead of the.includeofnes/audio_engine.asm, and_generate_main_asmsetsjukebox_mode, whensong_count is not None— deliberately notsong_count > 1.CA65Exporter.export_song_bank_bytecodealways emits jukebox-format symbols (song{i}_prefixes,song_table,jmp audio_init_song) regardless of song count, so a 1-song bank needs the gate too; the original> 1test left a 1-song build with 8 unresolved externals (MAP-2026-08-07-1, fixed in8ea7ac3). Verify-the-fix: the exporter's symbol format and the builder's gate must be driven by the same condition. Any future "optimization" that makes the exporter emit single-song symbols for a 1-song bank has to change both sides together — check both call sites, and treat a one-sided change as HIGH (link failure on a whole bank size). song_countitself is only ever passed byrun_song_build(main.py) — the documented splitprepare/compileflow (main.py'srun_prepare) and any library caller ofprepare_projectnever pass it. Fixed (#453/MAP-2026-08-21-1, verify):prepare_projectnow auto-detects jukebox mode frommusic_contentitself (the"multi-song jukebox build"markerexport_song_bank_bytecodealways stamps on line 1) whensong_count is None, treating that the same as an explicitsong_count. Before this fix,prepareon a jukeboxmusic.asm"succeeded" (capacity pre-flight passes, files written, "Ready for CC65 compilation!") and only failed two steps later atld65with 8 unresolved externals — a UX/defense-in-depth gap (ld65never produced a corrupt ROM), not a correctness break, since the normalsong buildroute already passedsong_countexplicitly and was unaffected. Verify-the-fix:prepare(nosong_count) on a jukebox music.asm still definesJUKEBOX_BUILDand links; an ordinary single-song bytecode music.asm (no jukebox marker) is not false-positive-detected as jukebox.- Retired placeholders: the old
prepare_multi_song_project/add_song_bankstubs are gone now thatsong buildis a real route. If either name reappears, it is dead code (/audit-tech-debt), not a feature.
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()andlink()checkresult.returncode != 0and raiseCompilationErrorcarryingstderr. This is correct today — verify it stays that way.check_toolchain()andget_version()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. Fixed (#454/MAP-2026-08-21-2, verify):assemble()/link()themselves used to buildcmdfrom the bare"ca65"/"ld65"strings instead of the storedself._ca65_path/self._ld65_path— undercutting #14's own TOCTOU/PATH- divergence rationale one call later — and caught onlysubprocess.TimeoutExpiredaround the real run, so a binary that vanished or was PATH-shadowed betweencheck_toolchain()and the real assemble/link raised a rawFileNotFoundErrorthat escaped as a generic message viacompile_rom's broadexcept Exception, instead of the typedToolchainErrorevery other missing-tool path produces. Both now useself._ca65_path or "ca65"/self._ld65_path or "ld65"and catchFileNotFoundErroralongside the timeout, mapping it toToolchainError.compile_rom()'s broadexcept Exceptionprintsf"[ERROR] Compilation failed: {e}", returnsFalse, and now callstraceback.print_exc()under--verbose(#32, fixed). Both callers thread the flag:run_compile()and the full pipeline passverbose=...tocompile_rom(). Verify the traceback actually surfaces under--verbose, and that the typedCompilationError/ValidationError/ToolchainErrorpaths still print a clean one-liner without a stack dump. Fixed (#457/SAFE-2026-08-21-3, verify):compile_romused to catch onlyCompilationError/ValidationError— a missing/vanished toolchain (ToolchainError, e.g. from the fix two bullets above) fell to the genericexcept Exceptionwhose own comment claimed the two typed clauses "cover every anticipated failure." A thirdexcept ToolchainErrorclause now closes that gap, andbuild_and_validate_rom(main.py) itself was fixed the same way — its prepare/compile/validate failures used to raise bareRuntimeError, misreporting as "Unexpected pipeline failure" one layer up inrun_full_pipeline's typed/untyped split (cross-ref/audit-safetyDimension 1) even though CC65-not-installed is the single most common real-world trigger of this whole function failing.- 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:361), andcompilerecovers it authoritatively via_prepared_mapper_name_from_cfg()(main.py:345-364), 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.