Skip to main content

rustynes_apu/
snapshot.rs

1//! Save-state encoding / decoding for the [`Apu`].
2//!
3//! Some fields below serialize state of models derived from Mesen2 and
4//! TriCNES; those derivations are recorded in the `// Provenance:` headers of
5//! `apu.rs` and `frame_counter.rs` (v2.9.9, NC-17).
6//!
7//! Hand-rolled little-endian binary so the crate stays free of `serde` /
8//! `bincode`. The container that wraps this blob into a tagged section
9//! lives in `rustynes_core::save_state`.
10//!
11//! The blob covers the four wave channels, DMC, frame counter, mixer phase /
12//! filter state, blip buffer (drained on restore), cycle bookkeeping, the
13//! DMC-DMA scheduling bytes, the W3-Stage-4 master-clock DMA-engine state
14//! (get/put parity, the exclusion/need latches, the delayed-`$4015`
15//! DMC-status machinery) and the scheduled warm-reset `$4017` re-write.
16//!
17//! Since v2.9.8 (ADR 0042) [`Apu::restore`] reads the current version only,
18//! and every field is required. Until then it accepted versions 1-3,
19//! migrating the frame counter's IRQ fields, and read the DMC-DMA bytes and
20//! the Stage-4 tail as trailing-optional, synthesising best-effort defaults
21//! for blobs that ended early. Until v5 (v2.9.9) the blip's synthesis state
22//! was not preserved, so a restore restarted the resampler cold and the audio
23//! after a load differed from an unrestored run (libretro re-audit NL-12); v5
24//! carries it. The undrained output queue is still not carried: hosts drain
25//! it every frame, so it is empty wherever a snapshot is taken.
26
27use alloc::vec::Vec;
28use thiserror::Error;
29
30use crate::Region;
31use crate::apu::Apu;
32use crate::blip::BlipBuf;
33use crate::dmc::Dmc;
34use crate::envelope::Envelope;
35use crate::frame_counter::{FrameCounter, Mode as FcMode};
36use crate::length::LengthCounter;
37use crate::mixer::{FilterChain, OnePole};
38use crate::noise::Noise;
39use crate::pulse::Pulse;
40use crate::triangle::Triangle;
41
42/// Schema version for the APU snapshot blob.
43///
44/// - v1 (v0.9.0 .. v1.0.0-rc2): original schema with `FrameCounter`
45///   carrying a `pending_irq_clear: bool` consumed at the next tick.
46/// - v2 (Session-25, 2026-05-23): `FrameCounter` replaces the bool
47///   with a `irq_flag_clear_cycle: u64` lazy-clear schedule mirroring
48///   Mesen2's `_irqFlagClearClock`. Old v1 blobs restore by migrating
49///   the bool to a synthesized schedule (a pending clear becomes
50///   "schedule for `cpu_cycle + 1`", a fresh clear).
51/// - v3 (Session-26 Sprint 2 iter 5, 2026-05-23 onwards):
52///   `FrameCounter` adds `irq_line_active: bool` as a SEPARATE field
53///   from `irq_flag`. v2 blobs migrate by setting both fields to the
54///   v2 `irq_flag` value (the IRQ-line state coincided with $4015
55///   bit 6 visibility under the v2 conflated model). Per ADR-0003,
56///   the v2 -> v3 migration may show a 1-cycle transient where a
57///   reloaded inhibited state has the CPU IRQ line deasserted as the
58///   FC step re-establishes it — acceptable.
59/// - v4 (2026-07-22): appends the scheduled warm-reset `$4017` re-write
60///   (`reset_4017_delay` + `reset_4017_value`, 2 bytes). [`Apu::reset`] arms
61///   the countdown at 2 and `tick_with_external` decrements it once per CPU
62///   cycle, issuing `FrameCounter::write` when it hits zero (the v2.0.0
63///   beta.3 A4 cycle-accurate reset, calibrated against blargg
64///   `4017_timing`). Both fields were previously unserialized, so a snapshot
65///   taken inside that 2-cycle window restored `delay = 0` and dropped the
66///   re-write entirely — the restored frame counter then kept the sequencer
67///   phase the re-write was supposed to reset. This is the same class as the
68///   PPU's v5 / v6 / v8 tails (ADR 0030 / ADR 0034): live mid-frame state
69///   absent from the schema, invisible to any straight-`run_frame` test and
70///   reachable only through a snapshot/restore round trip. Surfaced by the
71///   standing schema audit
72///   (`crates/rustynes-test-harness/tests/snapshot_schema_audit.rs`) rather
73///   than by a user-visible symptom.
74///
75///
76/// Since v2.9.8 (ADR 0042) only v4 is read, with every field required: the
77/// earlier versions' migrations and the trailing-optional tails (the v1.x
78/// DMC-DMA scheduling bytes and the W3-Stage-4 block) are gone. The v4 layout
79/// itself is unchanged, so the number did not move.
80///
81/// v5 (v2.9.9, NL-12): the blip resampler's synthesis state (ring head,
82/// warm-up flag, integrator, the 32 delta slots still in flight), so a
83/// save/load round trip at a frame boundary resumes the exact audio
84/// stream and serializes the same bytes as a run that never restored. v4 is
85/// refused (ADR 0042's current-version-only rule).
86pub const APU_SNAPSHOT_VERSION: u8 = 5;
87
88/// Errors returned by [`Apu::restore`].
89#[derive(Debug, Error)]
90#[non_exhaustive]
91pub enum ApuSnapshotError {
92    /// Blob is shorter than the schema declares.
93    #[error("APU snapshot truncated at offset {0}")]
94    Truncated(usize),
95    /// Blob is longer than the schema declares: this many bytes follow the
96    /// last field (v2.9.8; until then reported as [`Self::Truncated`]).
97    #[error("APU snapshot has {0} trailing byte(s) after its last field")]
98    TrailingBytes(usize),
99    /// The blob's version byte is not understood by this build.
100    #[error("APU snapshot unsupported version {0}")]
101    UnsupportedVersion(u8),
102    /// Region tag was not 0/1/2.
103    #[error("APU snapshot has invalid region tag {0}")]
104    InvalidRegion(u8),
105    /// Frame-counter mode tag was not 0/1.
106    #[error("APU snapshot has invalid frame-counter mode tag {0}")]
107    InvalidMode(u8),
108    /// Optional sample-buffer presence byte was not 0/1.
109    #[error("APU snapshot has invalid optional presence byte {0}")]
110    InvalidPresence(u8),
111    /// A register-width field held a value its hardware register cannot.
112    ///
113    /// Several of these index fixed tables on the next tick (`duty` and `step`
114    /// into the duty table, the triangle `step` into its 32-step sequence) or
115    /// flow into one (`decay`, a constant-volume `volume_or_period` and the DMC
116    /// `dac` sum into the mixer's 31- and 203-entry lookup tables), so an
117    /// unchecked value restores cleanly and then panics one CPU cycle later.
118    /// The rest (sweep, DMC rate / bit count) cannot panic today but are
119    /// bounded to the same register width so that no field of the restored
120    /// state is one the emulator itself could never have written. Core audit
121    /// IMP-02.
122    #[error("APU snapshot field `{field}` is {value}, above its maximum {max}")]
123    FieldOutOfRange {
124        /// Which field, as `channel.field`.
125        field: &'static str,
126        /// The value found in the blob.
127        value: u8,
128        /// The largest value the hardware register can hold.
129        max: u8,
130    },
131    /// A floating-point resampler or filter field was not usable.
132    ///
133    /// The band-limited resampler advances `phase` by `sample_rate / cpu_rate`
134    /// per CPU cycle and emits one host sample per whole unit crossed, so a
135    /// zero, negative, non-finite or merely huge ratio does not panic: it hangs
136    /// the emulation thread in that loop, or fills the host audio with NaN.
137    /// Core audit IMP-02.
138    #[error("APU snapshot resampler field `{0}` is out of range or not finite")]
139    InvalidResampler(&'static str),
140}
141
142/// Read one `u8` field and reject it if it exceeds `max` (IMP-02).
143fn bounded(r: &mut R<'_>, field: &'static str, max: u8) -> Result<u8, ApuSnapshotError> {
144    let value = r.u8()?;
145    if value > max {
146        return Err(ApuSnapshotError::FieldOutOfRange { field, value, max });
147    }
148    Ok(value)
149}
150
151/// Reject a non-finite float (IMP-02). A NaN in any filter or resampler field
152/// propagates into every later host sample; an infinity does the same after
153/// one subtraction.
154fn finite_f32(v: f32, field: &'static str) -> Result<f32, ApuSnapshotError> {
155    if v.is_finite() {
156        Ok(v)
157    } else {
158        Err(ApuSnapshotError::InvalidResampler(field))
159    }
160}
161
162// Bounds on the resampler's restored signal state (NC-09 and NL-12, v2.9.9
163// re-audits). `finite` alone was not enough: a finite value near `f32::MAX`
164// overflows to infinity within a few samples, the filter state then stays
165// non-finite, host audio is NaN for the rest of the session, and every
166// snapshot the machine takes afterwards is refused by this same validator,
167// which also breaks `Nes::restore`'s rollback (it restores the machine's own
168// snapshot). `1.0e38` decays; `-2.05e38` and `f32::MAX` do not.
169//
170// Each bound is chosen so that a state inside it can only produce states
171// inside it, which is what keeps the machine's own snapshot loadable:
172//
173// - `held_value` is the last input `add_sample` took, which it clamps to
174//   `-4.0..=4.0`.
175// - The integrator tracks the input: once every delta in flight has been
176//   integrated it equals `held_value`, so `integrator + sum(window) -
177//   held_value` is a constant of the motion (zero, up to float rounding,
178//   for a state this emulator produced). Requiring it under
179//   `RESAMPLER_DRIFT_MAX`, and every partial sum under
180//   `RESAMPLER_INTEGRATOR_MAX`, keeps every future integrator value near the
181//   clamped input.
182// - The filters are one-poles in a chain, `hp1 -> hp2 -> lp`, fed by that
183//   integrator. Each is bounded by a rule over its PAIR of state values,
184//   because separate caps on `prev_in` and `prev_out` are not closed: the
185//   high-pass update is `y' = c * (y + x' - x)`, so `x = -1024, y = 1024`
186//   passed two caps of 1024 and stepped to about `c * 2048` (#583 review,
187//   CodeRabbit; the cap of 1024 itself, and its `8a / (1 - a)` argument,
188//   also failed for the 10 Hz `Clean` stage, where that figure is ~5,700).
189//
190//   For a high-pass whose input stays within `X`, `y - c * x` is the
191//   quantity that is closed: `y' - c * x' = c * (y - x)
192//   = c * ((y - c * x) - (1 - c) * x)`, so `|y - c*x| <= c*X + S` gives
193//   `|y' - c*x'| <= c*(c*X + S) + c*(1 - c)*X <= c*X + S`, for ANY
194//   coefficient in `[0, 1]`, the slack `S` included. It follows that
195//   `|y| <= 2*X + S`, which bounds the next stage's input. A low-pass
196//   (`y' = y + c * (x' - y)`) is a convex step towards its input, so
197//   `|y| <= X + S` with `|x| <= X` is closed directly.
198//
199//   The input bounds chain from the integrator's: hp1 sees at most
200//   `RESAMPLER_INTEGRATOR_MAX`, hp2 at most hp1's `2*X + S`, the low-pass at
201//   most hp2's. `S` absorbs float rounding, which the contraction `(1 - c)*S`
202//   outpaces for every cutoff and host rate this emulator configures (1 Hz
203//   at 44.1 kHz gives ~1.4e-4 a sample against rounding near 1e-5). Real
204//   states sit far inside these bounds: the integrator stays near the
205//   clamped mixer level, under about 2.
206const RESAMPLER_HELD_MAX: f32 = 4.0;
207const RESAMPLER_INTEGRATOR_MAX: f32 = 16.0;
208const RESAMPLER_DRIFT_MAX: f32 = 1.0;
209const FILTER_SLACK: f32 = 1.0;
210/// The bound on each chain stage's input, in order `hp1`, `hp2`, `lp`.
211const FILTER_INPUT_MAX: [f32; 3] = [
212    RESAMPLER_INTEGRATOR_MAX,
213    2.0 * RESAMPLER_INTEGRATOR_MAX + FILTER_SLACK,
214    2.0 * (2.0 * RESAMPLER_INTEGRATOR_MAX + FILTER_SLACK) + FILTER_SLACK,
215];
216
217fn bounded_f32(v: f32, max: f32, field: &'static str) -> Result<f32, ApuSnapshotError> {
218    let v = finite_f32(v, field)?;
219    if v.abs() <= max {
220        Ok(v)
221    } else {
222        Err(ApuSnapshotError::InvalidResampler(field))
223    }
224}
225
226fn region_to_u8(r: Region) -> u8 {
227    match r {
228        Region::Ntsc => 0,
229        Region::Pal => 1,
230        Region::Dendy => 2,
231    }
232}
233fn region_from_u8(v: u8) -> Result<Region, ApuSnapshotError> {
234    match v {
235        0 => Ok(Region::Ntsc),
236        1 => Ok(Region::Pal),
237        2 => Ok(Region::Dendy),
238        other => Err(ApuSnapshotError::InvalidRegion(other)),
239    }
240}
241fn mode_to_u8(m: FcMode) -> u8 {
242    match m {
243        FcMode::FourStep => 0,
244        FcMode::FiveStep => 1,
245    }
246}
247fn mode_from_u8(v: u8) -> Result<FcMode, ApuSnapshotError> {
248    match v {
249        0 => Ok(FcMode::FourStep),
250        1 => Ok(FcMode::FiveStep),
251        other => Err(ApuSnapshotError::InvalidMode(other)),
252    }
253}
254
255struct W {
256    buf: Vec<u8>,
257}
258impl W {
259    fn u8(&mut self, v: u8) {
260        self.buf.push(v);
261    }
262    fn u16(&mut self, v: u16) {
263        self.buf.extend_from_slice(&v.to_le_bytes());
264    }
265    fn u32(&mut self, v: u32) {
266        self.buf.extend_from_slice(&v.to_le_bytes());
267    }
268    fn u64(&mut self, v: u64) {
269        self.buf.extend_from_slice(&v.to_le_bytes());
270    }
271    fn f32(&mut self, v: f32) {
272        self.buf.extend_from_slice(&v.to_le_bytes());
273    }
274    fn f64(&mut self, v: f64) {
275        self.buf.extend_from_slice(&v.to_le_bytes());
276    }
277    fn bool(&mut self, v: bool) {
278        self.buf.push(u8::from(v));
279    }
280}
281
282struct R<'a> {
283    src: &'a [u8],
284    pos: usize,
285}
286impl R<'_> {
287    fn need(&self, n: usize) -> Result<(), ApuSnapshotError> {
288        if self.src.len() - self.pos < n {
289            return Err(ApuSnapshotError::Truncated(self.pos));
290        }
291        Ok(())
292    }
293    fn u8(&mut self) -> Result<u8, ApuSnapshotError> {
294        self.need(1)?;
295        let v = self.src[self.pos];
296        self.pos += 1;
297        Ok(v)
298    }
299    fn u16(&mut self) -> Result<u16, ApuSnapshotError> {
300        self.need(2)?;
301        let v = u16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
302        self.pos += 2;
303        Ok(v)
304    }
305    fn u32(&mut self) -> Result<u32, ApuSnapshotError> {
306        self.need(4)?;
307        let mut a = [0u8; 4];
308        a.copy_from_slice(&self.src[self.pos..self.pos + 4]);
309        self.pos += 4;
310        Ok(u32::from_le_bytes(a))
311    }
312    fn u64(&mut self) -> Result<u64, ApuSnapshotError> {
313        self.need(8)?;
314        let mut a = [0u8; 8];
315        a.copy_from_slice(&self.src[self.pos..self.pos + 8]);
316        self.pos += 8;
317        Ok(u64::from_le_bytes(a))
318    }
319    fn f32(&mut self) -> Result<f32, ApuSnapshotError> {
320        self.need(4)?;
321        let mut a = [0u8; 4];
322        a.copy_from_slice(&self.src[self.pos..self.pos + 4]);
323        self.pos += 4;
324        Ok(f32::from_le_bytes(a))
325    }
326    fn f64(&mut self) -> Result<f64, ApuSnapshotError> {
327        self.need(8)?;
328        let mut a = [0u8; 8];
329        a.copy_from_slice(&self.src[self.pos..self.pos + 8]);
330        self.pos += 8;
331        Ok(f64::from_le_bytes(a))
332    }
333    fn bool(&mut self) -> Result<bool, ApuSnapshotError> {
334        Ok(self.u8()? != 0)
335    }
336}
337
338fn write_envelope(w: &mut W, e: Envelope) {
339    w.bool(e.start);
340    w.bool(e.loop_flag);
341    w.bool(e.constant);
342    w.u8(e.volume_or_period);
343    w.u8(e.divider);
344    w.u8(e.decay);
345}
346fn read_envelope(r: &mut R<'_>) -> Result<Envelope, ApuSnapshotError> {
347    Ok(Envelope {
348        start: r.bool()?,
349        loop_flag: r.bool()?,
350        constant: r.bool()?,
351        // All three are 4-bit: `$4000`/`$400C` bits 0-3, a divider reloaded
352        // from that period, and a counter that runs 15 -> 0.
353        volume_or_period: bounded(r, "envelope.volume_or_period", 15)?,
354        divider: bounded(r, "envelope.divider", 15)?,
355        decay: bounded(r, "envelope.decay", 15)?,
356    })
357}
358
359fn write_length(w: &mut W, l: LengthCounter) {
360    w.u8(l.count);
361    w.bool(l.halt);
362    w.bool(l.enabled);
363}
364fn read_length(r: &mut R<'_>) -> Result<LengthCounter, ApuSnapshotError> {
365    let count = r.u8()?;
366    let halt = r.bool()?;
367    let enabled = r.bool()?;
368    // The deferred-write scratch fields (`new_halt` / `reload_val` /
369    // `previous_count`) are NOT serialized: they resolve within the same CPU
370    // cycle as the register write that sets them (`LengthCounter::reload` runs
371    // every cycle), so no live deferral survives to a save-state taken at an
372    // instruction boundary. The snapshot byte layout is therefore unchanged
373    // (count + halt + enabled). `new_halt` MUST be seeded to the restored
374    // `halt`, otherwise the first post-restore `reload` would promote a stale
375    // `false` and spuriously clear a genuinely-halted counter.
376    Ok(LengthCounter {
377        count,
378        halt,
379        new_halt: halt,
380        enabled,
381        reload_val: 0,
382        previous_count: 0,
383    })
384}
385
386fn write_pulse(w: &mut W, p: &Pulse) {
387    w.u8(p.duty);
388    w.u8(p.step);
389    w.u16(p.timer_period);
390    w.u16(p.timer);
391    write_envelope(w, p.envelope);
392    write_length(w, p.length);
393    w.bool(p.sweep_enabled);
394    w.u8(p.sweep_period);
395    w.bool(p.sweep_negate);
396    w.u8(p.sweep_shift);
397    w.bool(p.sweep_reload);
398    w.u8(p.sweep_divider);
399    w.bool(p.is_pulse1);
400}
401fn read_pulse(r: &mut R<'_>) -> Result<Pulse, ApuSnapshotError> {
402    // `duty` and `step` index `DUTY_TABLE: [[u8; 8]; 4]`; the three sweep
403    // fields are the 3-bit fields of `$4001`/`$4005` (a shift of 16 or more
404    // would also overflow the `u16` shift in `sweep_target`).
405    let duty = bounded(r, "pulse.duty", 3)?;
406    let step = bounded(r, "pulse.step", 7)?;
407    let timer_period = r.u16()?;
408    let timer = r.u16()?;
409    let envelope = read_envelope(r)?;
410    let length = read_length(r)?;
411    let sweep_enabled = r.bool()?;
412    let sweep_period = bounded(r, "pulse.sweep_period", 7)?;
413    let sweep_negate = r.bool()?;
414    let sweep_shift = bounded(r, "pulse.sweep_shift", 7)?;
415    let sweep_reload = r.bool()?;
416    let sweep_divider = bounded(r, "pulse.sweep_divider", 7)?;
417    let is_pulse1 = r.bool()?;
418    let mut p = Pulse::new(is_pulse1);
419    p.duty = duty;
420    p.step = step;
421    p.timer_period = timer_period;
422    p.timer = timer;
423    p.envelope = envelope;
424    p.length = length;
425    p.sweep_enabled = sweep_enabled;
426    p.sweep_period = sweep_period;
427    p.sweep_negate = sweep_negate;
428    p.sweep_shift = sweep_shift;
429    p.sweep_reload = sweep_reload;
430    p.sweep_divider = sweep_divider;
431    Ok(p)
432}
433
434fn write_triangle(w: &mut W, t: &Triangle) {
435    w.u16(t.timer_period);
436    w.u16(t.timer);
437    w.u8(t.step);
438    write_length(w, t.length);
439    w.u8(t.linear_reload_value);
440    w.u8(t.linear_counter);
441    w.bool(t.linear_control);
442    w.bool(t.linear_reload_flag);
443}
444fn read_triangle(r: &mut R<'_>) -> Result<Triangle, ApuSnapshotError> {
445    let mut t = Triangle::new();
446    t.timer_period = r.u16()?;
447    t.timer = r.u16()?;
448    // Indexes the 32-step `TRIANGLE_TABLE`.
449    t.step = bounded(r, "triangle.step", 31)?;
450    t.length = read_length(r)?;
451    t.linear_reload_value = r.u8()?;
452    t.linear_counter = r.u8()?;
453    t.linear_control = r.bool()?;
454    t.linear_reload_flag = r.bool()?;
455    Ok(t)
456}
457
458fn write_noise(w: &mut W, n: &Noise) {
459    w.u16(n.lfsr);
460    w.bool(n.mode);
461    w.u16(n.timer_period);
462    w.u16(n.timer);
463    write_envelope(w, n.envelope);
464    write_length(w, n.length);
465    w.u8(region_to_u8(n.region));
466}
467fn read_noise(r: &mut R<'_>) -> Result<Noise, ApuSnapshotError> {
468    let lfsr = r.u16()?;
469    let mode = r.bool()?;
470    let timer_period = r.u16()?;
471    let timer = r.u16()?;
472    let envelope = read_envelope(r)?;
473    let length = read_length(r)?;
474    let region = region_from_u8(r.u8()?)?;
475    let mut n = Noise::new(region);
476    n.lfsr = lfsr;
477    n.mode = mode;
478    n.timer_period = timer_period;
479    n.timer = timer;
480    n.envelope = envelope;
481    n.length = length;
482    Ok(n)
483}
484
485fn write_dmc(w: &mut W, d: &Dmc) {
486    w.bool(d.irq_enable);
487    w.bool(d.loop_flag);
488    w.u8(d.rate_index);
489    w.u16(d.sample_addr);
490    w.u16(d.sample_length);
491    w.u16(d.current_addr);
492    w.u16(d.bytes_remaining);
493    if let Some(b) = d.sample_buffer {
494        w.u8(1);
495        w.u8(b);
496    } else {
497        w.u8(0);
498        w.u8(0);
499    }
500    w.u8(d.shift_register);
501    w.u8(d.bits_remaining);
502    w.u8(d.dac);
503    w.bool(d.silence);
504    w.u16(d.timer_period);
505    w.u16(d.timer);
506    w.bool(d.irq_flag);
507}
508fn read_dmc(r: &mut R<'_>, region: Region) -> Result<Dmc, ApuSnapshotError> {
509    let irq_enable = r.bool()?;
510    let loop_flag = r.bool()?;
511    // `$4010` bits 0-3.
512    let rate_index = bounded(r, "dmc.rate_index", 15)?;
513    let sample_addr = r.u16()?;
514    let sample_length = r.u16()?;
515    let current_addr = r.u16()?;
516    let bytes_remaining = r.u16()?;
517    let presence = r.u8()?;
518    let buf_byte = r.u8()?;
519    let sample_buffer = match presence {
520        0 => None,
521        1 => Some(buf_byte),
522        other => return Err(ApuSnapshotError::InvalidPresence(other)),
523    };
524    let shift_register = r.u8()?;
525    // The output unit counts 8 -> 0; the DAC is 7-bit and its value feeds the
526    // mixer's 203-entry `tnd_table`, which a DAC above 127 overruns.
527    let bits_remaining = bounded(r, "dmc.bits_remaining", 8)?;
528    let dac = bounded(r, "dmc.dac", 127)?;
529    let silence = r.bool()?;
530    let timer_period = r.u16()?;
531    let timer = r.u16()?;
532    let irq_flag = r.bool()?;
533    let mut d = Dmc::new(region);
534    d.irq_enable = irq_enable;
535    d.loop_flag = loop_flag;
536    d.rate_index = rate_index;
537    d.sample_addr = sample_addr;
538    d.sample_length = sample_length;
539    d.current_addr = current_addr;
540    d.bytes_remaining = bytes_remaining;
541    d.sample_buffer = sample_buffer;
542    d.shift_register = shift_register;
543    d.bits_remaining = bits_remaining;
544    d.dac = dac;
545    d.silence = silence;
546    d.timer_period = timer_period;
547    d.timer = timer;
548    d.irq_flag = irq_flag;
549    Ok(d)
550}
551
552fn write_fc(w: &mut W, fc: &FrameCounter) {
553    w.u8(mode_to_u8(fc.mode));
554    w.bool(fc.irq_inhibit);
555    w.bool(fc.irq_flag);
556    w.u32(fc.cycle);
557    w.u8(fc.reset_in);
558    w.u8(mode_to_u8(fc.pending_mode));
559    w.bool(fc.pending_inhibit);
560    w.bool(fc.apu_aligned);
561    // v2 (Session-25, 2026-05-23): lazy `$4015`-read clear schedule.
562    // 0 = no pending clear; otherwise the CPU cycle at which the
563    // clear matures. Replaces the v1 `pending_irq_clear: bool`.
564    w.u64(fc.irq_flag_clear_cycle);
565    // v3 (Session-26 iter 5, 2026-05-23): CPU IRQ line driver
566    // (`irq_line_active`) is now a separate field from `irq_flag`.
567    // Mesen2's `IRQSource::FrameCounter` registration on the CPU's
568    // `_irqSource` list, distinct from `_irqFlag` ($4015 bit 6
569    // visibility).
570    w.bool(fc.irq_line_active);
571}
572fn read_fc(r: &mut R<'_>) -> Result<FrameCounter, ApuSnapshotError> {
573    let mode = mode_from_u8(r.u8()?)?;
574    let irq_inhibit = r.bool()?;
575    let irq_flag = r.bool()?;
576    let cycle = r.u32()?;
577    let reset_in = r.u8()?;
578    let pending_mode = mode_from_u8(r.u8()?)?;
579    let pending_inhibit = r.bool()?;
580    let apu_aligned = r.bool()?;
581    let irq_flag_clear_cycle = r.u64()?;
582    let irq_line_active = r.bool()?;
583    let mut fc = FrameCounter::new();
584    fc.mode = mode;
585    fc.irq_inhibit = irq_inhibit;
586    fc.irq_flag = irq_flag;
587    fc.irq_line_active = irq_line_active;
588    fc.cycle = cycle;
589    fc.reset_in = reset_in;
590    fc.pending_mode = pending_mode;
591    fc.pending_inhibit = pending_inhibit;
592    fc.apu_aligned = apu_aligned;
593    fc.irq_flag_clear_cycle = irq_flag_clear_cycle;
594    Ok(fc)
595}
596
597fn write_onepole(w: &mut W, o: &OnePole) {
598    w.f32(o.coeff);
599    w.f32(o.prev_in);
600    w.f32(o.prev_out);
601    w.bool(o.is_hpf);
602}
603/// Read one filter stage at chain position `pos` (`0` = hp1, `1` = hp2,
604/// `2` = lp), refusing a stage of the wrong kind or a state outside the
605/// position's closed bound (see the `FILTER_INPUT_MAX` note).
606fn read_onepole(r: &mut R<'_>, pos: usize) -> Result<OnePole, ApuSnapshotError> {
607    let coeff = finite_f32(r.f32()?, "filter.coeff")?;
608    // Both constructors keep the coefficient in [0, 1]: `exp(-2*pi*fc/fs)` for
609    // the high-pass, `1 - exp(-2*pi*fc/fs)` for the low-pass. A finite value
610    // above 1 in the high-pass feeds `prev_out` back with gain > 1, which
611    // diverges to infinity and then NaN in the host audio (review finding on
612    // #546, CodeRabbit), so finiteness alone is not enough.
613    if !(0.0..=1.0).contains(&coeff) {
614        return Err(ApuSnapshotError::InvalidResampler("filter.coeff"));
615    }
616    let x_max = FILTER_INPUT_MAX[pos];
617    let prev_in = bounded_f32(r.f32()?, x_max, "filter.prev_in")?;
618    let prev_out = finite_f32(r.f32()?, "filter.prev_out")?;
619    let is_hpf = r.bool()?;
620    if is_hpf != (pos < 2) {
621        return Err(ApuSnapshotError::InvalidResampler("filter.kind"));
622    }
623    let excess = if is_hpf {
624        (prev_out - coeff * prev_in).abs() - coeff * x_max
625    } else {
626        prev_out.abs() - x_max
627    };
628    if excess > FILTER_SLACK {
629        return Err(ApuSnapshotError::InvalidResampler("filter.prev_out"));
630    }
631    // Reconstruct by overriding fields of a default-shape filter; we use
632    // either high_pass or low_pass to get the right shape, then patch the
633    // mutable state.
634    let mut o = if is_hpf {
635        OnePole::high_pass(0.0, 1.0)
636    } else {
637        OnePole::low_pass(0.0, 1.0)
638    };
639    o.coeff = coeff;
640    o.prev_in = prev_in;
641    o.prev_out = prev_out;
642    o.is_hpf = is_hpf;
643    Ok(o)
644}
645
646fn write_filter(w: &mut W, f: &FilterChain) {
647    write_onepole(w, &f.hp1);
648    write_onepole(w, &f.hp2);
649    write_onepole(w, &f.lp);
650}
651fn read_filter(r: &mut R<'_>) -> Result<FilterChain, ApuSnapshotError> {
652    let hp1 = read_onepole(r, 0)?;
653    let hp2 = read_onepole(r, 1)?;
654    let lp = read_onepole(r, 2)?;
655    Ok(FilterChain { hp1, hp2, lp })
656}
657
658fn write_blip(w: &mut W, b: &BlipBuf) {
659    w.u32(b.sample_rate);
660    w.f64(b.cpu_rate);
661    w.f64(b.phase);
662    write_filter(w, &b.filter);
663    w.f32(b.held_value);
664    // v5 (v2.9.9, NL-12): the band-limited synthesis state, so a restore
665    // resumes the exact stream. Until v5 a restore restarted the resampler
666    // cold: the frame after a load lost about 17 samples to warm-up and the
667    // integrator's level (a click), and the filter state then diverged from
668    // an unrestored run for good. Fixed size (135 bytes), so libretro's
669    // `retro_serialize_size`, read once at load, still covers every state.
670    //
671    // The undrained output queue is still NOT carried: it is host-rate
672    // output already produced, not machine state, and its length varies.
673    // Every host drains it at the end of each frame, which is where save
674    // states, run-ahead and rollback snapshot, so at those points it is
675    // empty and the round trip is exact.
676    let (head, primed, integrator, window) = b.live_state();
677    w.u16(head);
678    w.bool(primed);
679    w.f32(integrator);
680    for v in window {
681        w.f32(v);
682    }
683}
684fn read_blip(r: &mut R<'_>) -> Result<BlipBuf, ApuSnapshotError> {
685    let sample_rate = r.u32()?;
686    let cpu_rate = r.f64()?;
687    let phase = r.f64()?;
688    let filter = read_filter(r)?;
689    let held_value = bounded_f32(r.f32()?, RESAMPLER_HELD_MAX, "blip.held_value")?;
690    let head = r.u16()?;
691    let primed = r.bool()?;
692    let integrator = bounded_f32(r.f32()?, RESAMPLER_INTEGRATOR_MAX, "blip.integrator")?;
693    let mut window = [0.0f32; crate::blip_kernel::TAPS];
694    let mut partial = integrator;
695    for v in &mut window {
696        *v = finite_f32(r.f32()?, "blip.delta_window")?;
697        partial += *v;
698        if !partial.is_finite() || partial.abs() > RESAMPLER_INTEGRATOR_MAX {
699            return Err(ApuSnapshotError::InvalidResampler("blip.delta_window"));
700        }
701    }
702    if (partial - held_value).abs() > RESAMPLER_DRIFT_MAX {
703        return Err(ApuSnapshotError::InvalidResampler("blip.delta_window"));
704    }
705
706    if sample_rate == 0 {
707        return Err(ApuSnapshotError::InvalidResampler("blip.sample_rate"));
708    }
709    // `is_finite` rejects NaN and both infinities first, so the plain
710    // comparison that follows is total.
711    if !cpu_rate.is_finite() || cpu_rate <= 0.0 {
712        return Err(ApuSnapshotError::InvalidResampler("blip.cpu_rate"));
713    }
714    // The resampler emits one host sample per unit of phase crossed, and
715    // `add_sample` runs once per CPU cycle. A ratio above one output per
716    // CPU cycle is not a configuration the emulator produces (host rates are
717    // tens of kHz against a ~1.7 MHz CPU), and it is the knob that turns a
718    // finite corrupt value into an arbitrarily long `while phase >= 1.0` loop.
719    if f64::from(sample_rate) / cpu_rate > 1.0 {
720        return Err(ApuSnapshotError::InvalidResampler(
721            "blip.sample_rate/cpu_rate",
722        ));
723    }
724    if !(0.0..1.0).contains(&phase) {
725        return Err(ApuSnapshotError::InvalidResampler("blip.phase"));
726    }
727    let mut b = BlipBuf::new(sample_rate, cpu_rate);
728    b.phase = phase;
729    b.filter = filter;
730    b.held_value = held_value;
731    b.set_live_state(head, primed, integrator, &window);
732    Ok(b)
733}
734
735impl Apu {
736    /// Encode the APU's mutable state into a versioned binary blob.
737    #[must_use]
738    pub fn snapshot(&self) -> Vec<u8> {
739        let mut w = W {
740            buf: Vec::with_capacity(512),
741        };
742        w.u8(APU_SNAPSHOT_VERSION);
743        w.u8(region_to_u8(self.region));
744
745        write_pulse(&mut w, &self.pulse1);
746        write_pulse(&mut w, &self.pulse2);
747        write_triangle(&mut w, &self.triangle);
748        write_noise(&mut w, &self.noise);
749        write_dmc(&mut w, &self.dmc);
750        write_fc(&mut w, &self.frame_counter);
751        write_blip(&mut w, &self.blip);
752
753        w.bool(self.apu_phase);
754        w.u64(self.cpu_cycle);
755        w.bool(self.pending_dmc_dma);
756        w.u16(self.dmc_dma_addr);
757        w.u32(self.sample_rate);
758        w.u8(self.dmc_dma_delay);
759        w.bool(self.dmc_dma_is_load);
760        w.bool(self.pending_dmc_abort);
761        w.u8(self.dmc_abort_delay);
762        w.bool(self.dmc_dma_short);
763        w.bool(self.defer_dmc_reload_once);
764        w.u8(self.dmc_dma_cooldown);
765        w.u8(self.dmc_reload_suppress_outputs);
766
767        // === W3-Stage-4 (2026-06-10) trailing tail ===
768        // Serializes the master-clock DMA-engine state that the
769        // `mc-r1-full-cpu` umbrella promotion made load-bearing across an
770        // instruction boundary: the exact get/put parity, the TriCNES
771        // `CannotRunDMCDMARightNow` exclusion + its companion latches, the
772        // get/put-scheduler need flags, and the W3-Stage-3 delayed-`$4015`
773        // DMC-status machinery (pending slot + countdown + the implicit-abort
774        // trio + the `$540` consume-edge arm-suppress latch). The bytes are
775        // written UNCONDITIONALLY so the blob layout is identical across
776        // feature builds.
777        w.bool(self.put_cycle);
778        w.u64(self.parity_seed);
779        w.u8(self.cannot_run_dmc_dma);
780        w.bool(self.dmc_reenable_period_block);
781        w.u8(self.subpos_arm_countdown);
782        w.bool(self.dmc_need_halt);
783        w.bool(self.dmc_need_dummy_read);
784        w.bool(self.pending_dmc_dma_next);
785        {
786            w.u8(self.dmc_delayed_4015);
787            w.bool(self.dmc_delayed_status);
788            w.bool(self.dmc_status_applied);
789            w.bool(self.dmc_set_implicit_abort);
790            w.bool(self.dmc_implicit_abort);
791            w.bool(self.dmc_edge_arm_suppress);
792        }
793
794        // === v4 (2026-07-22) scheduled warm-reset `$4017` re-write ===
795        // Armed by `Apu::reset` (delay = 2, value = the frame counter's last
796        // `$4017`), consumed one CPU cycle at a time in `tick_with_external`.
797        // Live for only those 2 cycles, but a snapshot landing in them used to
798        // restore `delay = 0` and silently cancel the re-write.
799        w.u8(self.reset_4017_delay);
800        w.u8(self.reset_4017_value);
801
802        w.buf
803    }
804
805    /// Decode a previously [`Apu::snapshot`]ed blob.
806    ///
807    /// # Errors
808    ///
809    /// Returns [`ApuSnapshotError`] on a malformed blob.
810    pub fn restore(&mut self, data: &[u8]) -> Result<(), ApuSnapshotError> {
811        let mut r = R { src: data, pos: 0 };
812        let version = r.u8()?;
813        // Only the current version is read (v2.9.8, ADR 0042); see the
814        // module docs for what older versions used to migrate.
815        if version != APU_SNAPSHOT_VERSION {
816            return Err(ApuSnapshotError::UnsupportedVersion(version));
817        }
818        self.region = region_from_u8(r.u8()?)?;
819
820        self.pulse1 = read_pulse(&mut r)?;
821        self.pulse2 = read_pulse(&mut r)?;
822        self.triangle = read_triangle(&mut r)?;
823        self.noise = read_noise(&mut r)?;
824        self.dmc = read_dmc(&mut r, self.region)?;
825        self.frame_counter = read_fc(&mut r)?;
826        // v2.1.5: the frame counter's PAL step-position selector is derived
827        // from region, not persisted (the snapshot format is unchanged). Re-
828        // derive it here from the just-restored region so a restored PAL state
829        // keeps the PAL sequencer positions. `read_fc` returns a counter with
830        // `pal = false` (NTSC), which is correct for NTSC/Dendy.
831        self.frame_counter.pal = matches!(self.region, Region::Pal);
832        self.blip = read_blip(&mut r)?;
833
834        self.apu_phase = r.bool()?;
835        self.cpu_cycle = r.u64()?;
836        self.pending_dmc_dma = r.bool()?;
837        self.dmc_dma_addr = r.u16()?;
838        self.sample_rate = r.u32()?;
839        self.dmc_dma_delay = r.u8()?;
840        self.dmc_dma_is_load = r.bool()?;
841        self.pending_dmc_abort = r.bool()?;
842        self.dmc_abort_delay = r.u8()?;
843        self.dmc_dma_short = r.bool()?;
844        self.defer_dmc_reload_once = r.bool()?;
845        self.dmc_dma_cooldown = r.u8()?;
846        self.dmc_reload_suppress_outputs = r.u8()?;
847
848        // === W3-Stage-4 (2026-06-10) tail ===
849        // See the matching block in [`Apu::snapshot`].
850        self.put_cycle = r.bool()?;
851        self.parity_seed = r.u64()?;
852        self.cannot_run_dmc_dma = r.u8()?;
853        self.dmc_reenable_period_block = r.bool()?;
854        self.subpos_arm_countdown = r.u8()?;
855        self.dmc_need_halt = r.bool()?;
856        self.dmc_need_dummy_read = r.bool()?;
857        self.pending_dmc_dma_next = r.bool()?;
858        self.dmc_delayed_4015 = r.u8()?;
859        self.dmc_delayed_status = r.bool()?;
860        self.dmc_status_applied = r.bool()?;
861        self.dmc_set_implicit_abort = r.bool()?;
862        self.dmc_implicit_abort = r.bool()?;
863        self.dmc_edge_arm_suppress = r.bool()?;
864
865        // === v4 scheduled warm-reset `$4017` re-write ===
866        // See the matching block in [`Apu::snapshot`].
867        self.reset_4017_delay = r.u8()?;
868        self.reset_4017_value = r.u8()?;
869
870        // Every field is fixed-size, so the blob must end here.
871        if r.pos != data.len() {
872            return Err(ApuSnapshotError::TrailingBytes(data.len() - r.pos));
873        }
874        Ok(())
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use crate::blip::CPU_HZ_NTSC;
882
883    /// Locate a field's bytes in the blob by DIFFERENCE: snapshot two APUs that
884    /// differ only in that field, and the differing bytes are the field. This
885    /// keeps the IMP-02 tests independent of the schema's byte offsets, which
886    /// a hard-coded index would silently go stale against.
887    fn field_span(set_a: impl Fn(&mut Apu), set_b: impl Fn(&mut Apu)) -> (Vec<u8>, usize, usize) {
888        let mut a = Apu::new(Region::Ntsc, 44_100);
889        let mut b = Apu::new(Region::Ntsc, 44_100);
890        set_a(&mut a);
891        set_b(&mut b);
892        let (sa, sb) = (a.snapshot(), b.snapshot());
893        assert_eq!(sa.len(), sb.len());
894        let first = (0..sa.len())
895            .find(|&i| sa[i] != sb[i])
896            .expect("fields differ");
897        let last = (0..sa.len()).rfind(|&i| sa[i] != sb[i]).unwrap();
898        (sb, first, last + 1)
899    }
900
901    /// Corrupt one register-width `u8` field and assert a typed rejection
902    /// naming it, while its largest legal value still restores.
903    fn assert_u8_bounded(name: &'static str, max: u8, set: impl Fn(&mut Apu, u8)) {
904        let lo = max.saturating_sub(1);
905        let (blob, at, end) = field_span(|a| set(a, lo), |a| set(a, max));
906        assert_eq!(end - at, 1, "{name} is one byte");
907        Apu::new(Region::Ntsc, 44_100)
908            .restore(&blob)
909            .unwrap_or_else(|e| panic!("{name} = {max} is legal and must load: {e}"));
910        for bad in [max + 1, 0x80u8.max(max + 1), 0xFF] {
911            let mut b = blob.clone();
912            b[at] = bad;
913            match Apu::new(Region::Ntsc, 44_100).restore(&b) {
914                Err(ApuSnapshotError::FieldOutOfRange {
915                    field,
916                    value,
917                    max: m,
918                }) => {
919                    assert_eq!((field, value, m), (name, bad, max));
920                }
921                Err(e) => panic!("{name} = {bad}: wrong error {e}"),
922                Ok(()) => panic!("{name} = {bad}: restore ACCEPTED an out-of-range value"),
923            }
924        }
925    }
926
927    #[test]
928    fn every_register_width_field_is_bounded_on_restore() {
929        // Core audit IMP-02. One call per bound; each names the field its
930        // error must carry, so a check that is removed or attached to the
931        // wrong field fails here.
932        assert_u8_bounded("pulse.duty", 3, |a, v| a.pulse1.duty = v);
933        assert_u8_bounded("pulse.step", 7, |a, v| a.pulse1.step = v);
934        assert_u8_bounded("pulse.sweep_period", 7, |a, v| a.pulse2.sweep_period = v);
935        assert_u8_bounded("pulse.sweep_shift", 7, |a, v| a.pulse1.sweep_shift = v);
936        assert_u8_bounded("pulse.sweep_divider", 7, |a, v| a.pulse2.sweep_divider = v);
937        assert_u8_bounded("envelope.volume_or_period", 15, |a, v| {
938            a.pulse1.envelope.volume_or_period = v;
939        });
940        assert_u8_bounded("envelope.divider", 15, |a, v| a.noise.envelope.divider = v);
941        assert_u8_bounded("envelope.decay", 15, |a, v| a.pulse2.envelope.decay = v);
942        assert_u8_bounded("triangle.step", 31, |a, v| a.triangle.step = v);
943        assert_u8_bounded("dmc.rate_index", 15, |a, v| a.dmc.rate_index = v);
944        assert_u8_bounded("dmc.bits_remaining", 8, |a, v| a.dmc.bits_remaining = v);
945        assert_u8_bounded("dmc.dac", 127, |a, v| a.dmc.dac = v);
946    }
947
948    /// Overwrite a multi-byte field located by difference and expect a typed
949    /// resampler rejection naming `name`.
950    fn assert_float_rejected(name: &'static str, span: (Vec<u8>, usize, usize), bad: &[u8]) {
951        let (mut b, at, end) = span;
952        assert_eq!(
953            end - at,
954            bad.len(),
955            "{name}: located span is the value's width"
956        );
957        b[at..end].copy_from_slice(bad);
958        match Apu::new(Region::Ntsc, 44_100).restore(&b) {
959            Err(ApuSnapshotError::InvalidResampler(f)) => assert_eq!(f, name),
960            Err(e) => panic!("{name}: wrong error {e}"),
961            Ok(()) => panic!("{name}: restore ACCEPTED {bad:02x?}"),
962        }
963    }
964
965    #[test]
966    fn resampler_fields_that_would_hang_or_poison_audio_are_rejected() {
967        // Core audit IMP-02: a zero/NaN/huge rate ratio or an out-of-range
968        // phase cannot panic -- it hangs `add_sample`'s `while phase >= 1.0`
969        // loop, or fills the output with NaN. Each field is located by
970        // difference (its partner value is the bitwise complement, so every
971        // byte differs and the located span is the full width), then replaced
972        // with a hostile value.
973        let rate = |a: &mut Apu, v: u32| a.blip.sample_rate = v;
974        let sr = || field_span(|a| rate(a, 44_100), |a| rate(a, !44_100));
975        assert_float_rejected("blip.sample_rate", sr(), &0u32.to_le_bytes());
976        // A finite but enormous host rate: 4 billion outputs per ~1.8 M CPU
977        // cycles is > 1 per cycle, the hang the ratio bound exists for.
978        assert_float_rejected("blip.sample_rate/cpu_rate", sr(), &u32::MAX.to_le_bytes());
979
980        let cpu = |a: &mut Apu, v: f64| a.blip.cpu_rate = v;
981        let cr = || {
982            field_span(
983                |a| cpu(a, CPU_HZ_NTSC),
984                |a| cpu(a, f64::from_bits(!CPU_HZ_NTSC.to_bits())),
985            )
986        };
987        for bad in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
988            assert_float_rejected("blip.cpu_rate", cr(), &bad.to_le_bytes());
989        }
990        assert_float_rejected("blip.sample_rate/cpu_rate", cr(), &1.0e-9f64.to_le_bytes());
991
992        let ph = |a: &mut Apu, v: f64| a.blip.phase = v;
993        let pp = || field_span(|a| ph(a, 0.0), |a| ph(a, f64::from_bits(!0)));
994        for bad in [1.0, 1.0e300, -0.25, f64::NAN] {
995            assert_float_rejected("blip.phase", pp(), &bad.to_le_bytes());
996        }
997
998        let hv = |a: &mut Apu, v: f32| a.blip.held_value = v;
999        let hp = || field_span(|a| hv(a, 0.0), |a| hv(a, f32::from_bits(!0)));
1000        assert_float_rejected("blip.held_value", hp(), &f32::NAN.to_le_bytes());
1001        // NC-09 (v2.9.9): finite but outside anything the mixer produces.
1002        for bad in [f32::MAX, -2.05e38, 17.0] {
1003            assert_float_rejected("blip.held_value", hp(), &bad.to_le_bytes());
1004        }
1005        let po = |a: &mut Apu, v: f32| a.blip.filter.hp1.prev_out = v;
1006        let pop = || field_span(|a| po(a, 0.0), |a| po(a, f32::from_bits(!0)));
1007        let pi = |a: &mut Apu, v: f32| a.blip.filter.hp1.prev_in = v;
1008        let pip = || field_span(|a| pi(a, 0.0), |a| pi(a, f32::from_bits(!0)));
1009        for bad in [f32::MAX, -2.05e38, -2048.0] {
1010            assert_float_rejected("filter.prev_out", pop(), &bad.to_le_bytes());
1011            assert_float_rejected("filter.prev_in", pip(), &bad.to_le_bytes());
1012        }
1013
1014        let co = |a: &mut Apu, v: f32| a.blip.filter.lp.coeff = v;
1015        let cp = || field_span(|a| co(a, 0.5), |a| co(a, f32::from_bits(!0.5f32.to_bits())));
1016        assert_float_rejected("filter.coeff", cp(), &f32::INFINITY.to_le_bytes());
1017        // Finite but out of range: a high-pass gain above 1 diverges.
1018        for bad in [1.5f32, -0.25] {
1019            assert_float_rejected("filter.coeff", cp(), &bad.to_le_bytes());
1020        }
1021    }
1022
1023    /// NL-12 (v2.9.9 libretro re-audit): a save/load round trip resumes the
1024    /// exact sample stream and the exact serialized state. Before v5 the
1025    /// restored resampler started cold: fewer samples on the next drain, a
1026    /// different level, and filter bytes that never re-converged.
1027    #[test]
1028    fn a_restore_resumes_the_exact_audio_stream() {
1029        fn program(a: &mut Apu) {
1030            a.write_register(0x4015, 0x0F);
1031            a.write_register(0x4000, 0xBF);
1032            a.write_register(0x4002, 0x40);
1033            a.write_register(0x4003, 0x01);
1034            a.write_register(0x4008, 0xFF);
1035            a.write_register(0x400A, 0x80);
1036            a.write_register(0x400B, 0x02);
1037        }
1038        let mut straight = Apu::new(Region::Ntsc, 44_100);
1039        program(&mut straight);
1040        for _ in 0..20_000 {
1041            straight.tick();
1042        }
1043        // At a frame boundary, as every host snapshots: output drained.
1044        let _ = straight.blip.drain_all();
1045        let blob = straight.snapshot();
1046        let mut restored = Apu::new(Region::Ntsc, 48_000);
1047        restored.restore(&blob).unwrap();
1048        for _ in 0..40_000 {
1049            straight.tick();
1050            restored.tick();
1051        }
1052        let (a, b) = (straight.blip.drain_all(), restored.blip.drain_all());
1053        assert_eq!(a.len(), b.len(), "same number of samples");
1054        assert!(
1055            a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()),
1056            "bit-identical samples"
1057        );
1058        assert_eq!(straight.snapshot(), restored.snapshot(), "same state");
1059    }
1060
1061    /// A delta window whose sum leaves the integrator far from the held
1062    /// input is not a state the resampler produces, and would leave a DC
1063    /// offset the bounds could not contain; it is refused.
1064    #[test]
1065    fn a_window_inconsistent_with_the_held_value_is_refused() {
1066        let mut a = Apu::new(Region::Ntsc, 44_100);
1067        a.write_register(0x4015, 0x01);
1068        a.write_register(0x4000, 0xBF);
1069        a.write_register(0x4002, 0x40);
1070        a.write_register(0x4003, 0x01);
1071        for _ in 0..5_000 {
1072            a.tick();
1073        }
1074        let (head, primed, integrator, mut window) = a.blip.live_state();
1075        window[0] += 3.0;
1076        a.blip.set_live_state(head, primed, integrator, &window);
1077        assert!(matches!(
1078            Apu::new(Region::Ntsc, 44_100).restore(&a.snapshot()),
1079            Err(ApuSnapshotError::InvalidResampler("blip.delta_window"))
1080        ));
1081    }
1082
1083    /// NC-09 (v2.9.9 re-audit): the machine's own snapshot must always load
1084    /// back. Before the bound, a finite `prev_out` near `f32::MAX` was
1085    /// accepted, overflowed within a frame, and the next snapshot carried a
1086    /// non-finite value that `restore` then refused.
1087    #[test]
1088    fn a_huge_filter_value_is_refused_so_the_next_snapshot_still_loads() {
1089        let mut a = Apu::new(Region::Ntsc, 44_100);
1090        a.write_register(0x4015, 0x0F);
1091        a.write_register(0x4000, 0xBF);
1092        a.write_register(0x4002, 0x40);
1093        a.write_register(0x4003, 0x01);
1094        a.blip.filter.hp1.prev_out = f32::MAX;
1095        let poisoned = a.snapshot();
1096        let mut b = Apu::new(Region::Ntsc, 44_100);
1097        assert!(b.restore(&poisoned).is_err(), "the huge value is refused");
1098        // And the bound admits everything a real run produces.
1099        let mut c = Apu::new(Region::Ntsc, 44_100);
1100        c.write_register(0x4015, 0x0F);
1101        c.write_register(0x4000, 0xBF);
1102        c.write_register(0x4002, 0x40);
1103        c.write_register(0x4003, 0x01);
1104        for _ in 0..30_000 {
1105            c.tick();
1106        }
1107        let mut d = Apu::new(Region::Ntsc, 44_100);
1108        d.restore(&c.snapshot()).expect("a real state loads");
1109    }
1110
1111    /// #583 review (CodeRabbit): the filter bounds must be closed under the
1112    /// filter's own update, not just per field. `hp1.prev_in = -1024` with
1113    /// `prev_out = 1024` passed two independent caps of 1024 and stepped to
1114    /// about `c * 2048` on the next sample, so the machine's next snapshot
1115    /// was refused. Every state the validator accepts, on any stage, must
1116    /// keep producing states it accepts.
1117    #[test]
1118    fn every_accepted_filter_state_keeps_its_own_snapshot_loadable() {
1119        fn tone(a: &mut Apu) {
1120            a.write_register(0x4015, 0x0F);
1121            a.write_register(0x4000, 0xBF);
1122            a.write_register(0x4002, 0x40);
1123            a.write_register(0x4003, 0x01);
1124            a.write_register(0x4008, 0xFF);
1125            a.write_register(0x400A, 0x80);
1126            a.write_register(0x400B, 0x02);
1127        }
1128        type Stage = fn(&mut Apu) -> &mut crate::mixer::OnePole;
1129        let stages: [(&str, Stage); 3] = [
1130            ("hp1", |a| &mut a.blip.filter.hp1),
1131            ("hp2", |a| &mut a.blip.filter.hp2),
1132            ("lp", |a| &mut a.blip.filter.lp),
1133        ];
1134        let values = [
1135            -2048.0f32, -1024.0, -67.0, -33.0, -16.0, -8.0, 0.0, 8.0, 16.0, 33.0, 67.0, 1024.0,
1136        ];
1137        let mut accepted = 0;
1138        for (name, stage) in stages {
1139            for &pi in &values {
1140                for &po in &values {
1141                    let mut a = Apu::new(Region::Ntsc, 44_100);
1142                    tone(&mut a);
1143                    for _ in 0..2_000 {
1144                        a.tick();
1145                    }
1146                    let _ = a.blip.drain_all();
1147                    {
1148                        let s = stage(&mut a);
1149                        s.prev_in = pi;
1150                        s.prev_out = po;
1151                    }
1152                    let mut b = Apu::new(Region::Ntsc, 44_100);
1153                    if b.restore(&a.snapshot()).is_err() {
1154                        continue;
1155                    }
1156                    accepted += 1;
1157                    // A host can snapshot after any sample, and the overshoot
1158                    // decays within a few hundred, so check every one of the
1159                    // first fifty (about 41 CPU cycles each at 44.1 kHz).
1160                    for step in 0..50 {
1161                        for _ in 0..41 {
1162                            b.tick();
1163                        }
1164                        let _ = b.blip.drain_all();
1165                        let mut c = Apu::new(Region::Ntsc, 44_100);
1166                        c.restore(&b.snapshot()).unwrap_or_else(|e| {
1167                            panic!(
1168                                "{name} prev_in {pi} prev_out {po} was accepted, but the snapshot \
1169                                 {step} samples later is refused: {e:?}"
1170                            )
1171                        });
1172                    }
1173                }
1174            }
1175        }
1176        assert!(accepted > 0, "the grid must include accepted states");
1177    }
1178
1179    /// A filter stage whose kind does not match its position (a low-pass
1180    /// in `hp1`, say) is not a state this emulator produces, and the bounds
1181    /// for each position assume its kind.
1182    #[test]
1183    fn a_filter_stage_of_the_wrong_kind_is_refused() {
1184        let mut a = Apu::new(Region::Ntsc, 44_100);
1185        a.blip.filter.hp1.is_hpf = false;
1186        assert!(matches!(
1187            Apu::new(Region::Ntsc, 44_100).restore(&a.snapshot()),
1188            Err(ApuSnapshotError::InvalidResampler("filter.kind"))
1189        ));
1190    }
1191
1192    #[test]
1193    fn snapshot_round_trip_on_fresh_apu() {
1194        let a = Apu::new(Region::Ntsc, 44_100);
1195        let blob = a.snapshot();
1196        let mut b = Apu::new(Region::Pal, 48_000);
1197        b.restore(&blob).unwrap();
1198        assert_eq!(b.region, Region::Ntsc);
1199        assert_eq!(b.sample_rate, 44_100);
1200    }
1201
1202    #[test]
1203    fn snapshot_after_some_ticks_round_trips() {
1204        let mut a = Apu::new(Region::Ntsc, 44_100);
1205        a.write_register(0x4000, 0xBE);
1206        a.write_register(0x4002, 0x42);
1207        a.write_register(0x4015, 0x0F);
1208        for _ in 0..100 {
1209            a.tick();
1210        }
1211        let blob = a.snapshot();
1212        let mut b = Apu::new(Region::Ntsc, 44_100);
1213        b.restore(&blob).unwrap();
1214        // Spot-check critical fields.
1215        assert_eq!(b.cpu_cycle, a.cpu_cycle);
1216        assert_eq!(b.pulse1.timer_period, a.pulse1.timer_period);
1217        assert_eq!(b.pulse1.length.count, a.pulse1.length.count);
1218        assert_eq!(b.frame_counter.cycle, a.frame_counter.cycle);
1219    }
1220
1221    #[test]
1222    fn snapshot_rejects_bad_version() {
1223        let mut a = Apu::new(Region::Ntsc, 44_100);
1224        let err = a.restore(&[0xFF; 4]).unwrap_err();
1225        assert!(matches!(err, ApuSnapshotError::UnsupportedVersion(0xFF)));
1226    }
1227
1228    #[test]
1229    fn snapshot_is_deterministic() {
1230        let a = Apu::new(Region::Ntsc, 44_100);
1231        assert_eq!(a.snapshot(), a.snapshot());
1232    }
1233
1234    #[test]
1235    fn stage4_tail_round_trips_parity_and_dma_state() {
1236        let mut a = Apu::new(Region::Ntsc, 44_100);
1237        a.put_cycle = true;
1238        a.cannot_run_dmc_dma = 2;
1239        a.dmc_reenable_period_block = true;
1240        a.subpos_arm_countdown = 3;
1241        a.dmc_need_halt = true;
1242        a.dmc_need_dummy_read = true;
1243        {
1244            a.dmc_delayed_4015 = 4;
1245            a.dmc_delayed_status = true;
1246            a.dmc_status_applied = true;
1247            a.dmc_edge_arm_suppress = true;
1248        }
1249        let blob = a.snapshot();
1250        let mut b = Apu::new(Region::Ntsc, 44_100);
1251        b.restore(&blob).unwrap();
1252        assert!(b.put_cycle);
1253        assert_eq!(b.cannot_run_dmc_dma, 2);
1254        assert!(b.dmc_reenable_period_block);
1255        assert_eq!(b.subpos_arm_countdown, 3);
1256        assert!(b.dmc_need_halt);
1257        assert!(b.dmc_need_dummy_read);
1258        {
1259            assert_eq!(b.dmc_delayed_4015, 4);
1260            assert!(b.dmc_delayed_status);
1261            assert!(b.dmc_status_applied);
1262            assert!(b.dmc_edge_arm_suppress);
1263        }
1264    }
1265
1266    /// v2.9.8 (ADR 0042): older versions and blobs that end early are
1267    /// refused. Until then v1-v3 blobs were migrated, and a blob that ended
1268    /// before the DMC-DMA bytes or the W3-Stage-4 tail loaded with defaults.
1269    #[test]
1270    fn older_versions_and_short_blobs_are_refused() {
1271        let a = Apu::new(Region::Ntsc, 44_100);
1272        let blob = a.snapshot();
1273        for v in 1..APU_SNAPSHOT_VERSION {
1274            let mut old = blob.clone();
1275            old[0] = v;
1276            assert!(matches!(
1277                Apu::new(Region::Ntsc, 44_100).restore(&old),
1278                Err(ApuSnapshotError::UnsupportedVersion(got)) if got == v
1279            ));
1280        }
1281        // The pre-Stage-4 shape: the current blob minus the v4 tail (2 bytes)
1282        // and the Stage-4 tail (21 bytes), at the current version.
1283        let short = &blob[..blob.len() - (2 + 21)];
1284        assert!(matches!(
1285            Apu::new(Region::Ntsc, 44_100).restore(short),
1286            Err(ApuSnapshotError::Truncated(_))
1287        ));
1288        // Every shorter length is refused, and so is a trailing byte.
1289        for len in 1..blob.len() {
1290            assert!(
1291                Apu::new(Region::Ntsc, 44_100)
1292                    .restore(&blob[..len])
1293                    .is_err(),
1294                "an APU blob cut to {len} bytes loaded"
1295            );
1296        }
1297        let mut long = blob.clone();
1298        long.push(0);
1299        // Named as what it is: "truncated" for a blob that is too LONG sent
1300        // the reader looking for missing bytes (CodeRabbit on #580).
1301        assert!(matches!(
1302            Apu::new(Region::Ntsc, 44_100).restore(&long),
1303            Err(ApuSnapshotError::TrailingBytes(n)) if n == 1
1304        ));
1305    }
1306
1307    #[test]
1308    fn v4_round_trips_the_scheduled_reset_4017_rewrite() {
1309        // The countdown and its payload are live for the 2 CPU cycles between
1310        // `Apu::reset` arming them and `tick_with_external` firing the write.
1311        let mut a = Apu::new(Region::Ntsc, 44_100);
1312        a.reset_4017_delay = 2;
1313        a.reset_4017_value = 0x80;
1314        let blob = a.snapshot();
1315        assert_eq!(
1316            blob[0], APU_SNAPSHOT_VERSION,
1317            "blob carries current version"
1318        );
1319
1320        let mut b = Apu::new(Region::Pal, 48_000);
1321        b.restore(&blob).unwrap();
1322        assert_eq!(b.reset_4017_delay, 2);
1323        assert_eq!(b.reset_4017_value, 0x80);
1324    }
1325
1326    #[test]
1327    fn a_reset_survives_a_snapshot_restore_taken_mid_countdown() {
1328        // The behavioural pin, not just a field round trip: a save/restore
1329        // landing inside the arming window must still deliver the `$4017`
1330        // re-write on the same cycle a straight run would. Before the v4 tail
1331        // the restored APU dropped it, so the frame counter kept the sequencer
1332        // phase the re-write exists to reset.
1333        let mut plain = Apu::new(Region::Ntsc, 44_100);
1334        plain.write_register(0x4017, 0x80); // mode 5-step, so the re-write is observable
1335        plain.reset();
1336        assert_eq!(plain.reset_4017_delay, 2, "reset arms the countdown");
1337
1338        // Round-trip through a snapshot taken with the countdown live.
1339        let mut restored = Apu::new(Region::Pal, 48_000);
1340        restored.restore(&plain.snapshot()).unwrap();
1341
1342        // Advance far enough for the whole chain to play out: the countdown
1343        // fires at t=2, `FrameCounter::write` then schedules its own 3/4-cycle
1344        // maturation, and only when THAT lands does the sequencer restart. Ten
1345        // cycles clears it with margin. (Four does not — the write has fired
1346        // but its effect has not yet matured, and both sides still look alike.)
1347        for _ in 0..10 {
1348            plain.tick_with_external(0.0);
1349            restored.tick_with_external(0.0);
1350        }
1351        assert_eq!(
1352            restored.reset_4017_delay, plain.reset_4017_delay,
1353            "countdown diverged across the round trip"
1354        );
1355        // `frame_counter.cycle` is the discriminating observable. `mode` is not:
1356        // `reset_rewrite_4017` retains bit 7, so the re-write always restores the
1357        // mode already in effect and the field reads the same either way.
1358        // Without the v4 tail the restored APU never issues the write, so its
1359        // sequencer keeps counting instead of restarting.
1360        assert_eq!(
1361            restored.frame_counter.cycle, plain.frame_counter.cycle,
1362            "the scheduled $4017 re-write did not survive the round trip — the \
1363             restored sequencer never restarted"
1364        );
1365        assert_eq!(
1366            restored.frame_counter.reset_in, plain.frame_counter.reset_in,
1367            "frame-counter reset maturation diverged across the round trip"
1368        );
1369    }
1370
1371    #[test]
1372    fn fresh_apu_snapshot_has_zero_irq_clear_schedule() {
1373        let a = Apu::new(Region::Ntsc, 44_100);
1374        assert_eq!(a.frame_counter.irq_flag_clear_cycle, 0);
1375        let blob = a.snapshot();
1376        let mut b = Apu::new(Region::Pal, 48_000);
1377        b.restore(&blob).unwrap();
1378        assert_eq!(b.frame_counter.irq_flag_clear_cycle, 0);
1379    }
1380}