Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,71 @@ cycle-accurate core later replaced.

## [Unreleased]

### Added

- **Audio provenance — point at a moment in the frame and read why it sounds
like that.** The APU counterpart of pixel provenance, and deliberately the
same shape: a per-register write attribution answering *what wrote this, and
from which instruction*, and a per-CPU-cycle mix trace answering *what were
the channels actually doing*. Surfaced at **Tools → Audio → Audio
Provenance**. Output-only, runtime-default-off, and not serialized, so the
deterministic audio contract is unaffected whether it is armed or not.

Every ingredient but one already shipped — the Audio Scope plots the
waveforms, the Audio Mixer sets the gains, `Apu::pulse1_out()` and its
siblings expose live channel outputs, and the Event Viewer already classifies
`$4000-$4017` writes as `EventKind::ApuWrite`. What existed nowhere is the
link from a sample back to the instruction that caused it: `EventRec` carries
`kind / scanline / dot / addr / value` — no PC, no CPU cycle — and is
scanline-oriented rather than sample-oriented. So the event log is the
interception *point* this reuses; it is not the record.

The trace is per **CPU cycle**, the cadence at which the mix is genuinely
computed, rather than per output sample. `blip` decimates to 44.1 kHz — about
one sample per 40.6 CPU cycles — and an output sample is a weighted sum of
transitions across the filter kernel, not a copy of one instant. Recording at
output rate would mean picking which of those ~40 mixes "is" the sample, which
the signal chain cannot answer; the panel reports the cycle window and says so
instead. `MIX_CAP` is sized from **Dendy** (35,464 cycles/frame), not the NTSC
figure that comes to mind first, and reports `truncated()` rather than
returning a short buffer that looks complete.

Register rows carry their **side-band effects**, because naming the right
instruction and then describing the wrong effect is its own failure: a write
to `$4003` does not merely set the period, it also loads the length counter,
resets the duty sequencer and restarts the envelope. Those annotations were
confirmed against this emulator's own implementation, not from memory.

**The trap this feature inherited was closed in the same change as the
feature, not after a bug report.** Pixel provenance shipped non-functional for
four releases because run-ahead's per-frame rollback cleared the store before
the frontend released the emulator lock, so the UI could never observe a
populated record — and a comment asserted the opposite, which is what stopped
anyone checking. Audio provenance rides the identical rollback, so
`take_audio_provenance` / `put_audio_provenance` carry the state around
`restore_quiet` in `RunAhead::finish` from the outset. Save-state loads and
netplay rollback still clear, unchanged: those are genuine timeline changes,
and run-ahead's is not. The regression test drives the real produce path at
`run_ahead = 1` — the default — and is mutation-checked. Both it and its
control are floored at 20,000 records rather than "non-empty", because the
APU's reset sequence alone produces eight, so a non-emptiness check would pass
on a run that emulated nothing.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Spec: `docs/audio-provenance.md`. `rustynes-apu` gains a `debug-hooks`
feature, forwarded from the core's.

### Changed

- **Audio provenance costs the shipped default nothing when it is not armed.**
The feature is compiled into every build (the frontend enables the core's
`debug-hooks` unconditionally), so "default-off" describes the runtime arm
rather than the code. Two separate mechanisms were found charging the APU
hot path while disarmed — the mix record was being built before the arm was
tested, and the recording body was being inlined into the hot mix path — and
both are fixed; the disarmed path measures at baseline. The full measurement
chronology, including a diagnosis that was made, measured and rejected, is in
`docs/performance.md` §v2.3.7 C2 rather than here.

### Fixed

- **VRC7 save states now carry the FM synthesizer, so rewind no longer garbles
Expand Down
10 changes: 10 additions & 0 deletions crates/rustynes-apu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ workspace = true
# would then quietly opt every consumer in to `std`, masking no_std regressions
# from cross-target CI gates that build with `--no-default-features`.
std = []
# v2.3.7 "Overtone" — audio provenance: per-register write attribution (which
# instruction wrote `$4000-$4017`) and a per-CPU-cycle trace of the channel
# outputs actually mixed. Mirrors `rustynes-ppu/debug-hooks`, and like it the
# feature is only half the gate: `rustynes-core` forwards it and the frontend
# enables it unconditionally, so **the thing that is default-off is the runtime
# ARM**, a lazily-allocated `Option` whose disarmed cost is one discriminant
# test per register write and per mixed cycle. Output-only: nothing here is read
# back into synthesis or carried in the save state, so the shipped default is
# byte-identical and the determinism contract is untouched.
debug-hooks = []

