Skip to main content

rustynes_apu/
provenance.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Audio provenance (v2.3.7 "Overtone") — why does this moment sound like that?
3//!
4//! The APU analogue of the PPU's `rustynes_ppu::provenance` (a plain code span,
5//! not an intra-doc link: `rustynes-apu` does not depend on `rustynes-ppu`, and a
6//! bracketed link to a crate outside the graph fails `RUSTDOCFLAGS=-D warnings`
7//! while clippy stays green), and deliberately
8//! built to the same shape: a **register-attribution** half that answers "what
9//! wrote this, and from which instruction", and a **per-cycle mix trace** that
10//! answers "what were the channels actually doing".
11//!
12//! # What was missing before this
13//!
14//! Every other ingredient already shipped. The frontend's audio scope plots the
15//! per-channel waveforms, the audio mixer exposes per-channel gain,
16//! [`crate::Apu::pulse1_out`] and its siblings expose live channel outputs, and
17//! the trace logger has PC and cycle. What did not exist anywhere is the **link
18//! between a sample and the instruction that caused it** — exactly the gap the
19//! pixel-provenance design identified for video.
20//!
21//! # Cadence, and why it is per CPU cycle rather than per output sample
22//!
23//! The mix is computed **once per CPU cycle** (1.789 MHz NTSC) and handed to the
24//! band-limited `blip` decimator, which produces output samples at 44.1 kHz —
25//! about one per 40.6 CPU cycles. Recording at *output* rate would therefore
26//! require choosing which of those 40 mixes "is" the sample, and band-limited
27//! synthesis makes that choice ill-posed: an output sample is a weighted sum of
28//! transitions across the filter kernel, not a copy of one instant.
29//!
30//! So this records what was genuinely mixed, at the cadence it was mixed. The
31//! panel maps a clicked output sample back to its CPU-cycle window; the doc says
32//! plainly that the window is a kernel width, not a point. **A provenance tool
33//! that answers a question it cannot actually answer is worse than one that
34//! declines** — the whole argument v2.3.6 was built on.
35//!
36//! The cost is smaller than it sounds: 29,781 records per NTSC frame against the
37//! pixel store's 61,440 — **0.48x the record count of the video side**.
38//!
39//! # Determinism
40//!
41//! Output-only. Nothing here is read back into synthesis, none of it is part of
42//! the save state, and every store is behind a runtime arm that is off by
43//! default. With the arm off the cost is one `Option` discriminant test per
44//! register write and per mixed cycle.
45
46use alloc::boxed::Box;
47use alloc::vec::Vec;
48
49/// First APU/IO register address covered by [`RegisterAttribution`].
50pub const REG_BASE: u16 = 0x4000;
51
52/// Number of register slots tracked: `$4000-$4017` inclusive.
53///
54/// `$4014` (OAM DMA) and `$4016` (controller strobe) are inside the range and
55/// are NOT APU registers. They are tracked anyway rather than punched out: the
56/// range is what the bus already classifies as [`EventKind::ApuWrite`], keeping
57/// one contiguous index space costs two slots, and a hole would be a permanent
58/// invitation to off-by-one arithmetic at every call site.
59///
60/// [`EventKind::ApuWrite`]: https://docs.rs/rustynes-core
61pub const REG_COUNT: usize = 0x18;
62
63/// [`REG_COUNT`] as a `u16`, so address arithmetic never needs a cast.
64pub const REG_COUNT_U16: u16 = 0x18;
65
66/// Largest CPU-cycle count in one frame across every supported region, which is
67/// what [`MixTrace`] is sized for.
68///
69/// Dendy is the worst case, not NTSC: 1,773,448 Hz / 50.007 fps = **35,464**
70/// cycles per frame, against PAL's 33,247 and NTSC's 29,781. Sizing this from
71/// the NTSC number — the one that comes to mind first — would silently truncate
72/// the last 16% of every Dendy frame.
73pub const MIX_CAP: usize = 36_864;
74
75// ---------------------------------------------------------------------------
76// Register attribution — "what wrote $4003, and from where?"
77// ---------------------------------------------------------------------------
78
79/// One register write: the byte, the CPU cycle, and the instruction that did it.
80///
81/// `cycle` is the CPU cycle counter at the writing instruction, which is what
82/// makes a record comparable against the Trace Logger and the Event Viewer for
83/// the same frame. Mirrors `rustynes_ppu::provenance::WriteAttrib`, which
84/// carries the same three fields for VRAM/OAM/palette bytes.
85#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
86pub struct RegWrite {
87    /// CPU cycle count at the write.
88    pub cycle: u64,
89    /// Program counter of the writing instruction. Meaningful only when
90    /// [`Self::origin`] is [`WriteOrigin::Instruction`].
91    pub pc: u16,
92    /// The byte written, as the CPU put it on the bus.
93    pub value: u8,
94    /// What performed the write.
95    pub origin: WriteOrigin,
96}
97
98/// What performed a register write.
99///
100/// **Not every write to `$4000-$4017` comes from an instruction, and a
101/// provenance tool that pretends otherwise is worse than no tool.** `Apu::reset`
102/// performs an internal `write_register($4015, 0)` modelling the warm-reset
103/// silencing of the channels. That is real hardware behaviour with no CPU
104/// instruction behind it, and attributing it to whatever PC happened to be
105/// latched would print a confident, specific, wrong answer — the exact failure
106/// this feature exists to prevent, reproduced by the feature itself.
107///
108/// Found in review of the PR that introduced audio provenance, before it
109/// shipped. The alternative fixes were both worse: suppressing the record
110/// entirely would leave the slot advertising the register's *previous* value
111/// after reset genuinely changed it, and a sentinel PC would be indistinguishable
112/// from a real write to address zero.
113#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
114pub enum WriteOrigin {
115    /// A CPU instruction wrote it; `pc` names that instruction.
116    #[default]
117    Instruction,
118    /// The APU's own reset sequence wrote it. `pc` is not meaningful.
119    Reset,
120}
121
122/// Storage-side mirror of [`RegWrite`] with a `Default`, so a slot array can be
123/// built without inventing a meaningless public default PC.
124#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
125struct RegWriteInner {
126    cycle: u64,
127    pc: u16,
128    value: u8,
129    origin: WriteOrigin,
130}
131
132/// One attribution slot plus whether anything has been written yet.
133///
134/// A `written` flag rather than a sentinel cycle, for the reason the PPU side
135/// documents: **cycle 0 is a legitimate value** — the reset sequence performs
136/// real writes — so a sentinel would silently misreport the earliest writes in a
137/// run as "never written".
138#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
139struct Slot {
140    rec: RegWriteInner,
141    written: bool,
142}
143
144impl Slot {
145    const fn get(self) -> Option<RegWrite> {
146        if self.written {
147            Some(RegWrite {
148                cycle: self.rec.cycle,
149                pc: self.rec.pc,
150                value: self.rec.value,
151                origin: self.rec.origin,
152            })
153        } else {
154            None
155        }
156    }
157}
158
159/// Last write to each of `$4000-$4017`, with its cause.
160///
161/// **Last write, not a history.** One slot per address rather than a ring,
162/// because the question this half answers is "what is the register holding, and
163/// who put it there" — and a ring would need a retention policy nobody has a
164/// principled value for. The Event Viewer already keeps the per-frame write
165/// *sequence*; this keeps the per-register *cause*, which it does not.
166#[derive(Clone, Debug)]
167pub struct RegisterAttribution {
168    slots: [Slot; REG_COUNT],
169}
170
171impl RegisterAttribution {
172    /// A fresh table with every slot unwritten.
173    #[must_use]
174    pub const fn new() -> Self {
175        Self {
176            slots: [Slot {
177                rec: RegWriteInner {
178                    cycle: 0,
179                    pc: 0,
180                    value: 0,
181                    origin: WriteOrigin::Instruction,
182                },
183                written: false,
184            }; REG_COUNT],
185        }
186    }
187
188    /// Forget every recorded write.
189    pub fn clear(&mut self) {
190        *self = Self::new();
191    }
192
193    /// Record a write to `addr`. Addresses outside `$4000-$4017` are dropped
194    /// rather than wrapping into an unrelated slot.
195    pub const fn record(&mut self, addr: u16, pc: u16, cycle: u64, value: u8) {
196        let Some(idx) = Self::index(addr) else {
197            return;
198        };
199        self.slots[idx] = Slot {
200            rec: RegWriteInner {
201                cycle,
202                pc,
203                value,
204                origin: WriteOrigin::Instruction,
205            },
206            written: true,
207        };
208    }
209
210    /// Record a write performed by the APU's own reset sequence.
211    ///
212    /// Overwrites whatever [`Self::record`] just stored for the same address,
213    /// which is deliberate: `Apu::reset` reaches the slot through the ordinary
214    /// `write_register` path, so the honest origin has to replace the
215    /// instruction attribution that path installs. The value and cycle are
216    /// genuine — the register really did change, at that time — and only the
217    /// claim about *who caused it* is corrected.
218    pub const fn record_reset(&mut self, addr: u16, cycle: u64, value: u8) {
219        let Some(idx) = Self::index(addr) else {
220            return;
221        };
222        self.slots[idx] = Slot {
223            rec: RegWriteInner {
224                cycle,
225                pc: 0,
226                value,
227                origin: WriteOrigin::Reset,
228            },
229            written: true,
230        };
231    }
232
233    /// The last write to `addr`, or `None` if the address is out of range or
234    /// nothing has written it since the last [`Self::clear`].
235    #[must_use]
236    pub const fn get(&self, addr: u16) -> Option<RegWrite> {
237        match Self::index(addr) {
238            Some(idx) => self.slots[idx].get(),
239            None => None,
240        }
241    }
242
243    /// Map a register address to a slot index, or `None` when out of range.
244    const fn index(addr: u16) -> Option<usize> {
245        if addr < REG_BASE {
246            return None;
247        }
248        let idx = (addr - REG_BASE) as usize;
249        if idx < REG_COUNT { Some(idx) } else { None }
250    }
251}
252
253impl Default for RegisterAttribution {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Mix trace — "what were the channels doing?"
261// ---------------------------------------------------------------------------
262
263/// The five channel outputs that went into one mixed CPU-cycle sample, plus the
264/// result.
265///
266/// Channel values are the raw pre-mix outputs each channel presented — 0-15 for
267/// the two pulses, the triangle and the noise, 0-127 for the DMC — which is what
268/// the non-linear mixer consumes. They are NOT scaled by the frontend's mixer
269/// gains: those are a presentation control, and recording post-gain values would
270/// make the record describe the user's slider rather than the chip.
271#[derive(Clone, Copy, Debug, Default, PartialEq)]
272pub struct MixRecord {
273    /// The mixed sample handed to the band-limited decimator, including any
274    /// expansion audio.
275    pub mixed: f32,
276    /// The expansion-audio contribution, RAW (`0.0` on a cartridge without one).
277    ///
278    /// Raw in the same sense as the five channel fields: before the frontend's
279    /// expansion gain and before the mixer mask. So on a muted or attenuated
280    /// expansion channel this reports what the cartridge produced, not what
281    /// reached `mixed` — consistent with `pulse1` reporting a muted pulse's
282    /// output rather than zero. Both mix paths record this same raw value; an
283    /// earlier revision recorded the gained value on one of them, which review
284    /// caught.
285    pub external: f32,
286    /// Pulse 1 output, 0-15.
287    pub pulse1: u8,
288    /// Pulse 2 output, 0-15.
289    pub pulse2: u8,
290    /// Triangle output, 0-15.
291    pub triangle: u8,
292    /// Noise output, 0-15.
293    pub noise: u8,
294    /// DMC output, 0-127.
295    pub dmc: u8,
296}
297
298impl MixRecord {
299    /// Which channel contributed most to this sample, as an index into the
300    /// conventional order (0 = pulse 1 … 4 = DMC), or `None` when every channel
301    /// is silent.
302    ///
303    /// Compares each channel's share of **its own full scale** rather than its
304    /// raw value, because the raw values are not commensurable: a DMC 127 and a
305    /// pulse 15 are both "full scale" on different scales.
306    ///
307    /// That normalisation is **linear** — `value / max` — and deliberately does
308    /// NOT model the non-linear mixer, which an earlier draft of this comment
309    /// claimed it did. The two give different answers, since the mixer weights
310    /// the triangle/noise/DMC group differently from the pulses and is not
311    /// proportional in either. This reports which channel is working hardest
312    /// relative to what it can do, which is the question a user pointing at a
313    /// cycle is asking; attributing loudness in the final mix would be a
314    /// different function, and calling this one that would be a false label on
315    /// a correct computation.
316    #[must_use]
317    pub fn dominant(&self) -> Option<usize> {
318        let shares = [
319            f32::from(self.pulse1) / 15.0,
320            f32::from(self.pulse2) / 15.0,
321            f32::from(self.triangle) / 15.0,
322            f32::from(self.noise) / 15.0,
323            f32::from(self.dmc) / 127.0,
324        ];
325        let mut best = None;
326        let mut best_share = 0.0f32;
327        for (i, &s) in shares.iter().enumerate() {
328            if s > best_share {
329                best_share = s;
330                best = Some(i);
331            }
332        }
333        best
334    }
335}
336
337/// One frame's worth of per-CPU-cycle mix records.
338///
339/// The index **is** the cycle offset from [`Self::first_cycle`], so no per-record
340/// timestamp is stored — that is what keeps the record at 16 bytes.
341///
342/// Two different numbers follow from that, and the first draft of this comment
343/// conflated them. An **NTSC frame** of 29,781 records is ~465 KiB; the
344/// **allocation** is [`MIX_CAP`] records reserved up front, which is 576 KiB,
345/// because the cap is sized from Dendy (the longest frame) rather than from
346/// NTSC. The buffer is therefore always the worst case, never the typical one.
347#[derive(Clone, Debug)]
348pub struct MixTrace {
349    recs: Vec<MixRecord>,
350    first_cycle: u64,
351    /// Set when a frame produced more cycles than [`MIX_CAP`] and records were
352    /// dropped. Surfaced rather than silent: a truncated trace that looks
353    /// complete is the failure mode this whole subsystem exists to avoid.
354    truncated: bool,
355}
356
357impl MixTrace {
358    /// An empty trace with capacity for the worst-case region.
359    #[must_use]
360    pub fn new() -> Self {
361        Self {
362            recs: Vec::with_capacity(MIX_CAP),
363            first_cycle: 0,
364            truncated: false,
365        }
366    }
367
368    /// Drop every record and re-anchor the trace at `first_cycle`.
369    pub fn clear(&mut self, first_cycle: u64) {
370        self.recs.clear();
371        self.first_cycle = first_cycle;
372        self.truncated = false;
373    }
374
375    /// Append one mixed cycle. Beyond [`MIX_CAP`] the record is dropped and the
376    /// trace is flagged [`Self::truncated`].
377    pub fn push(&mut self, rec: MixRecord) {
378        if self.recs.len() >= MIX_CAP {
379            self.truncated = true;
380            return;
381        }
382        self.recs.push(rec);
383    }
384
385    /// CPU cycle the first record corresponds to.
386    #[must_use]
387    pub const fn first_cycle(&self) -> u64 {
388        self.first_cycle
389    }
390
391    /// Whether records were dropped for exceeding [`MIX_CAP`].
392    #[must_use]
393    pub const fn truncated(&self) -> bool {
394        self.truncated
395    }
396
397    /// Every record, oldest first.
398    #[must_use]
399    pub fn records(&self) -> &[MixRecord] {
400        &self.recs
401    }
402
403    /// The record for absolute CPU `cycle`, or `None` if it is outside the
404    /// trace.
405    #[must_use]
406    pub fn at_cycle(&self, cycle: u64) -> Option<MixRecord> {
407        let idx = usize::try_from(cycle.checked_sub(self.first_cycle)?).ok()?;
408        self.recs.get(idx).copied()
409    }
410}
411
412impl Default for MixTrace {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418// ---------------------------------------------------------------------------
419// Run-ahead carry
420// ---------------------------------------------------------------------------
421
422/// Both audio stores, lifted out of the APU so a same-timeline restore can put
423/// them back.
424///
425/// **This exists because of a shipped bug, not a hypothetical one.** Pixel
426/// Provenance was non-functional from v2.3.2 to v2.3.6 because run-ahead's
427/// per-frame rollback cleared the provenance store *after* the visible frame was
428/// produced and *before* the UI could read it — so the panel could never observe
429/// a populated record, and a comment two lines above the clear asserted the
430/// opposite. Audio Provenance rides the identical rollback.
431///
432/// The frontend takes this before `restore_quiet` and puts it back after, which
433/// leaves the restore's own reasoning completely intact: a save-state load and a
434/// netplay rollback still clear, because those are genuine timeline changes.
435/// Run-ahead's rollback is not — it returns to the timeline it just left.
436#[derive(Debug, Default)]
437pub struct AudioProvenanceStash {
438    pub(crate) state: Option<Box<AudioProvenance>>,
439}
440
441impl AudioProvenanceStash {
442    /// Whether the store was armed when this stash was taken, so the caller can
443    /// skip the put-back on the common path where nothing is armed.
444    #[must_use]
445    pub const fn is_armed(&self) -> bool {
446        self.state.is_some()
447    }
448
449    /// The stashed per-CPU-cycle mix trace, or `None` when unarmed.
450    ///
451    /// v2.3.8 — read-only, so a detached stash can be inspected without being
452    /// put back into an emulator first. `rustynes-probe` captures a trial's
453    /// provenance precisely so it never touches a live `Nes` again; routing the
454    /// read through a scratch instance would undo that.
455    #[must_use]
456    pub fn mix_trace(&self) -> Option<&MixTrace> {
457        self.state.as_ref().map(|p| &p.mix_trace)
458    }
459
460    /// The stashed per-register write attribution, or `None` when unarmed.
461    /// Companion to [`Self::mix_trace`].
462    #[must_use]
463    pub fn register_attribution(&self) -> Option<&RegisterAttribution> {
464        self.state.as_ref().map(|p| &p.reg_attrib)
465    }
466}
467
468/// Everything audio provenance owns, behind ONE pointer.
469///
470/// **Consolidated after measurement, not for tidiness.** The first shape put
471/// four fields directly on `Apu` — two `Option<Box<..>>` plus the `u16`/`u64`
472/// attribution context. That grew the struct on the hot path and the
473/// `apu_throughput` bench read **+9%** on two of three workloads with the arm
474/// OFF, which is the configuration the shipped frontend runs. One `Option<Box>`
475/// costs eight bytes and one null test when disarmed, and everything else moves
476/// behind the allocation where only an armed session pays for it.
477#[derive(Clone, Debug)]
478pub struct AudioProvenance {
479    /// Last write to each of `$4000-$4017`, with its cause.
480    pub reg_attrib: RegisterAttribution,
481    /// This frame's per-CPU-cycle mix records.
482    pub mix_trace: MixTrace,
483    /// PC of the instruction currently executing, pushed down once per
484    /// instruction by the core so `write_register` can attribute a write.
485    pub attrib_pc: u16,
486    /// CPU-cycle counterpart of [`Self::attrib_pc`].
487    pub attrib_cycle: u64,
488}
489
490impl AudioProvenance {
491    /// A freshly-armed store.
492    #[must_use]
493    pub fn new() -> Self {
494        Self {
495            reg_attrib: RegisterAttribution::new(),
496            mix_trace: MixTrace::new(),
497            attrib_pc: 0,
498            attrib_cycle: 0,
499        }
500    }
501}
502
503impl Default for AudioProvenance {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn unwritten_slots_report_none() {
515        let a = RegisterAttribution::new();
516        for addr in REG_BASE..REG_BASE + REG_COUNT_U16 {
517            assert_eq!(a.get(addr), None, "{addr:#06X} should be unwritten");
518        }
519    }
520
521    #[test]
522    fn cycle_zero_is_a_real_write_not_a_sentinel() {
523        // The reset sequence performs writes at cycle 0. A sentinel-cycle design
524        // would report these as "never written".
525        let mut a = RegisterAttribution::new();
526        a.record(0x4000, 0, 0, 0);
527        assert_eq!(
528            a.get(0x4000),
529            Some(RegWrite {
530                cycle: 0,
531                pc: 0,
532                value: 0,
533                origin: WriteOrigin::Instruction,
534            })
535        );
536    }
537
538    #[test]
539    fn out_of_range_addresses_are_dropped_not_wrapped() {
540        let mut a = RegisterAttribution::new();
541        a.record(0x3FFF, 0x1234, 9, 0xAA);
542        a.record(0x4018, 0x1234, 9, 0xBB);
543        a.record(0xFFFF, 0x1234, 9, 0xCC);
544        for addr in REG_BASE..REG_BASE + REG_COUNT_U16 {
545            assert_eq!(a.get(addr), None, "{addr:#06X} was written by a stray addr");
546        }
547        assert_eq!(a.get(0x4018), None);
548    }
549
550    /// A reset-driven write must NOT claim an instruction caused it.
551    ///
552    /// `Apu::reset` silences the channels via an internal
553    /// `write_register($4015, 0)`, which reaches the attribution table through
554    /// the ordinary CPU path and is therefore stamped with whatever PC was last
555    /// latched. Without the correction that produces a specific, confident,
556    /// false answer in the one register a user would look at after pressing
557    /// Reset. Both halves are asserted because they fail independently: the
558    /// origin could be right while the value went stale, or vice versa.
559    #[test]
560    fn a_reset_write_is_not_attributed_to_an_instruction() {
561        let mut a = RegisterAttribution::new();
562
563        // The CPU path runs first, exactly as `Apu::reset` causes it to.
564        a.record(0x4015, 0xC5F3, 1_234, 0x1F);
565        let before = a.get(0x4015).expect("recorded");
566        assert_eq!(before.origin, WriteOrigin::Instruction);
567        assert_eq!(before.pc, 0xC5F3);
568
569        // ...then the reset correction replaces the CAUSE, not the effect.
570        a.record_reset(0x4015, 1_234, 0x00);
571        let after = a.get(0x4015).expect("still recorded");
572        assert_eq!(
573            after.origin,
574            WriteOrigin::Reset,
575            "a reset write still claims an instruction wrote it"
576        );
577        assert_eq!(
578            after.value, 0x00,
579            "the corrected record lost the value the reset actually wrote"
580        );
581        assert_eq!(after.cycle, 1_234, "the correction moved the write in time");
582
583        // A neighbouring slot is untouched -- the correction is not a wipe.
584        assert!(a.get(0x4014).is_none());
585    }
586
587    #[test]
588    fn last_write_wins_per_address_and_addresses_are_independent() {
589        let mut a = RegisterAttribution::new();
590        a.record(0x4003, 0x8000, 10, 0x11);
591        a.record(0x4003, 0x8100, 20, 0x22);
592        a.record(0x4007, 0x8200, 30, 0x33);
593        assert_eq!(
594            a.get(0x4003).map(|w| (w.pc, w.cycle, w.value)),
595            Some((0x8100, 20, 0x22))
596        );
597        assert_eq!(
598            a.get(0x4007).map(|w| (w.pc, w.cycle, w.value)),
599            Some((0x8200, 30, 0x33))
600        );
601    }
602
603    #[test]
604    fn mix_trace_index_is_the_cycle_offset() {
605        let mut t = MixTrace::new();
606        t.clear(1_000);
607        for i in 0..4u8 {
608            t.push(MixRecord {
609                pulse1: i,
610                ..MixRecord::default()
611            });
612        }
613        assert_eq!(t.at_cycle(1_000).map(|r| r.pulse1), Some(0));
614        assert_eq!(t.at_cycle(1_003).map(|r| r.pulse1), Some(3));
615        assert_eq!(t.at_cycle(999), None, "before the anchor");
616        assert_eq!(t.at_cycle(1_004), None, "past the end");
617    }
618
619    #[test]
620    fn truncation_is_reported_rather_than_silent() {
621        let mut t = MixTrace::new();
622        t.clear(0);
623        for _ in 0..MIX_CAP {
624            t.push(MixRecord::default());
625        }
626        assert!(!t.truncated(), "exactly at capacity is not truncation");
627        t.push(MixRecord::default());
628        assert!(t.truncated(), "over capacity must be visible to the caller");
629        assert_eq!(t.records().len(), MIX_CAP);
630    }
631
632    #[test]
633    fn mix_cap_covers_the_worst_case_region() {
634        // Dendy, not NTSC, is the worst case: 1_773_448 / 50.007 = 35,464.
635        // Sizing from the NTSC number would truncate 16% of every Dendy frame.
636        // Integer arithmetic: a float cast here would trip the truncation
637        // lint that this crate denies, and ceil-divide is the exact operation
638        // the bound needs anyway.
639        let dendy_cycles_per_frame = (1_773_448_000_usize).div_ceil(50_007);
640        assert!(
641            MIX_CAP >= dendy_cycles_per_frame,
642            "MIX_CAP {MIX_CAP} < Dendy {dendy_cycles_per_frame}"
643        );
644    }
645
646    #[test]
647    fn dominant_compares_normalised_share_not_raw_value() {
648        // DMC 100/127 (0.787) beats pulse 15/15? No — pulse is 1.0. The point is
649        // that raw magnitude would pick the DMC, and share picks the pulse.
650        let r = MixRecord {
651            pulse1: 15,
652            dmc: 100,
653            ..MixRecord::default()
654        };
655        assert_eq!(r.dominant(), Some(0), "pulse at full scale must win");
656
657        let r = MixRecord {
658            pulse1: 8,
659            dmc: 100,
660            ..MixRecord::default()
661        };
662        assert_eq!(r.dominant(), Some(4), "DMC 0.787 beats pulse 0.533");
663
664        assert_eq!(
665            MixRecord::default().dominant(),
666            None,
667            "silence has no winner"
668        );
669    }
670
671    #[test]
672    fn a_stash_reports_unarmed_when_empty() {
673        assert!(!AudioProvenanceStash::default().is_armed());
674    }
675}