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}