[dependencies]
bitflags.workspace = true
Expand Down
219 changes: 219 additions & 0 deletions crates/rustynes-apu/src/apu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,15 @@ pub struct Apu {
/// audio — the visualization samples a copy, exactly like the base-channel
/// `*_out()` DAC accessors already do.
pub(crate) last_external: f32,

/// v2.3.7 "Overtone" — audio provenance, behind ONE pointer.
///
/// `None` until armed via [`Apu::set_audio_provenance`]. Consolidated into a
/// single `Option<Box<..>>` after `apu_throughput` measured +9% on the
/// DISARMED path with this state spread across four inline fields — see
/// `crate::provenance::AudioProvenance`.
#[cfg(feature = "debug-hooks")]
pub(crate) audio_prov: Option<alloc::boxed::Box<crate::provenance::AudioProvenance>>,
}

/// All [`Apu::channel_mask`] bits set — every channel audible (the default and
Expand Down Expand Up @@ -383,9 +392,185 @@ impl Apu {
channel_mask: CHANNEL_MASK_ALL,
channel_gain: CHANNEL_GAIN_UNITY,
last_external: 0.0,
#[cfg(feature = "debug-hooks")]
audio_prov: None,
}
}

// -----------------------------------------------------------------
// v2.3.7 "Overtone" — audio provenance (output-only, off by default)
// -----------------------------------------------------------------

/// Record one mixed CPU cycle — the OUTLINED half.
///
/// Called from both mix paths so the fast default-configuration
/// specialization and the gated general path produce the same trace: a
/// provenance record that existed on only one of two byte-identical paths
/// would be a trap for whoever next changed the other.
///
/// # Why `#[cold]` and `#[inline(never)]` are load-bearing
///
/// This function is measurement-driven twice over, and the second lesson is
/// the less obvious one.
///
/// The FIRST version built the `MixRecord` before testing whether
/// provenance was armed, so a DISARMED build recomputed all five channel
/// outputs every CPU cycle — and `Pulse::output` is not free (it calls
/// `muted()`, which calls `sweep_target()`). `apu_throughput` measured
/// **+14% to +23%** in the feature-on/arm-off configuration the shipped
/// frontend runs. Hoisting the arm check to the top fixed that.
///
/// It was NOT enough. With the check first, a quiet-host A/B still measured
/// **+7.98% / +2.88% / +11.03%** on the three `apu_throughput` workloads
/// (order-bias control: +0.11% / +0.76% / +0.67%, so the deltas are real).
/// The absolute costs — +33 µs, +15 µs, +65 µs — are wildly non-uniform,
/// which a per-cycle branch cannot produce: a constant branch costs a
/// constant number of cycles. The cause was that this body was still being
/// INLINED into `tick_with_external`. The five `output()` calls sat in the
/// hot function even though the branch skipped over them, inflating it past
/// the point where the mixer and the channel ticks kept their registers and
/// their I-cache line.
///
/// So the hot path now contains exactly one null test, and everything else
/// lives out of line behind it. `#[cold]` additionally tells LLVM to lay
/// this block out away from the fall-through path. It pessimizes the ARMED
/// case, which is the correct trade: armed is an interactive debugging mode
/// and disarmed is what every user runs.
#[cfg(feature = "debug-hooks")]
#[cold]
#[inline(never)]
fn record_mix_armed(&mut self, mixed: f32, external: f32) {
let rec = crate::provenance::MixRecord {
mixed,
external,
pulse1: self.pulse1.output(),
pulse2: self.pulse2.output(),
triangle: self.triangle.output(),
noise: self.noise.output(),
dmc: self.dmc.output(),
};
if let Some(p) = self.audio_prov.as_mut() {
p.mix_trace.push(rec);
}
}

