description: "Audit output exporters — CA65/NSF/FamiTracker assembly and bytecode correctness" argument-hint: "[--focus <dims>]"
Exporters Audit
Audit the output-format generators in exporter/ — the stage that turns the
frames dict into something a build toolchain or external tracker consumes. The
default python main.py input.mid output.nes run goes through the CA65 path
(exporter/exporter_ca65.py → music.asm → nes/project_builder.py → CC65), so
that path carries the most weight; NSF and FamiStudio are secondary outputs that
must stay consistent with it for the same input.
Shared protocol: .claude/commands/_audit-common.md — read the export contract
(export → CA65Exporter.export_tables_with_patterns(frames, patterns, references, output_path)
writes music.asm), the inter-stage data contracts, and the dedup/finding format
there. The bytecode this stage must emit is specified in docs/AUDIO_BYTECODE_SPEC.md
and the macro semantics in docs/MACRO_USAGE_GUIDE.md — treat both as the target the
6502 engine plays back. Severity rubric: .claude/commands/_audit-severity.md. Do not
restate either file; apply them.
A recent bug-fixing sprint closed most of the exporter findings from the prior audit
(AUDIT_EXPORTERS_2026-06-29.md). Several dimensions below have been reframed from
"here is a live bug" to "verify the fix holds / check edge cases" — don't re-report
a closed issue as new without re-confirming against current code first (see the
dedup protocol in _audit-common.md).
Parameters (from $ARGUMENTS)
--focus <dims>— comma-separated dimension numbers (e.g.--focus 1,5). Default: all.
Extra Per-Finding Field
- Dimension: one of the 8 below.
- Spec ref: cite
docs/AUDIO_BYTECODE_SPEC.md/docs/MACRO_USAGE_GUIDE.mdsection, or the consumer (nes/project_builder.py) for emitted-format claims.
Dimensions
Dimension 1: CA65 Assembly Well-Formedness & Builder Compatibility
The text export_tables_with_patterns and export_direct_frames write in
exporter/exporter_ca65.py must assemble under ca65 and link under the config
nes/project_builder.py generates. Skeptical checklist:
- Every label referenced is defined: the
.exportline (pulse1_sequence,pulse2_sequence,triangle_sequence,noise_sequence,dpcm_sequence,ntsc_period_low,ntsc_period_high,instrument_table, thedpcm_*_tables) has a matching definition;macro_vol_*/macro_arp_*/macro_pitch_*/macro_duty_*referenced frominstrument_table.wordrows (exporter_ca65.py:1172) all exist. - Segments emitted (
CODE_8000,BANK_{NN},DPCM, and inexport_direct_framesHEADER/ZEROPAGE/BSS/RODATA/CODE/VECTORS) are all declared in the linker confignes/project_builder.pywrites — a segment the exporter emits butnes.cfghas no MEMORY/SEGMENT for is a link failure. Cross-checkdocs/MAPPER_MMC3_REFERENCE.md. .importzp ptr1, temp1, temp2, frame_counter(pattern path,:981) vs.importzp frame_counter, temp_ptr(direct path,:228): confirm the importing names are exported/.global'd bynes/project_builder.py'smain.asm. A mismatched zeropage symbol is a link failure.- The
non-standalonebranch (:1270-1279) emits.import audio_init, audio_updateand jumps to them — confirm those exist in the engine the builder ships. .byte $FE, ${next_bank:02X}, <{label}, >{label}bank-jump lines (:1239): the forward label is defined in the nextBANK_{NN}segment in the same file (:1238) — verify it always is, including whenMAX_SEQUENCE_BANKis reached (the code now raisesValueErrorinstead of silently overflowing the bank budget,:1229-1237— confirm this guard still fires for every over-budget path, not just this one call site).
A label/segment that fails to assemble or link = HIGH (wrong output on every ROM through this path).
Dimension 2: APU Register Serialization Correctness
export_direct_frames writes literal APU stores (sta $4000/$4002/$4003 for
pulse1, $4004–$4007 pulse2, $4008/$400A/$400B triangle). The named
constants APU_PULSE1_CTRL…APU_STATUS at the top of exporter/exporter_ca65.py
(:6-29) define $4000–$4015. Check:
- Each channel writes its own register block, not another channel's (off-by-$4 bugs).
- The triangle path never writes a duty/volume-shaped control byte — triangle has no
volume or duty (
docs/APU_TRIANGLE_REFERENCE.md). Noteexport_direct_framesbuilds trianglecontrolas0x80 | (volume * 7)(:346); confirm that targets the linear-counter semantics ($4008) and is not treated as a pulse volume nibble. ora #$08(:625) before the timer-hi store sets the length-counter reload bit — confirm that is the intended $4003/$4007/$400B bit perdocs/APU_LENGTH_COUNTER_REFERENCE.md.$4015channel-enable and$4017frame-counter init (:466/:848) in the standalone reset and theinit_musicblock matchdocs/NES_APU_REFERENCE.md/docs/APU_FRAME_COUNTER_REFERENCE.md.- Verify fix (#78, closed): continuation (sustain) frames must reuse the same
per-channel pitch table as the frame that started the note.
midi_note_to_timer_valueis called with thechannelargument on both the note-start path (:1115) and the continuation path (:1134, explicit regression comment citing #78) — omittingchannelpreviously defaulted triangle to the pulse/16table and bent every sustained triangle note flat. Confirm no other call site (e.g. a future refactor) reintroduces a channel-less call inside the continuation branch. - Verify fix (#81, closed): the old
NSFExporter._generate_play_routine/_serialize_compressed_datahand-assembled opcodes (with aBEQ/BNEoffset bug and JSON-as-data) no longer exist inexporter/exporter_nsf.py—export()/export_nsf()(:73-80) now raiseNotImplementedErrorimmediately instead of emitting broken machine code. Confirm nothing still calls the deleted private methods, and thatNSFHeader(:7)/NSFMacroPacker(:83, retained as scaffolding) are dead code with no live caller that would trip over their draft state.
Wrong register address or triangle driven with volume/duty = HIGH.
Dimension 3: Pattern-vs-Empty Export Paths
run_export in main.py calls export_tables_with_patterns with patterns={} when
no --patterns file is given, and export_tables_with_patterns early-returns
(exporter_ca65.py:973-974) to export_direct_frames when not patterns. So there are
two completely different emitters (literal frame tables vs macro bytecode) selected
by truthiness of patterns. Check:
- Both paths produce assembly the same
nes/project_builder.pycan build — they emit different segments and different exported symbols (the direct path has no*_sequence/instrument_table; the macro path has no*_note/*_controltables). If the builder/engine expects one shape, the other path is silently broken. This is at least HIGH; if the builder accepts it but the song is wrong, CRITICAL. export_tables_with_patternsstill ignores itsreferencesargument entirely — this is now explicitly documented in the method's own docstring (:963-972, citing #4) as intentional: the macro path re-derives events fromframes, and pattern/reference compression is analysis/metrics only. Confirm the docstring's claim still matches behavior (grepreferencesinside the method body — it should appear only in the signature/docstring) rather than re-reporting this as a new finding.- The empty-patterns path is the default
python main.py input.mid out.nesrun — a regression there hits every user.
Dimension 4: Byte-Range Safety (no value >255 or negative emitted)
Every .byte ${val:02X} must receive 0–255; .word rows must receive valid 16-bit
labels. Hunt for values that can exceed a byte without clamping in
exporter/exporter_ca65.py:
- Verify fix (#80, closed) — EXP-04: the
.byte $80, ${inst_id:02X} ; CMD_INSTRUMENToperand at:1251can no longer widen past two hex digits.inst_idis now assigned by the static helper_register_instrument(:905-924, called at:1109/:1164), which raisesValueErrorwhenlen(instrument_defs) > 0xFF(guard at:916) instead of handing back a 3-hex-digit id for a song with >256 unique (vol,arp,pitch,duty) tuples. Theloop_starthalf of the original finding is now moot: loop compression was removed (#163/NH-21), so_compress_macro(:926-960) only ever appends$FFand never emits a rawloop_startoperand into the macro byte stream (formatted${val:02X}at:1179). Confirm the instrument guard still fires on every over-budget path and that no macro byte can exceed 255 without clamping. - Verify fix (#77, closed): a legitimate volume/pitch/arp value of
0xFF/0xFEcan no longer collide with the End/Loop control bytes._encode_macro_offset(:71-84) clamps every signed pitch/arp offset to[-128, 127]and then snaps the two colliding encodings away from the reserved bytes (MACRO_CTRL_END = 0xFF,MACRO_CTRL_LOOP = 0xFEat:68-69):-1 (0xFF) -> 0x00,-2 (0xFE) -> 0xFD. Confirm every pitch/arp encode site (note-start:1117/:1121, continuation:1136/:1137) routes through this helper rather than formatting a raw offset directly. - The
CMD_DMC_LEVEL($87) emitter was removed as a dead path (#72/D-09): no stage producesdmc_level, and greppingexporter_ca65.pyfor$87/CMD_DMC_LEVELnow turns up nothing. Note the engine (nes/audio_engine.asm) still contains an unreachable@cmd_dmc_levelhandler for it (and an unreachable@cmd_dpcm_playfor$85) — that's dead-code tech debt on the engine side (see/audit-tech-debt), not an exporter bug; if DMC-level control is ever reintroduced here, confirm the emitted level is range-checked to the 7-bit $4011 domain (docs/APU_DMC_REFERENCE.md, 0–127). - Verify fix (nes-hardware #158, closed, touches this file):
noteis now clamped on both ends before it's baked into the bytecode stream and fed back intomidi_note_to_timer_value:elif note > 95: note = 95(:1082-1083) and, for tone channels other than noise,elif channel != 'noise' and 0 < note < 24: note = 24(:1084-1092, added so the runtime base-period lookup and the pitch offset agree on the same note — a sub-C1 note previously producedbase_timer = 0and a pitch offset that overflowed the 11-bit timer). Confirm both clamps still hold. - Verify fix (#298, closed) — EXP-10: the clamp is no longer silent. Tone-channel
notes re-pitched by either clamp are counted (
:1099-1105, keyed on the pre-clamp source note so a sustained note counts once and dpcm — whose "note" is a sample id — is excluded), tallied ontoself.notes_clamped = {'high':.., 'low':..}(:1308), and reported with a one-line⚠ N note(s) clamped to the NES tone range (24-95)…summary at end of export (:1310-1315). Confirm the count still fires on both boundaries so an out-of-range song is surfaced rather than silently re-pitched — that counter is what moves this off the silent-clamp MEDIUM/CRITICAL boundary in the severity rubric.
Any out-of-range .byte = HIGH (won't assemble or wraps to a wrong value).
Dimension 5: Bytecode-Spec Conformance
Cross-check the bytes export_tables_with_patterns emits against
docs/AUDIO_BYTECODE_SPEC.md §3 (the command table) and §2 (data structures):
- Length+note encoding: code emits
${(write_dur - 1) + 0x60:02X}, ${note:02X}(:1259) withwrite_dur = min(rem_dur, 32)(:1258). Spec §3 Length Commands are$60–$7F= lengthvalue-$60+1(1–32 frames) — verify the cap of 32 and the-1bias match, and that notes still fall in the$00–$5Fnote range so the engine doesn't read a note as a length/command. - Verify fix (#83, closed) — EXP-07: the doc/code gap on
$FEis resolved. The exporter's live command set is$80(CMD_INSTRUMENT, matches spec §3),$FE+bank+ ptr_lo+ptr_hi (a sequence-level bank jump,:1239), and the$FFend-of-stream terminator.docs/AUDIO_BYTECODE_SPEC.md§3 now carries an explicit$FE CMD_BANK_JUMProw documenting the sequence-level meaning and calling out that it is distinct from the in-macro$FE, <offset>loop control byte (§2.3, now marked reserved/not- implemented). Exporter (:1239) and engine (nes/audio_engine.asm:266@cmd_bank_jump) agree on the sequence-level meaning, so there is no runtime bug. The$85 CMD_DPCM_PLAYand$87 CMD_DMC_LEVELrows still exist in §3, but each now notes the Python exporter does not emit them (DPCM triggers ride as note bytes; the$87emitter was removed, #72; see Dimension 4). Confirm the doc stays in sync rather than re-reporting the$FEgap. - Fixed (#163/NH-21, closed): NH-21 (nes-hardware audit) covered a macro-runtime
$FEhazard — the old_compress_macrocould emit loop-compressed macros ($FE, loop_start) that the shippedEVAL_MACROroutine (which only checks$FF) would misread as data. The fix removed loop compression entirely:_compress_macro(:926-960) now emits only$FF, so no$FEever reaches a macro stream. This dimension's$FEconcern is the sequence-level bank-jump command (now documented, above), which lives in a separate stream. No live$FEgap remains on either. - Channel order and the
$FF(end-of-stream) terminator per channel match the song- header pointer order in spec §2.1 (pulse1, pulse2, triangle, noise, dpcm).
Dimension 6: Macro Emission
Per docs/MACRO_USAGE_GUIDE.md and docs/AUDIO_BYTECODE_SPEC.md §2.3, the four macro
kinds and the instrument-pointer table must be emitted correctly:
instrument_tablerows are.word macro_vol_{v}, macro_arp_{a}, macro_pitch_{p}, macro_duty_{d}(:1172) — order must be Vol, Arp, Pitch, Duty (spec §2.2). Verified: the instrument tuple is built as(vol_macros[v_seq], arp_macros[a_seq], pitch_macros[p_seq], duty_macros[d_seq])(:1108/:1163) and unpacked asv_id, a_id, p_id, d_id = inst(:1171) in the same order — no transposition today. Re-check this on any future refactor of the instrument tuple; a transposed pair points an instrument at the wrong macro kind = wrong timbre (HIGH).- Macro value domains (spec §2.3): Volume macros absolute 0–15; Arpeggio macros
semitone offsets; Pitch macros timer offsets; all terminated by
$FF(sustain)._compress_macro(:926-960) now performs sustain compression only — loop compression ($FE,<offset>) was removed (#163/NH-21), and §2.3 marks$FEreserved/not-implemented. The reserved-byte encoding from Dimension 4 (#77) still keeps data values out of$FE/$FF's way. Confirm the index-0macro_*_0 = ($FF,)null/sustain macro exists (the{(0xFF,): 0}dict seeds at:1032-1039, emitted at:1178-1179, spec §2.2macro_null). - Macro dedup: the
vol_macros/duty_macros/arp_macros/pitch_macrosdicts dedupe by tuple — verify identical shapes collapse to one def (the guide's stated ROM-saving property) and that a_compress_macroround-trip can't change the played values (lossy macro compression that changes volume/pitch = CRITICAL per the severity rubric). - Fixed (#163/NH-21, closed): NH-21 found that the shipped
EVAL_MACROnever decodes$FEinside a macro, so a loop-compressed macro would be misread as data. Rather than teach the engine to decode$FE, the fix removed loop compression from_compress_macro(:926-960) — every emitted macro is now sustain-encoded ($FF-terminated), which is a strict subset of what the engine decodes. This dimension only checks emission correctness against the spec's data-structure rules; don't re-report NH-21 as open.
Dimension 7: Cross-Exporter Consistency
For the same frames input, NSF (exporter/exporter_nsf.py) and FamiStudio
(exporter/exporter_famistudio.py) should describe the same song the CA65 path
produces. (The old FamiTracker-text path — exporter/exporter.py +
exporter/pattern_exporter.py — was deleted as dead + frame-space-buggy, #101; neither
file exists in the repo anymore.) Check:
- Verify fix (#81, closed):
NSFExporter.export()andexport_nsf()(exporter/exporter_nsf.py:73-80) now raiseNotImplementedErrorwith a message citing #81, instead of serializing channel data as JSON text embedded in the NSF binary. TheNSFHeader/NSFMacroPackerclasses remain as unused scaffolding for a future real implementation — confirm nothing calls them expecting working output, and that raising loudly (rather than writing a broken file) is preserved on any future change here. - Verify fix (#79, closed — see also Dimension 8): confirm no remaining call path
reaches the NSF exporter from the CLI;
main.py'sexportsubcommand only offers--format ca65today. - Channel-set agreement: CA65 macro path handles
pulse1/pulse2/triangle/noise/dpcm;exporter_famistudio.pyiterates the identical five-channel list (:150) — confirmed consistent, no channel silently dropped. - Verify fix (#82, closed):
midi_note_to_famistudio(exporter_famistudio.py:164- 170) now clampsoctave = max(0, min(7, (note // 12) - 1))(:168) into FamiStudio's valid 0–7 range (previously produced negative octaves for MIDI notes 0–11). The dpcm branch (:102-111) also now falls back tomax(0, event.get('note', 1) - 1)whenevent['sample_id']is absent, instead of raisingKeyError(the frames dict encodes DPCM triggers asnote = sample_id + 1, not asample_idkey). Cross-check againstCA65Exporter.midi_note_to_timer_value's valid range (24–119,exporter_ca65.py:46) — confirm a note in range for one exporter is still in range (post-clamp) for the other, not silently re-pitched to a different octave than the ROM plays. - This dimension overlaps
/audit-tech-debtDimension 1 (the exporters duplicating serialization). Report duplication there; report behavioral divergence here.
Dimension 8: Format-String / CLI-Choices Mismatch
Verify fix (#79, closed): main.py's export subcommand now declares
p_export.add_argument('--format', choices=['ca65'], default='ca65') (main.py:1143),
with nsf intentionally absent (comment at :1141 citing #79/#81) rather than
present-but-unreachable. run_export (:499) only branches on if args.format == "ca65": (:519) — the old if args.format == "nsftxt": dead branch (dispatching on a
string argparse never allowed) is gone (see comment at :514-516). Requesting
--format nsf now fails argparse validation up front with a clear CLI error instead of
silently no-op'ing. Check:
- No other dispatch site still assumes NSF export works.
run_config_validate(main.py:1357) printsf"NSF load address: 0x{config_manager.get('export.nsf. load_address'):04X}"(:1369) under--verbose— this reads adefault_config.yamlvalue with no live consumer (NSFExporteralways raisesNotImplementedError); confirm this is at worst cosmetic (LOW) and not advertised anywhere as a working feature. - If NSF export is ever reintroduced, re-verify the new dispatch string exactly matches
a value in
choices=[...]— this is precisely the class of bug #79 was.
Cross-Dimension Dedup
A single root cause can surface across dimensions (an out-of-range .byte is both a
byte-range bug (D4) and a spec-conformance bug (D5)). Report it once, in the most
actionable dimension, and cross-reference. Run the _audit-common.md dedup protocol
(gh issue list + scan docs/audits/) before filing each finding.
Output
Write to: docs/audits/AUDIT_EXPORTERS_<TODAY>.md (YYYY-MM-DD). Structure:
- Summary — counts per severity and per dimension; the 3 highest-impact findings (default-pipeline CA65 path first).
- Findings — base format from
.claude/commands/_audit-common.md+ theDimensionandSpec reffields above.
Then suggest:
/audit-publish docs/audits/AUDIT_EXPORTERS_<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.