NES Hardware Correctness Audit

Audit NES APU register writes for hardware correctness across Pulse, Triangle, Noise, DPCM channels, verifying code against documented hardware semantics.

Sby Skills Guide Bot
TestingAdvanced
608/22/2026
Claude Code
#nes#apu#hardware-audit#emulation#code-review

Recommended for


description: "Audit NES APU hardware correctness — channels, pitch tables, envelopes, ranges" argument-hint: "[--focus <dims>]"

NES Hardware Correctness Audit

Audit the boundary where Python numbers become APU register writes: the four tone channels (Pulse1, Pulse2, Triangle, Noise) plus DPCM must be driven per their real register semantics. A value that is wrong here is wrong on every ROM the compiler produces, so this subsystem carries HIGH/CRITICAL severity floors.

Shared protocol: .claude/commands/_audit-common.md — read NES Hardware Constraints and Key Reference Docs before starting; do not restate them here. Severity: .claude/commands/_audit-severity.md — apply the NES-hardware rows of the Special Rules table (out-of-range value = HIGH, Triangle volume/duty = HIGH, bad vectors / no APU init = CRITICAL).

Cite, do not assert. For every hardware claim, point at the section of the relevant docs/APU_*.md that backs the expected behavior — never assert NES semantics from memory. The docs under docs/ are the hardware-verified baseline; the code must match them, and where the code contradicts a doc the code is the suspect (unless the doc itself is rot, which is a separate LOW finding).

The hot files for this audit: nes/emulator_core.py, nes/pitch_table.py, nes/envelope_processor.py, nes/audio_engine.asm (the live bytecode playback engine), and the serializer exporter/exporter_ca65.py.

Note on recent history: this repo just closed ~100 issues in a bug-fixing sprint. Most hardware bugs previously tracked here (NH-01..NH-11, NH-15..NH-24) are now fixed — the bullets below describe the current (fixed) behavior and ask you to verify the fix is complete/holds under edge cases, rather than hunt for the original bug. A smaller set (NH-14, NH-25) is still open — keep hunting those at full strength. Don't assume either list is exhaustive; re-derive from the code.

Parameters (from $ARGUMENTS)

  • --focus <dims> — comma-separated dimension numbers (e.g. --focus 1,5,9). Default: all.

Extra Per-Finding Field

  • Hardware ref: docs/APU_*.md section backing the expected behavior (e.g. docs/APU_TRIANGLE_REFERENCE.md §1 Hardware Architecture). A finding with no hardware-doc citation is not done.

Dimensions

Dimension 1: Pulse1 / Pulse2 — duty, volume, timer, sweep