/// Arm or disarm audio provenance.
///
/// Arming allocates both stores; disarming frees them. Mirrors
/// `Ppu::set_pixel_provenance`, including that re-arming an already-armed
/// APU is a no-op rather than a silent wipe — the frontend re-asserts the
/// arm every frame (a lesson from the pixel panel, whose edge-triggered
/// mirror desynced permanently the moment a ROM load installed a fresh
/// core).
#[cfg(feature = "debug-hooks")]
pub fn set_audio_provenance(&mut self, enabled: bool) {
if enabled {
if self.audio_prov.is_none() {
self.audio_prov = Some(alloc::boxed::Box::new(
crate::provenance::AudioProvenance::new(),
));
}
} else {
self.audio_prov = None;
}
}

/// Whether audio provenance is armed.
#[cfg(feature = "debug-hooks")]
#[must_use]
pub const fn audio_provenance_armed(&self) -> bool {
self.audio_prov.is_some()
}

/// The per-register write attribution, or `None` when disarmed.
#[cfg(feature = "debug-hooks")]
#[must_use]
pub fn register_attribution(&self) -> Option<&crate::provenance::RegisterAttribution> {
self.audio_prov.as_ref().map(|p| &p.reg_attrib)
}

/// The per-CPU-cycle mix trace, or `None` when disarmed.
#[cfg(feature = "debug-hooks")]
#[must_use]
pub fn mix_trace(&self) -> Option<&crate::provenance::MixTrace> {
self.audio_prov.as_ref().map(|p| &p.mix_trace)
}

/// Begin a new frame's mix trace, anchored at `first_cycle`.
///
/// The register attribution is deliberately NOT cleared here: "which
/// instruction last wrote `$4003`" is a question whose answer legitimately
/// predates the current frame, and clearing it every frame would report a
/// register nobody has touched this frame as never written.
#[cfg(feature = "debug-hooks")]
pub fn begin_audio_provenance_frame(&mut self, first_cycle: u64) {
if let Some(p) = self.audio_prov.as_mut() {
p.mix_trace.clear(first_cycle);
}
}

/// Forget the register attribution history. Called on a cold boot, where
/// the history it describes genuinely ended.
#[cfg(feature = "debug-hooks")]
pub fn clear_audio_provenance_history(&mut self) {
if let Some(p) = self.audio_prov.as_mut() {
p.reg_attrib.clear();
}
}

/// Attribute a write in `$4000-$4017` that the bus does NOT route through
/// [`Self::write_register`].
///
/// Two addresses in the range are not APU registers and are handled
/// entirely on the bus: `$4014` (OAM DMA, which arms a burst) and `$4016`
/// (controller strobe, which is buffered to the next M2-low boundary).
/// `Bus::write` dispatches only `$4000-$4013 | $4015 | $4017` to
/// `write_register`, so the attribution recorded there can never see those
/// two — yet the table reserves slots for them, because the range is what
/// the bus already classifies as an APU write and punching a hole in it
/// would invite off-by-one arithmetic at every call site.
///
/// Without this entry point those two slots would stay permanently empty
/// while the docs claimed they were tracked. This records the cause exactly
/// as `write_register` would, and dispatches nothing — the emulation of both
/// addresses stays wherever the bus already implements it.
#[cfg(feature = "debug-hooks")]
pub const fn record_bus_handled_register_write(&mut self, addr: u16, value: u8) {
if let Some(p) = self.audio_prov.as_mut() {
p.reg_attrib
.record(addr, p.attrib_pc, p.attrib_cycle, value);
}
}

/// Push the writing instruction's PC + cycle down, mirroring the PPU's
/// write-attribution context. Called once per instruction by the core.
#[cfg(feature = "debug-hooks")]
pub const fn set_attrib_context(&mut self, pc: u16, cycle: u64) {
// No-op when disarmed: nothing reads these, so skipping the stores keeps
// the disarmed per-instruction cost at one null test.
if let Some(p) = self.audio_prov.as_mut() {
p.attrib_pc = pc;
p.attrib_cycle = cycle;
}
}

