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 that guides Claude through the complete TDD cycle.
Web Accessibility Audit
Testing
Performs a comprehensive web accessibility audit following WCAG standards.
UAT Test Case Generator
Testing
Generates structured and comprehensive user acceptance test cases.