Pulse channels live at $4000–$4007 (exporter/exporter_ca65.py defines APU_PULSE1_CTRLAPU_PULSE2_TIMER_HI). Verify:

  • Duty is 2 bits in the control byte (bits 6–7). In nes/envelope_processor.py:get_envelope_control_byte the duty is masked (duty_cycle & 0x03) << 6 and the constant-volume flag 0x10 (bit 4) is set — confirm both, and that the 4-bit volume occupies bits 0–3 (volume & 0x0F).
  • The duty ID reaching get_envelope_control_byte is one of the four legal NES duties (0–3 → 12.5/25/50/75%). The old PULSE_DUTY_CYCLES 8-bit constant that contradicted the 2-bit field is confirmed removed (#108/NH-15 — grep -rn PULSE_DUTY_CYCLES across the repo returns nothing outside history). Verify no new duty producer reintroduces an out-of-0–3 value.
  • Timer write order / phase-reset click (docs/APU_PULSE_REFERENCE.md §3 "Critical Side Effects" / docs/NES_APU_REFERENCE.md §2.1): rewriting Timer High ($4003/$4007) restarts the pulse sequencer's phase regardless of the value written. This is fixed in the live bytecode enginenes/audio_engine.asm caches the last-written high byte per pulse channel (last_written_hi, .res 5) and only issues sta $4003/sta $4007 when the value changed (@p1_write_hi/ @p2_write_hi, #161/NH-18), forcing a rewrite at genuine note onset via the cmp-then-branch guard. The direct-export path (exporter/exporter_ca65.py's play_pulse1/play_pulse2 procs) never had this bug — its @sustain label is a bare rts that touches no registers when the note is unchanged. Verify the bytecode engine's guard holds across bank switches / instrument changes.
  • Sweep ($4001/$4005): confirmed disabled at both init sites — the standalone reset proc and the project-builder init_music routine (exporter/exporter_ca65.py, lda #$08 / sta $4001 / sta $4005). $08 = EPPP.NSSS with E (enable, bit 7) clear, which disables the unit per docs/APU_PULSE_REFERENCE.md §2 regardless of the stray negate bit — confirm this reading and that no other code path re-enables sweep afterward (a stale sweep left enabled silently bends pitch — HIGH per Special Rules).
  • Closed (NH-25, #167): required no further code change — already fixed by an earlier commit (cb2a8ac) that the GitHub issue was simply never closed against. get_envelope_control_byte (nes/envelope_processor.py:145-174) sets envelope_bits = 0x30 (:174) — both constant-volume (bit 4) and length-counter halt (bit 5) — so the direct-export play_pulse1/play_pulse2 "new note" path's control-byte write (sta $4000/sta $4004) always carries the halt bit per docs/APU_LENGTH_COUNTER_REFERENCE.md §5. tests/test_core.py:156-161 and tests/test_envelope.py:106 both pin 0x30 explicitly. Verify-the-fix: confirm no future envelope-byte change drops back to a bare 0x10 (constant-volume only) — that would silently let the hardware length counter cut off long sustained notes again, independent of continued frame writes, now that NH-20 (#160) lets real note durations flow through instead of a 4-frame cap.

Dimension 2: Triangle — the no-volume / no-duty invariant + linear counter

This is the highest-yield dimension. The Triangle channel ($4008–$400B) has no volume and no duty (docs/APU_TRIANGLE_REFERENCE.md §1; docs/NES_APU_REFERENCE.md §2.2). Verify, skeptically:

  • nes/emulator_core.py:process_all_tracks routes triangle through compile_channel_to_frames with default_duty=None (the 'pulse' in channel_name test). Confirm the non-pulse branch is taken — the emitted frame dict for triangle carries only pitch/volume/arpeggio/note, no control/duty key at all. Any path that writes a duty or 4-bit volume into a triangle register ($4008/$400B) is HIGH per the Special Rules table.
  • In exporter/exporter_ca65.py's export_direct_frames, the triangle control byte is derived independently from volume: 0x00 when silent, else the named TRIANGLE_CONTROL_ON constant (0x80 control/halt flag | 0x7F max reload = 0xFF) — this is a real linear-counter reload (docs/APU_TRIANGLE_REFERENCE.md §4), matching the bytecode engine's fixed $FF write in nes/audio_engine.asm. The old formula, 0x80 | (volume * 7), scaled the reload by loudness even though the control flag stayed set (re-arming the reload every frame, so it never gated the note) — inert in practice but an opaque latent trap: clearing bit 7 in a future edit would have silently turned it into a wrong note-length knob. Fixed in #364 (NH-HW-04); confirm no re-introduction of a loudness-derived reload and that no $30-style "duty + constant volume" constant leaks into the triangle path.
  • Note-off: nes/audio_engine.asm's @silence_tri writes $80 (halt bit set, zero reload — "Linear Counter Halt", per docs/APU_TRIANGLE_REFERENCE.md §5) and the direct-export @silence label writes $00 to $4008; the "new note" fallthrough (relevant to NH-14 below) also writes $00 at true rest frames. Confirm none of these paths writes a pulse-style volume into $4008.

Dimension 3: Noise — period table & mode flag

Noise is at $400C–$400F; frequency is a 4-bit index into a 16-entry table, mode is bit 7 of $400E (docs/APU_NOISE_REFERENCE.md §3–§4; docs/NES_APU_REFERENCE.md §2.3). NH-04 (#20) — the module/instance disagreement and the dropped period — is fixed; verify it holds:

  • get_noise_period in nes/pitch_table.py is now the single source of truth: it clamps the note to CHANNEL_RANGES["noise"] (24–60), scales to 0–15, and inverts (15 - scaled) so a higher MIDI note maps to a lower index → higher frequency (docs/APU_NOISE_REFERENCE.md §3). PitchProcessor._get_noise_period now delegates to this same function instead of carrying a divergent second implementation — confirm both call sites still agree.
  • nes/emulator_core.py:process_all_tracks's noise branch now computes a real period via self.midi_to_nes_pitch(e['note'], 'noise') (floored at 1, since 0 is the bytecode rest sentinel) and reads noise_mode from the event (e.get('noise_mode', 0) & 1) instead of hardcoding mode 0 — confirm the mode bit is still reachable end-to-end: dpcm_sampler/enhanced_drum_mapper.py's _noise_mode_for_note/METALLIC_NOISE_ROLES (#204/NH-29) is the live producer on the legacy front-end, deterministically for hi-hats/cowbell, not "rare".
  • #392 (NH-HW-2026-08-05-1) is CLOSED: the --arranger front-end used to have no equivalent producer at all — its noise frames go through a separate path (arranger/voice_allocator.py's _allocate_noisearranger/pipeline_integration.py's data.get('mode', 0), a distinct key from emulator_core.py's noise_mode above — not the same code path), and DrumMapping (arranger/gm_instruments.py) had no mode field, so every --arranger percussion hit rendered as long-mode noise regardless of GM role. DrumMapping.periodic now mirrors METALLIC_NOISE_ROLES for the same four GM roles (closed/pedal/open hi-hat, cowbell), threaded through _allocate_noise's return (now (period, velocity, mode)) into the noise frame dict's mode key. Verify-the-fix: a closed hi-hat (GM note 42) through --arranger must produce control & 0x40 set; a non-metallic role (e.g. GM note 49, crash cymbal) must not.
  • NH-19 (#162, noise decay) is fixed: process_all_tracks bakes a software volume ramp per hit, cut short by a re-trigger. NOISE_DECAY_FRAMES = 6 and the ramp formula (noise_strike_decay_volume: peak_volume * (span - offset) / span, floored at 1) now live in nes/envelope_processor.py rather than inline in emulator_core.py — extracted so the --arranger path's noise post-processing (FrameByFrameAllocator._apply_noise_strike_decay, arranger/voice_allocator.py) shares the exact same decay instead of drifting from it (#359/ARR-2026-07-19-1; see /audit-arranger Dimension 7 for that side). Verify both process_all_tracks and _apply_noise_strike_decay still import from nes/envelope_processor.py rather than either re-defining its own copy, that the ramp still reaches audible decay (not all frames rounding to the same value), and that a rapid re-trigger correctly truncates the previous hit's tail rather than overlapping it.

Dimension 4: DPCM / DMC — level handling

DMC is at $4010–$4013; direct level load is $4011 (7-bit, docs/APU_DMC_REFERENCE.md §2–§3). NH-05 (#24) — "level has a consumer but no producer, not 7-bit clamped" — is fixed by removing the dead path rather than wiring it up (#71/#72). Verify:

  • nes/emulator_core.py:process_all_tracks's dpcm branch emits volume: 15 as a boolean-ish trigger gate (consumed only to decide whether the sample fires that frame), not as a level to write to $4011 — there is no "DMC volume" register on real hardware, so this is correct as long as nothing downstream reinterprets it as a level.
  • nes/audio_engine.asm (bytecode path) writes $4011 only to reset the DMC DAC to 0 at init (audio_engine.asm:128), preventing the documented Triangle/Noise mixing-DC-offset quirk — confirm this stays the only live $4011 write on that path. #348/NH-HW-1 is CLOSED: the direct-export path's own init_music/reset no longer omits the DAC-zero — export_direct_frames's standalone reset proc (exporter/exporter_ca65.py:788, sta $4011 right after lda #$00 / sta $4015) and its non-standalone init_music (:946) both now zero $4011 before enabling channels, with a comment citing the same §5 mixing-quirk doc. The third init_music (non-standalone bytecode path, :1422) just jmps to audio_init, which already had the zero. Verify-the-fix: confirm all three init_music/reset code paths still zero $4011 before sta $4015 re-enables channels (ordering matters — enabling first could let one frame of stale DAC output through), and that a future direct-export refactor doesn't reintroduce a path that skips it. (nes/mmc3_init.asm — a second, never-assembled copy — was deleted as dead code, #203.)
  • The @cmd_dmc_level handler in nes/audio_engine.asm (reads a 7-bit level operand and writes it to $4011) still exists, but exporter/exporter_ca65.py never emits the CMD_DMC_LEVEL/$87 opcode that would trigger it (confirmed by tests/test_ca65_export.py::test_dmc_level_command_path_removed). This consumer is now dead code with no producer — flag as LOW (dead code) unless you find a resurrected producer.
  • Sample address/length alignment ($4012/$4013) and the $C000–$FFFF residency constraint (docs/APU_DMC_REFERENCE.md §4; docs/NES_APU_REFERENCE.md §2.4) — if the generated project can place samples outside that window, note it (cross-refs the mapper audit).

Dimension 5: Per-channel pitch-table correctness + 11-bit clamp

The pulse and triangle channels do not share a period table — for the same 11-bit period the pulse sounds one octave above the triangle (docs/APU_PITCH_TABLE_REFERENCE.md §1; docs/NES_APU_REFERENCE.md §2.2 "Triangle … one octave lower"). NH-02/NH-03 (#12/#16) are fixed; verify:

  • nes/pitch_table.py now builds both tables from one parameterized generate_note_table(divider)NES_NOTE_TABLE (divider 16, pulse) and NES_TRIANGLE_TABLE (divider 32, triangle) — and PitchProcessor.get_channel_pitch branches on channel_type == "triangle" to index self.triangle_table instead of the shared pulse table. Confirm both the frame-generation path (nes/emulator_core.py) and the exporter's own base-timer lookup (CA65Exporter.midi_note_to_timer_value, which branches on channel == 'triangle' to pick NES_TRIANGLE_TABLE) stay on the same table so the pitch and the base timer it's differenced against don't scale-mismatch (#16).
  • Every timer is clamped to 11-bit $0–$7FF and floored at 8 (not 0): generate_note_table does max(8, min(timer, 0x07FF)) — the floor-at-8 is deliberate, since t < 8 silences pulse/triangle (docs/APU_PULSE_REFERENCE.md §3/§7); apply_pitch_bend re-applies the same max(8, min(…, 0x07FF)) clamp after bending.
  • NH-16 (#158, sub-C1 notes) is fixed: CA65Exporter.midi_note_to_timer_value now clamps the note to 24–119 instead of returning a bare 0 for out-of-range notes, so the +127-clamped pitch-offset macro can no longer wrap the 11-bit timer. Verify the clamp bounds (24, 119) are still consistent with CHANNEL_RANGES elsewhere.
  • Open / re-verify: the skill previously flagged an EnvelopeProcessor. get_pitch_modification vibrato path adding to an already-clamped pitch with no re-clamp — that entire method and its dead-copy NESEmulatorCore host were removed (#37/#38/NH-10; see Cross-Dimension Dedup note below), so this specific described path no longer exists. The live additive-pitch site is now nes/audio_engine.asm's macro evaluator: EVAL_MACRO 4, macro_steps_pitch, ... produces temp_pitch/temp_pitch_hi (sign-extended), which is added via adc temp_pitch / adc temp_pitch_hi directly onto ntsc_period_low/_high (or the triangle table) before sta $4002/$4003 etc., with no re-clamp to $7FF afterward. A live nonzero producer of pitch_seq now exists: the CA65 macro serializer emits pitch_offset = _encode_macro_offset(pitch_val - base_timer) (exporter/exporter_ca65.py) for pulse notes near the top of the table (~96–108), where the frame pitch (clamped to note 108 by get_channel_pitch in nes/pitch_table.py) is differenced against a base timer whose note the serializer clamped to 95 — yielding a nonzero (negative) delta. A recent audit verified the runtime reconstruction (ntsc_period + temp_pitch before sta $4002/$4003) stays inside the 11-bit range for the highest producible note, so this is in-range (correct, not a bug) — but the add is still structurally identical to the 11-bit-overflow trap already fixed in the dead duplicate core, with no post-add re-clamp to $7FF. Re-verify the reconstruction stays ≤ $7FF, and flag HIGH if any producer ever widens the pitch delta past the 11-bit ceiling.
  • The t < 8 silence quirk (docs/APU_PULSE_REFERENCE.md §3 / docs/NES_APU_REFERENCE.md §2.1): timers under 8 silence the channel; confirmed floored at 8 (above). Flag if any new code path can still push a nonzero pitch below 8.

Dimension 6: Velocity → 4-bit volume mapping

APU volume is 4-bit (0–15) on pulse/noise (docs/APU_PULSE_REFERENCE.md §1; docs/APU_NOISE_REFERENCE.md §2); MIDI velocity is 0–127. NH-08 (#34, dead/contradictory pulse-volume expression) is fixednes/emulator_core.py:compile_channel_to_frames's pulse branch and non-pulse branch both now use a single clean expression, max(1, int(15 * math.pow(velocity / 127.0, 1.5))) (velocity 0 is filtered out earlier by the continue on note-off, so the old unreachable velocity == 0 ternary arm is gone). Verify:

  • Output stays clamped to 0..15 in all three computation sites: emulator_core.py's two branches and envelope_processor.py:get_envelope_control_byte's min(15, round((envelope_volume * midi_volume) / 15.0)) combination step (both factors are already ≤15, so the product/15 can't exceed 15, but confirm the round() can't tip it to 16 at the boundary).
  • The pow(velocity/127, 1.5) curve keeps non-zero velocities audible via max(1, …) — a curve that under/overshoots but stays in range is MEDIUM; emitting outside 0..15 is HIGH.

Dimension 7: Envelope / ADSR behavior

The engine bypasses the hardware envelope and drives constant volume per frame (docs/APU_ENVELOPE_REFERENCE.md §4 Constant Volume Output, §5 Engine Implementation). Closed as documented (NH-24, #166): the ADSR/effects/arpeggio plumbing is intentionally inert scaffolding (kept for a future GM-based producer), not a bug to fix — but the behavior below still holds, so verify it hasn't silently changed. Check in nes/envelope_processor.py and its only caller (nes/emulator_core.py):

  • compile_channel_to_frames calls get_envelope_control_byte(envelope_type, frame_offset, ..., default_duty, None, velocity) with the effects argument hardcoded to None — tremolo and duty_sequence are therefore unreachable from any real pipeline run (only tests exercise them directly). envelope_type defaults to event.get('envelope_type', 'default'), and grep -rn "envelope_type" --include=*.py . outside tests/ shows no producer (parser, track_mapper, or arranger) ever sets this key — every real note plays the flat "default" envelope (attack=0, decay=0, sustain=15, release=0). Confirm this is still true after any arranger/instrument work and flag as a real (if inert-for-now) missing-feature finding, not just dead code — the whole piano/pad/pluck/ percussion envelope catalog and the vibrato/duty-sequence effects table are unreachable production code.
  • The constant-volume flag (bit 4, 0x10) is still set unconditionally in get_envelope_control_byte — confirm this remains true (missing it would be HIGH, wrong output).
  • The percussion-envelope division `(frame_offset - attack_end) / (note_duration - 1
    • attack_end)inget_envelope_valuehas a real divide-by-zero shape for a 1-frame note, but since no producer ever selectsenvelope_type="percussion"` (per the point above) this path is currently unreachable in production — confirm that remains true, or it becomes a live crash risk the moment an envelope producer is wired up.
  • Cross-ref Dimension 1 (NH-25): the length-counter halt bit is a related "constant output, no hardware decay" concern but lives on the pulse control byte path, not here.

Dimension 8: 60Hz frame timing & frame counter init

Playback is one frame entry per 1/60s NMI tick; the frame counter $4017 must be initialized to disable the hardware sequencer interfering with the NMI engine (docs/APU_FRAME_COUNTER_REFERENCE.md §2–§3; docs/NES_APU_REFERENCE.md §3.2). Verify:

  • Both init sites (exporter/exporter_ca65.py's standalone reset proc and the project-builder init_music) write lda #$40 / sta $4017 before playback starts — $40 = %01000000, i.e. Mode bit (bit 7) clear = 4-step mode, Interrupt Inhibit (bit 6) set = frame IRQ disabled (docs/APU_FRAME_COUNTER_REFERENCE.md §2 Register Map, §3 Sequencer Modes). This is the correct value.
  • Fixed (NH-22, #164): init_music's comment on that line previously read ; Frame counter mode 1, disable frame IRQ, which was doc-rot — $40 is mode 0 (4-step), not mode 1 (5-step is $C0/$80). Both live init sites now read 4-step mode (mode 0) (exporter/exporter_ca65.py init_music and nes/audio_engine.asm's $4017 write). Confirm the comment still matches the byte and no new init path reintroduces the wrong "mode 1" description.
  • The frame model is one-entry-per-tick (compile_channel_to_frames iterates integer frames range(start_frame, end_frame)). Flag any float tempo→frame accumulation that drifts off the 60Hz grid over a song (HIGH; cross-refs the tempo audit, but the engine must consume integer frames).

Dimension 9: Register addresses & $4015 enable correctness

All APU writes must land in $4000–$4017; channel enables are $4015 (---D NT21), frame counter $4017 (docs/NES_APU_REFERENCE.md §3; docs/APU_LENGTH_COUNTER_REFERENCE.md for $4015 length-counter side effects). Verify in exporter/exporter_ca65.py:

  • The APU_* constants (APU_PULSE1_CTRL=0x4000APU_STATUS=0x4015) all fall in the window and map to the correct channel/function. Grep every sta $40xx in the emitted proc bodies and confirm none writes outside $4000–$4017 or to the wrong channel's register.
  • Both init sites enable channels via $4015 = $0F (Pulse1/Pulse2/Triangle/Noise) and leave DMC (bit 4) off until a sample actually triggers, at which point @write_dpcm (nes/audio_engine.asm) / play_dpcm (exporter/exporter_ca65.py) write $1F. Confirm every channel the song actually uses is covered by one of these two paths — a channel used but never enabled in $4015 is silent (HIGH).

Dimension 10: Value-range clamping across the board

A sweep for every numeric value that reaches a register, independent of the dimension that produces it. For each of {note, timer, volume, duty, noise index, dmc level}, confirm a clamp exists on the path from Python value to emitted byte:

  • timers → $0–$7FF floored at 8 (Dim 5), volumes/duty → 4-bit / 2-bit masks (Dim 1/6), noise index → 0–15 (Dim 3), dmc level → not applicable post-fix (Dim 4; the "level" is a trigger gate now, not a register value).
  • Live unclamped-add sites to re-verify (nes/audio_engine.asm, both structurally unguarded downstream of the table's own clamp): the pitch macro add (adc temp_pitch / adc temp_pitch_hi onto the period tables, no post-add clamp to $7FF) now receives live nonzero deltas — the CA65 serializer emits nonzero pitch_seq offsets for pulse notes ~96–108 (Dim 5), so re-verify the post-add period actually stays ≤ $7FF in practice (a recent audit found it in-range) and flag HIGH the moment a producer widens the delta past the 11-bit ceiling. The arpeggio add (clc; lda current_note, x; adc temp_arp; sta temp_note — an 8-bit add with no range check before temp_note indexes the 128-entry period tables via ldy temp_note) is still fed only the neutral zero offset (_encode_macro_offset(0), no arp producer, #166) — HIGH if it ever receives a live nonzero input without a guard being added first. Both match the overflow pattern already fixed once in the dead duplicate core (#38/NH-10).

Dimension 11: Jukebox engine paths (.ifdef JUKEBOX_BUILD)

New in #30/F-13 and only assembled when nes/project_builder.py defines JUKEBOX_BUILD for a song build ROM. These paths are invisible to every single-song test and every single-song ROM — the engine's non-jukebox bytes are byte-identical with the symbol undefined, which is the design goal but also means this whole surface gets no coverage from the ordinary pipeline. Audit it as new code, not as a verify-the-fix pass.

  • EVAL_MACRO's indirect instrument table (nes/audio_engine.asm:87-103). A single-song build reads the fixed instrument_table label; a jukebox build indirects through the zero-page pointer instrument_table_ptr (:37), which load_song_streams_indexed (:259-265) rewrites on every song change. Verify the pointer is loaded before the first lda (instrument_table_ptr), y can execute on a cold boot (audio_init_song, :291-301, calls the loader before falling into the shared init tail) and that no macro can be evaluated between an audio_advance_song and the pointer store — a stale pointer reads the previous song's instrument bytes as this song's, giving wrong duty/volume/arp on every channel (CRITICAL: silent song corruption, not an audible failure).
  • The song_table stride contract. load_song_streams_indexed (:259-286) reads song_table_ptr_lo / _hi / song_table_bank at song_index * 5 + channel; the producer is CA65Exporter.export_song_bank_bytecode. Verify the stride, the channel order (SEQUENCE_CHANNELS), and the song_count comparison in audio_advance_song (:310-318) match the emitted tables exactly — see /audit-exporters Dimension 9 for the producer side. Note song_instrument_ptr_* is indexed by song alone (no * 5).
  • Auto-advance trigger condition (nes/audio_engine.asm:740-760). The end-of-stream handler sets channel_ended, x and scans all 5 entries, advancing only when every channel has ended. This block re-fires every frame (the surrounding silence re-arm is deliberately idempotent, #159), so verify: the scan is genuinely idempotent; X is saved/restored around the inner scan (it is reused as the scan index); audio_advance_song clears channel_ended on its way out so the new song cannot instantly re-advance; and a song whose channels end on different frames advances exactly once, not once per trailing frame.
  • State reset on song change. audio_advance_song reloads stream pointers and clears per-channel playback state (current_len, frame_wait, …) so the new song does not inherit timing state from the previous song's last note. Verify every piece of per-channel state the engine carries is in that reset list — one missed variable produces a glitch only on song 2+, which no single-song test can catch. Cross-check against what audio_init's cold-boot path clears.
  • Wrap-around. Both auto-advance and the Start-button skip wrap past the last song back to song 0, so a 1-song bank wraps to itself. Confirm that is harmless (it re-inits the same song) rather than an infinite re-trigger inside one frame.
  • Start-button skip. The edge-triggered poll lives in main.asm, not this file (nes/project_builder.py:365-386), and calls audio_advance_song from inside the NMI. Verify it cannot interleave with audio_update's own channel writes in a way that leaves a half-updated APU register set (cross-refs /audit-mappers Dimension 2).

Cross-Dimension Dedup

One root cause (e.g. the shared triangle/pulse pitch table, now fixed) may surface under several dimensions (pitch-table correctness and the triangle invariant). Report it once, in the most actionable dimension, and cross-reference.

Historical note: nes/envelope_processor.py used to define a second, near-duplicate NESEmulatorCore (with a vibrato path that added pitch_mod to an already-clamped pitch, no re-clamp) plus the get_pitch_modification method that was its only caller. Both were removed in #37/#38 (NH-10) — nes/envelope_processor.py now contains only EnvelopeProcessor. nes/emulator_core.py's process_all_tracks remains the single live entry point per _audit-common.md; if you find any lingering reference to the old dead copy (docs, tests, comments), it's stale and should be flagged LOW (doc-rot), not re-litigated as a hardware bug.

Output

Write to: docs/audits/AUDIT_NES_HARDWARE_<TODAY>.md (YYYY-MM-DD). Structure:

  1. Summary — counts per severity, the highest-risk hardware divergences (anything that is wrong on every ROM).
  2. Findings — base format from _audit-common.md + Dimension + Hardware ref.

Then suggest:

/audit-publish docs/audits/AUDIT_NES_HARDWARE_<TODAY>.md
Related skills