/// Lift both stores out for a same-timeline restore (run-ahead), leaving the
/// APU disarmed. See [`crate::provenance::AudioProvenanceStash`] for why
/// this exists at all.
#[cfg(feature = "debug-hooks")]
#[must_use]
pub fn take_audio_provenance(&mut self) -> crate::provenance::AudioProvenanceStash {
crate::provenance::AudioProvenanceStash {
state: self.audio_prov.take(),
}
}

/// Put back stores taken by [`Self::take_audio_provenance`].
#[cfg(feature = "debug-hooks")]
pub fn put_audio_provenance(&mut self, stash: crate::provenance::AudioProvenanceStash) {
self.audio_prov = stash.state;
}

/// Reset (warm). Per nesdev: most APU state is preserved across reset
/// except `$4015` is cleared (channels disabled, DMC silenced).
///
Expand All @@ -412,6 +597,15 @@ impl Apu {
self.reset_4017_value = last;
self.reset_4017_delay = 2;
self.write_register(0x4015, 0x00);
// v2.3.7 — that write went through the ordinary CPU path, which just
// attributed it to whatever instruction was last latched. No instruction
// caused it: this models the warm-reset silencing of the channels.
// Correct the origin so the panel reports hardware rather than naming an
// innocent PC. (Caught in review of the PR that added the feature.)
#[cfg(feature = "debug-hooks")]
if let Some(p) = self.audio_prov.as_mut() {
p.reg_attrib.record_reset(0x4015, p.attrib_cycle, 0x00);
}
self.pending_dmc_dma = false;
self.dmc_dma_is_load = false;
self.dmc_dma_short = false;
Expand Down Expand Up @@ -1086,6 +1280,10 @@ impl Apu {
self.noise.output(),
self.dmc.output(),
) + external;
#[cfg(feature = "debug-hooks")]
if self.audio_prov.is_some() {
self.record_mix_armed(mixed, external);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.blip.add_sample(mixed);
// Nothing follows the general path's `add_sample` but comments --
// the get/put flip moved to `dmc_tick_end` under M-2 -- so there is
Expand Down Expand Up @@ -1140,6 +1338,19 @@ impl Apu {
scale(3, gate(3, self.noise.output()), 15),
scale(4, gate(4, self.dmc.output()), 127),
) + if mask & (1 << 5) != 0 { ext } else { 0.0 };
#[cfg(feature = "debug-hooks")]
if self.audio_prov.is_some() {
// RAW `external`, not the gained `ext`, and not zero when the mask
// bit clears it. The five channel fields are already the raw
// pre-gate outputs, so recording a gain-scaled or mask-zeroed
// expansion value would make ONE field follow the user's mixer
// sliders while five describe the chip -- and would make this path
// disagree with the fast path, which records the raw value. Review
// caught the disagreement; this resolves it toward the documented
// semantic rather than toward the local variable that happened to
// be in scope.
self.record_mix_armed(mixed, external);
}
self.blip.add_sample(mixed);

// v2.0 interleaved-DMA Phase A: toggle the global get/put flip-flop once
Expand Down Expand Up @@ -1550,6 +1761,14 @@ impl Apu {

/// CPU register write (`$4000-$4017` excluding `$4014`).
pub fn write_register(&mut self, addr: u16, value: u8) {
// v2.3.7 "Overtone" — attribute the write BEFORE dispatching it, so the
// recorded value is what the CPU put on the bus rather than whatever a
// channel decided to keep. One `Option` test when disarmed.
#[cfg(feature = "debug-hooks")]
if let Some(p) = self.audio_prov.as_mut() {
p.reg_attrib
.record(addr, p.attrib_pc, p.attrib_cycle, value);
}
Comment thread
doublegate marked this conversation as resolved.
match addr {
0x4000 => self.pulse1.write_ctrl(value),
0x4001 => self.pulse1.write_sweep(value),
Expand Down
2 changes: 2 additions & 0 deletions crates/rustynes-apu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ mod length;
mod mixer;
mod noise;
mod opll;
#[cfg(feature = "debug-hooks")]
pub mod provenance;
mod pulse;
mod snapshot;
mod triangle;
Expand Down
Loading