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_*.mdsection 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_CTRL … APU_PULSE2_TIMER_HI). Verify:
- Duty is 2 bits in the control byte (bits 6–7). In
nes/envelope_processor.py:get_envelope_control_bytethe duty is masked(duty_cycle & 0x03) << 6and the constant-volume flag0x10(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_byteis one of the four legal NES duties (0–3 → 12.5/25/50/75%). The oldPULSE_DUTY_CYCLES8-bit constant that contradicted the 2-bit field is confirmed removed (#108/NH-15 —grep -rn PULSE_DUTY_CYCLESacross 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 engine —nes/audio_engine.asmcaches the last-written high byte per pulse channel (last_written_hi,.res 5) and only issuessta $4003/sta $4007when 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'splay_pulse1/play_pulse2procs) never had this bug — its@sustainlabel is a barertsthat 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 standaloneresetproc and the project-builderinit_musicroutine (exporter/exporter_ca65.py,lda #$08/sta $4001/sta $4005).$08=EPPP.NSSSwithE(enable, bit 7) clear, which disables the unit perdocs/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) setsenvelope_bits = 0x30(:174) — both constant-volume (bit 4) and length-counter halt (bit 5) — so the direct-exportplay_pulse1/play_pulse2"new note" path's control-byte write (sta $4000/sta $4004) always carries the halt bit perdocs/APU_LENGTH_COUNTER_REFERENCE.md§5.tests/test_core.py:156-161andtests/test_envelope.py:106both pin0x30explicitly. Verify-the-fix: confirm no future envelope-byte change drops back to a bare0x10(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_tracksroutestrianglethroughcompile_channel_to_frameswithdefault_duty=None(the'pulse' in channel_nametest). Confirm the non-pulse branch is taken — the emitted frame dict for triangle carries onlypitch/volume/arpeggio/note, nocontrol/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'sexport_direct_frames, the triangle control byte is derived independently fromvolume:0x00when silent, else the namedTRIANGLE_CONTROL_ONconstant (0x80control/halt flag| 0x7Fmax reload =0xFF) — this is a real linear-counter reload (docs/APU_TRIANGLE_REFERENCE.md§4), matching the bytecode engine's fixed$FFwrite innes/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_triwrites$80(halt bit set, zero reload — "Linear Counter Halt", perdocs/APU_TRIANGLE_REFERENCE.md§5) and the direct-export@silencelabel writes$00to$4008; the "new note" fallthrough (relevant to NH-14 below) also writes$00at 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_periodinnes/pitch_table.pyis now the single source of truth: it clamps the note toCHANNEL_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_periodnow 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'snoisebranch now computes a real period viaself.midi_to_nes_pitch(e['note'], 'noise')(floored at 1, since 0 is the bytecode rest sentinel) and readsnoise_modefrom 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
--arrangerfront-end used to have no equivalent producer at all — its noise frames go through a separate path (arranger/voice_allocator.py's_allocate_noise→arranger/pipeline_integration.py'sdata.get('mode', 0), a distinct key fromemulator_core.py'snoise_modeabove — not the same code path), andDrumMapping(arranger/gm_instruments.py) had no mode field, so every--arrangerpercussion hit rendered as long-mode noise regardless of GM role.DrumMapping.periodicnow mirrorsMETALLIC_NOISE_ROLESfor 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'smodekey. Verify-the-fix: a closed hi-hat (GM note 42) through--arrangermust producecontrol & 0x40set; a non-metallic role (e.g. GM note 49, crash cymbal) must not. - NH-19 (#162, noise decay) is fixed:
process_all_tracksbakes a software volume ramp per hit, cut short by a re-trigger.NOISE_DECAY_FRAMES = 6and the ramp formula (noise_strike_decay_volume:peak_volume * (span - offset) / span, floored at 1) now live innes/envelope_processor.pyrather than inline inemulator_core.py— extracted so the--arrangerpath'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-arrangerDimension 7 for that side). Verify bothprocess_all_tracksand_apply_noise_strike_decaystill import fromnes/envelope_processor.pyrather 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'sdpcmbranch emitsvolume: 15as 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$4011only 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$4011write on that path. #348/NH-HW-1 is CLOSED: the direct-export path's owninit_music/resetno longer omits the DAC-zero —export_direct_frames's standaloneresetproc (exporter/exporter_ca65.py:788,sta $4011right afterlda #$00 / sta $4015) and its non-standaloneinit_music(:946) both now zero$4011before enabling channels, with a comment citing the same §5 mixing-quirk doc. The thirdinit_music(non-standalone bytecode path,:1422) justjmps toaudio_init, which already had the zero. Verify-the-fix: confirm all threeinit_music/resetcode paths still zero$4011beforesta $4015re-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_levelhandler innes/audio_engine.asm(reads a 7-bit level operand and writes it to$4011) still exists, butexporter/exporter_ca65.pynever emits theCMD_DMC_LEVEL/$87opcode that would trigger it (confirmed bytests/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–$FFFFresidency 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.pynow builds both tables from one parameterizedgenerate_note_table(divider)—NES_NOTE_TABLE(divider 16, pulse) andNES_TRIANGLE_TABLE(divider 32, triangle) — andPitchProcessor.get_channel_pitchbranches onchannel_type == "triangle"to indexself.triangle_tableinstead 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 onchannel == 'triangle'to pickNES_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–$7FFand floored at 8 (not 0):generate_note_tabledoesmax(8, min(timer, 0x07FF))— the floor-at-8 is deliberate, sincet < 8silences pulse/triangle (docs/APU_PULSE_REFERENCE.md§3/§7);apply_pitch_bendre-applies the samemax(8, min(…, 0x07FF))clamp after bending. - NH-16 (#158, sub-C1 notes) is fixed:
CA65Exporter.midi_note_to_timer_valuenow clamps the note to24–119instead of returning a bare0for 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 withCHANNEL_RANGESelsewhere. - Open / re-verify: the skill previously flagged an
EnvelopeProcessor. get_pitch_modificationvibrato path adding to an already-clamped pitch with no re-clamp — that entire method and its dead-copyNESEmulatorCorehost 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 nownes/audio_engine.asm's macro evaluator:EVAL_MACRO 4, macro_steps_pitch, ...producestemp_pitch/temp_pitch_hi(sign-extended), which is added viaadc temp_pitch/adc temp_pitch_hidirectly ontontsc_period_low/_high(or the triangle table) beforesta $4002/$4003etc., with no re-clamp to$7FFafterward. A live nonzero producer ofpitch_seqnow exists: the CA65 macro serializer emitspitch_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 framepitch(clamped to note 108 byget_channel_pitchinnes/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_pitchbeforesta $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 < 8silence 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 fixed — nes/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..15in all three computation sites:emulator_core.py's two branches andenvelope_processor.py:get_envelope_control_byte'smin(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 theround()can't tip it to 16 at the boundary). - The
pow(velocity/127, 1.5)curve keeps non-zero velocities audible viamax(1, …)— a curve that under/overshoots but stays in range is MEDIUM; emitting outside0..15is 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_framescallsget_envelope_control_byte(envelope_type, frame_offset, ..., default_duty, None, velocity)with theeffectsargument hardcoded toNone— tremolo andduty_sequenceare therefore unreachable from any real pipeline run (only tests exercise them directly).envelope_typedefaults toevent.get('envelope_type', 'default'), andgrep -rn "envelope_type" --include=*.py .outsidetests/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 wholepiano/pad/pluck/percussionenvelope catalog and the vibrato/duty-sequence effects table are unreachable production code.- The constant-volume flag (bit 4,
0x10) is still set unconditionally inget_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.
- attack_end)
- 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 standaloneresetproc and the project-builderinit_music) writelda #$40/sta $4017before 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 —$40is mode 0 (4-step), not mode 1 (5-step is$C0/$80). Both live init sites now read4-step mode (mode 0)(exporter/exporter_ca65.pyinit_music andnes/audio_engine.asm's$4017write). 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_framesiterates integer framesrange(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=0x4000…APU_STATUS=0x4015) all fall in the window and map to the correct channel/function. Grep everysta $40xxin the emitted proc bodies and confirm none writes outside$4000–$4017or 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$4015is 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–$7FFfloored at8(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_hionto the period tables, no post-add clamp to$7FF) now receives live nonzero deltas — the CA65 serializer emits nonzeropitch_seqoffsets for pulse notes ~96–108 (Dim 5), so re-verify the post-add period actually stays ≤$7FFin 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 beforetemp_noteindexes the 128-entry period tables vialdy temp_note) is still fed only the neutral zero offset (_encode_macro_offset(0), noarpproducer, #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 fixedinstrument_tablelabel; a jukebox build indirects through the zero-page pointerinstrument_table_ptr(:37), whichload_song_streams_indexed(:259-265) rewrites on every song change. Verify the pointer is loaded before the firstlda (instrument_table_ptr), ycan 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 anaudio_advance_songand 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_tablestride contract.load_song_streams_indexed(:259-286) readssong_table_ptr_lo/_hi/song_table_bankatsong_index * 5 + channel; the producer isCA65Exporter.export_song_bank_bytecode. Verify the stride, the channel order (SEQUENCE_CHANNELS), and thesong_countcomparison inaudio_advance_song(:310-318) match the emitted tables exactly — see/audit-exportersDimension 9 for the producer side. Notesong_instrument_ptr_*is indexed by song alone (no* 5). - Auto-advance trigger condition (
nes/audio_engine.asm:740-760). The end-of-stream handler setschannel_ended, xand 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;Xis saved/restored around the inner scan (it is reused as the scan index);audio_advance_songclearschannel_endedon 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_songreloads 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 whataudio_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 callsaudio_advance_songfrom inside the NMI. Verify it cannot interleave withaudio_update's own channel writes in a way that leaves a half-updated APU register set (cross-refs/audit-mappersDimension 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:
- Summary — counts per severity, the highest-risk hardware divergences (anything that is wrong on every ROM).
- Findings — base format from
_audit-common.md+Dimension+Hardware ref.
Then suggest:
/audit-publish docs/audits/AUDIT_NES_HARDWARE_<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.