Exporters Audit

VerifiedSafe

Audit of CA65/NSF/FamiTracker assembly and bytecode correctness for exporter modules.

Sby Skills Guide Bot
TestingAdvanced
7/23/2026
Claude Code
#code-audit#ca65-assembly#nes-audio#bytecode-verification#static-analysis

Recommended for

Automated summary

This skill performs a structured audit of the output-format generators in `exporter/`, checking that the CA65 assembly, NSF bytecode and FamiStudio outputs match the 6502 engine specifications.

Strengths

  • Splits the audit into 8 explicit dimensions, each with a skeptical checklist and precise file:line references
  • Cross-checks emitted-format claims against target documents (AUDIO_BYTECODE_SPEC, MACRO_USAGE_GUIDE, linker config, MMC3 mapper reference)
  • Enforces a dedup protocol against the prior audit so already-closed issues are not re-reported
  • Requires a Spec ref field per finding and defers to a shared severity rubric, making results traceable
  • The --focus flag lets you restrict a run to specific dimensions

Security analysis

Safe
Quality score86/100

This is a read-and-analyze audit instruction file for a chiptune exporter (CA65/NSF) pipeline. It only directs the agent to read source files, cross-check labels/segments against linker configs and reference docs, and report findings in a structured format. There are no shell commands, network calls, secret handling, obfuscated payloads, or destructive operations, and no tools are pre-authorized beyond file inspection.

No concerns found

