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 qui guide Claude a travers le cycle TDD complet.
Audit d'Accessibilité Web
Testing
Réalise un audit d'accessibilité web complet selon les normes WCAG.
Générateur de Tests UAT
Testing
Génère des cas de test d'acceptation utilisateur structurés et complets.