How this analysis is made

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.md section, 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 .export line (pulse1_sequence, pulse2_sequence, triangle_sequence, noise_sequence, dpcm_sequence, ntsc_period_low, ntsc_period_high, instrument_table, the dpcm_*_tables) has a matching definition; macro_vol_*/macro_arp_*/macro_pitch_*/macro_duty_* referenced from instrument_table .word rows (exporter_ca65.py:1172) all exist.
  • Segments emitted (CODE_8000, BANK_{NN}, DPCM, and in export_direct_frames HEADER/ZEROPAGE/BSS/RODATA/CODE/VECTORS) are all declared in the linker config nes/project_builder.py writes — a segment the exporter emits but nes.cfg has no MEMORY/SEGMENT for is a link failure. Cross-check docs/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 by nes/project_builder.py's main.asm. A mismatched zeropage symbol is a link failure.
  • The non-standalone branch (:1270-1279) emits .import audio_init, audio_update and 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 next BANK_{NN} segment in the same file (:1238) — verify it always is, including when MAX_SEQUENCE_BANK is reached (the code now raises ValueError instead 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). Note export_direct_frames builds triangle control as 0x80 | (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 per docs/APU_LENGTH_COUNTER_REFERENCE.md.
  • $4015 channel-enable and $4017 frame-counter init (:466/:848) in the standalone reset and the init_music block match docs/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_value is called with the channel argument on both the note-start path (:1115) and the continuation path (:1134, explicit regression comment citing #78) — omitting channel previously defaulted triangle to the pulse /16 table 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_data hand-assembled opcodes (with a BEQ/BNE offset bug and JSON-as-data) no longer exist in exporter/exporter_nsf.py — export()/export_nsf() (:73-80) now raise NotImplementedError immediately instead of emitting broken machine code. Confirm nothing still calls the deleted private methods, and that NSFHeader (: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.py can build — they emit different segments and different exported symbols (the direct path has no *_sequence/instrument_table; the macro path has no *_note/*_control tables). 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_patterns still ignores its references argument entirely — this is now explicitly documented in the method's own docstring (:963-972, citing #4) as intentional: the macro path re-derives events from frames, and pattern/reference compression is analysis/metrics only. Confirm the docstring's claim still matches behavior (grep references inside 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.nes run — 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_INSTRUMENT operand at :1251 can no longer widen past two hex digits. inst_id is now assigned by the static helper _register_instrument (:905-924, called at :1109/:1164), which raises ValueError when len(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. The loop_start half of the original finding is now moot: loop compression was removed (#163/NH-21), so _compress_macro (:926-960) only ever appends $FF and never emits a raw loop_start operand 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/0xFE can 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 = 0xFE at :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 produces dmc_level, and grepping exporter_ca65.py for $87/CMD_DMC_LEVEL now turns up nothing. Note the engine (nes/audio_engine.asm) still contains an unreachable @cmd_dmc_level handler for it (and an unreachable @cmd_dpcm_play for $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): note is now clamped on both ends before it's baked into the bytecode stream and fed back into midi_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 produced base_timer = 0 and 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 onto self.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) with write_dur = min(rem_dur, 32) (:1258). Spec §3 Length Commands are $60–$7F = length value-$60+1 (1–32 frames) — verify the cap of 32 and the -1 bias match, and that notes still fall in the $00–$5F note range so the engine doesn't read a note as a length/command.
  • Verify fix (#83, closed) — EXP-07: the doc/code gap on $FE is 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 $FF end-of-stream terminator. docs/AUDIO_BYTECODE_SPEC.md §3 now carries an explicit $FE CMD_BANK_JUMP row 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_PLAY and $87 CMD_DMC_LEVEL rows still exist in §3, but each now notes the Python exporter does not emit them (DPCM triggers ride as note bytes; the $87 emitter was removed, #72; see Dimension 4). Confirm the doc stays in sync rather than re-reporting the $FE gap.
  • Fixed (#163/NH-21, closed): NH-21 (nes-hardware audit) covered a macro-runtime $FE hazard — the old _compress_macro could emit loop-compressed macros ($FE, loop_start) that the shipped EVAL_MACRO routine (which only checks $FF) would misread as data. The fix removed loop compression entirely: _compress_macro (:926-960) now emits only $FF, so no $FE ever reaches a macro stream. This dimension's $FE concern is the sequence-level bank-jump command (now documented, above), which lives in a separate stream. No live $FE gap 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_table rows 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 as v_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 $FE reserved/not-implemented. The reserved-byte encoding from Dimension 4 (#77) still keeps data values out of $FE/$FF's way. Confirm the index-0 macro_*_0 = ($FF,) null/sustain macro exists (the {(0xFF,): 0} dict seeds at :1032-1039, emitted at :1178-1179, spec §2.2 macro_null).
  • Macro dedup: the vol_macros/duty_macros/arp_macros/pitch_macros dicts dedupe by tuple — verify identical shapes collapse to one def (the guide's stated ROM-saving property) and that a _compress_macro round-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_MACRO never decodes $FE inside 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() and export_nsf() (exporter/exporter_nsf.py:73-80) now raise NotImplementedError with a message citing #81, instead of serializing channel data as JSON text embedded in the NSF binary. The NSFHeader/NSFMacroPacker classes 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's export subcommand only offers --format ca65 today.
  • Channel-set agreement: CA65 macro path handles pulse1/pulse2/triangle/noise/dpcm; exporter_famistudio.py iterates 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 clamps octave = 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 to max(0, event.get('note', 1) - 1) when event['sample_id'] is absent, instead of raising KeyError (the frames dict encodes DPCM triggers as note = sample_id + 1, not a sample_id key). Cross-check against CA65Exporter.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-debt Dimension 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) prints f"NSF load address: 0x{config_manager.get('export.nsf. load_address'):04X}" (:1369) under --verbose — this reads a default_config.yaml value with no live consumer (NSFExporter always raises NotImplementedError); 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:

  1. Summary — counts per severity and per dimension; the 3 highest-impact findings (default-pipeline CA65 path first).
  2. Findings — base format from .claude/commands/_audit-common.md + the Dimension and Spec ref fields above.

Then suggest:

/audit-publish docs/audits/AUDIT_EXPORTERS_<TODAY>.md

From the file to a first result

The download contains exactly the content displayed on this page. This copy may differ from the latest version in the source repository.

View the source and supporting files

This download contains SKILL.md only. Any scripts, references, templates and dependencies are not included. Get the required files from the source, preserving their directory structure, before continuing.

1. Download the file

Download SKILL.md

From the root of a test project, save the file as .skill-downloads/exporters-audit-mrx3y0gl/SKILL.md, or use the command below. This folder is for inspecting the skill before installation.

Command for macOS/Linux or WSL: requires a POSIX shell and curl. On Windows without WSL, use the download button and create the folder manually.

mkdir -p '.skill-downloads/exporters-audit-mrx3y0gl' && curl --fail --show-error --location 'https://skills-guides.com/api/skills/exporters-audit-mrx3y0gl/download' --output '.skill-downloads/exporters-audit-mrx3y0gl/SKILL.md.part' && mv '.skill-downloads/exporters-audit-mrx3y0gl/SKILL.md.part' '.skill-downloads/exporters-audit-mrx3y0gl/SKILL.md'

2. Check prerequisites

Read SKILL.md before using it. If it references scripts or external services, inspect them and prepare the required access in your test environment.

No recognized prerequisites section in this file. This does not mean it is self-contained: libraries, tools and access requirements may be mentioned elsewhere in the content or source repository.

The platforms on this page are catalog indications. The paths below follow tool documentation; they do not prove this skill has been run in those tools. No minimum tool version is confirmed for this listing.

3. Load the skill

This file does not provide a usable name/description header for this native procedure. It remains available to download and inspect; use the manual method below.

Manual method or another tool: attach SKILL.md to a conversation, or open it in an agent that can read files. Include any required supporting resources. No universal folder is recognized by every tool.

4. Try a first task

Use a short, synthetic input: an API endpoint, a small document or an interface brief. In a new conversation, attach the file or give the agent its path, then adapt this request:

Read .skill-downloads/exporters-audit-mrx3y0gl/SKILL.md and apply its instructions to this example: [describe a test input]. Identify any missing files, dependencies or access. Produce the result requested by the skill without inventing data or running unreviewed scripts.

Expected result: output matching the skill instructions, or a precise list of missing prerequisites. A read confirmation alone does not validate a first use. If an external resource is unavailable, that path remains untested.

Related skills