Skip to main content

rustyn64_core/
vi.rs

1//! Video Interface (VI) register file.
2//!
3//! The VI reads the framebuffer at `VI_ORIGIN` and scans it out to the DAC,
4//! raising the VI interrupt at the programmed scanline. This module owns the
5//! **memory-mapped register block** at `0x0440_0000` — sixteen 32-bit registers
6//! the CPU programs — plus the scan-position timing:
7//!
8//! - The register latches, with one side effect: writing `VI_V_CURRENT`
9//!   acknowledges (clears) the VI interrupt.
10//! - [`Vi::tick`] advances `VI_V_CURRENT` off `master_ticks` (the fractional VI
11//!   domain — `docs/scheduler.md`) and reports a `VI_V_INTR` crossing, which the
12//!   scheduler turns into `MI_INTR.vi`.
13//!
14//! Not here (elsewhere or deferred): the framebuffer→RGBA scan-*out* conversion
15//! is `Bus::scanout` (the live 1:1 path) and `Bus::scanout_scaled` (the accurate
16//! `VI_X_SCALE`/`VI_Y_SCALE`-resampling path being built up slice by slice against
17//! the Angrylion `.vivec` oracle — ledger R-5); the field cadence is anchored to
18//! nominal 60 Hz NTSC (ledger R-6, PAL later); and the per-register write masks are
19//! not yet applied (ledger R-4). Reference: `n64brew_wiki/markdown/Video Interface.md`.
20
21use serde::{Deserialize, Serialize};
22/// `VI_CTRL` (`0x0440_0000`): pixel type, AA/serrate/dither config. `TYPE == 0`
23/// turns the VI off (no interrupt is ever generated).
24pub const VI_CTRL: u32 = 0;
25/// `VI_ORIGIN` (0x04): RDRAM base of the framebuffer being scanned out.
26pub const VI_ORIGIN: u32 = 1;
27/// `VI_WIDTH` (0x08): framebuffer width in pixels.
28pub const VI_WIDTH: u32 = 2;
29/// `VI_V_INTR` (0x0C): the half-line at which the VI interrupt is raised.
30pub const VI_V_INTR: u32 = 3;
31/// `VI_V_CURRENT` (0x10): the half-line currently being scanned.
32///
33/// A **write acknowledges the VI interrupt**; the value itself is not
34/// software-latched (it reflects the scan position, set by the scheduler once
35/// that lands).
36pub const VI_V_CURRENT: u32 = 4;
37/// `VI_BURST` (0x14): color-burst timing.
38pub const VI_BURST: u32 = 5;
39/// `VI_V_TOTAL` (0x18): total half-lines per frame; bit 0 selects
40/// interlaced/progressive `VI_V_INTR` behavior.
41pub const VI_V_TOTAL: u32 = 6;
42/// `VI_H_TOTAL` (0x1C): total pixels (quarter-precision) per line.
43pub const VI_H_TOTAL: u32 = 7;
44/// `VI_H_TOTAL_LEAP` (0x20): line-length modulation for exact frame timing.
45pub const VI_H_TOTAL_LEAP: u32 = 8;
46/// `VI_H_VIDEO` (0x24): active video horizontal start/end.
47pub const VI_H_VIDEO: u32 = 9;
48/// `VI_V_VIDEO` (0x28): active video vertical start/end.
49pub const VI_V_VIDEO: u32 = 10;
50/// `VI_V_BURST` (0x2C): vertical color-burst start/end.
51pub const VI_V_BURST: u32 = 11;
52/// `VI_X_SCALE` (0x30): horizontal scale factor (framebuffer → screen).
53pub const VI_X_SCALE: u32 = 12;
54/// `VI_Y_SCALE` (0x34): vertical scale factor.
55pub const VI_Y_SCALE: u32 = 13;
56/// `VI_TEST_ADDR` (0x38): RDRAM diagnostic access address.
57pub const VI_TEST_ADDR: u32 = 14;
58/// `VI_STAGED_DATA` (0x3C): RDRAM diagnostic staged data.
59pub const VI_STAGED_DATA: u32 = 15;
60
61/// Number of 32-bit registers in the VI block.
62pub const VI_REG_COUNT: usize = 16;
63
64/// Nominal NTSC field rate anchoring the VI scan cadence.
65///
66/// The VI dot clock is off a separate crystal (~48.68 MHz) that the N64brew wiki
67/// gives only *roughly*, so rather than fit an imprecise dot-clock frequency, the
68/// field cadence is anchored to the standard NTSC **60 Hz** and the per-half-line
69/// period derived from the software-programmed `VI_V_TOTAL`. PAL uses
70/// [`VI_FIELD_HZ_PAL`], selected by `Vi::field_hz` from the field length.
71pub const VI_FIELD_HZ: u64 = 60;
72
73/// Nominal PAL field rate (R-6): PAL's standard **50 Hz** field cadence.
74///
75/// The counterpart to NTSC's [`VI_FIELD_HZ`] — a documented broadcast standard
76/// (N64brew *Video Interface*: NTSC ~60 Hz / PAL 50 Hz), not a fitted value, so it
77/// is anchored the same way as the NTSC rate. Selected when the programmed field is
78/// PAL-length (`VI_V_TOTAL > 550`, ~625 half-lines vs NTSC's ~525), matching the
79/// scan-out geometry's `ispal` test. The interlace/serrate half-field quirk and the
80/// exact `H_TOTAL` sub-field timing remain deferred under R-6.
81pub const VI_FIELD_HZ_PAL: u64 = 50;
82
83/// The number of half-lines the widest `VI_V_TOTAL` can encode.
84///
85/// `Vi::total_halflines` is `(VI_V_TOTAL & 0x3FF) + 1`, so the field is 1..=1024
86/// half-lines long and can never be zero — which is why `ticks_per_halfline` cannot
87/// divide by zero, and also why it can never *return* zero.
88const VI_MAX_HALFLINES: u64 = 1024;
89
90/// The smallest value `Vi::ticks_per_halfline` can take over the **entire**
91/// programmable space: the fastest field rate against the longest field.
92///
93/// `Vi::tick` runs on every RCP step and a half-line elapses on roughly one call in
94/// 1,980, so the division that produces the real period is almost always wasted. It
95/// is skipped when the accumulator has not reached this bound, which is exact rather
96/// than approximate: below it the `while` loop cannot execute for *any* legal
97/// register programming, so nothing that depends on the true period is being guessed.
98///
99/// Both operands are extremal on purpose. `VI_FIELD_HZ` (60) is the larger of the two
100/// field rates and [`VI_MAX_HALFLINES`] the longest field, so their product is the
101/// largest divisor and this quotient the smallest period. A `const` assertion below
102/// pins that, so a new field rate or a wider `VI_V_TOTAL` mask breaks the build
103/// rather than silently making the bound too large — which would skip a half-line.
104const VI_MIN_TICKS_PER_HALFLINE: u64 = crate::MASTER_HZ / (VI_FIELD_HZ * VI_MAX_HALFLINES);
105
106const _: () = {
107    assert!(
108        VI_FIELD_HZ >= VI_FIELD_HZ_PAL,
109        "VI_MIN_TICKS_PER_HALFLINE assumes the NTSC rate is the faster one; if PAL \
110         ever exceeds it, the minimum period is computed from the wrong field rate"
111    );
112    // The mask in `total_halflines` is what actually bounds the field length. If it
113    // widens, this constant must widen with it or `Vi::tick` will skip a half-line.
114    assert!(
115        VI_MAX_HALFLINES == (0x3FF + 1),
116        "VI_MAX_HALFLINES must match the 0x3FF mask in Vi::total_halflines"
117    );
118    assert!(
119        VI_MIN_TICKS_PER_HALFLINE > 0,
120        "a zero bound would disable the fast path rather than merely loosen it"
121    );
122};
123
124/// The **encoded** `VI_V_TOTAL` register value above which a field is treated as
125/// **PAL** rather than NTSC.
126///
127/// The comparison is against the raw register value, which encodes `half-lines − 1`
128/// (`total_halflines() == VI_V_TOTAL + 1`): NTSC programs ~524 (525 half-lines), PAL
129/// ~624 (625 half-lines), so the encoded `> 550` splits them with wide margin.
130/// Shared by `Vi::field_hz` (the field-rate select) and `bus::scanout_scaled` (the
131/// geometry `ispal` select) so cadence and geometry agree on the region. Named after
132/// N64brew *Video Interface* §Clocks (region field lengths); the register at this
133/// offset is `VI_V_SYNC` in the wiki's naming.
134pub const VI_PAL_V_TOTAL_THRESHOLD: u32 = 550;
135
136/// The Video Interface register file (the `0x0440_0000` block).
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Vi {
139    /// The sixteen 32-bit registers, indexed by word offset. `pub(crate)` so
140    /// every external access goes through [`Vi::read`]/[`Vi::write`] — which is
141    /// where the `VI_V_CURRENT` side effect (and future write masks) live; the
142    /// scan-out and tests, being in this crate, read them directly.
143    pub(crate) regs: [u32; VI_REG_COUNT],
144    /// The current scan half-line (`VI_V_CURRENT`'s read-back value). Advanced by
145    /// [`Vi::tick`] one half-line at a time — this is the fractional-domain state
146    /// the scheduler drives (`docs/scheduler.md`). Kept **relative** (incremented
147    /// and wrapped at `VI_V_TOTAL + 1`) rather than derived from absolute
148    /// `master_ticks`, so a mid-run `VI_V_TOTAL` change re-bases cleanly.
149    v_current: u32,
150    /// Master ticks accumulated toward the next half-line advance (the fractional
151    /// remainder the scheduler doc calls for).
152    acc: u64,
153    /// The `master_ticks` at the last [`Vi::tick`], to compute the elapsed delta.
154    prev_ticks: u64,
155}
156
157impl Default for Vi {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163impl Vi {
164    /// Construct at power-on: every register zero, so `VI_CTRL.TYPE == 0` (the
165    /// VI is off) — the correct cold-boot state.
166    #[must_use]
167    pub const fn new() -> Self {
168        Self {
169            regs: [0; VI_REG_COUNT],
170            v_current: 0,
171            acc: 0,
172            prev_ticks: 0,
173        }
174    }
175
176    /// Rebase the scan timeline to a fresh `master_ticks == 0` (a warm reset),
177    /// clearing the accumulated position and the delta baseline **without**
178    /// touching the programmed registers. Without this, `prev_ticks` would keep
179    /// the pre-reset value and every post-reset delta would saturate to 0 until
180    /// the new run caught up — suppressing the VI interrupt across a reset.
181    pub const fn reset_scan(&mut self) {
182        self.v_current = 0;
183        self.acc = 0;
184        self.prev_ticks = 0;
185    }
186
187    /// Total scan half-lines per field (`VI_V_TOTAL + 1`), 1..=1024.
188    const fn total_halflines(&self) -> u32 {
189        (self.regs[VI_V_TOTAL as usize] & 0x3FF) + 1
190    }
191
192    /// The nominal field rate for the programmed field length: PAL 50 Hz when the
193    /// field is PAL-length (`VI_V_TOTAL > 550`), else NTSC 60 Hz (R-6). Matches the
194    /// scan-out geometry's `ispal` test (`bus::scanout_scaled`) so cadence and
195    /// geometry agree on the region.
196    const fn field_hz(&self) -> u64 {
197        if (self.regs[VI_V_TOTAL as usize] & 0x3FF) > VI_PAL_V_TOTAL_THRESHOLD {
198            VI_FIELD_HZ_PAL
199        } else {
200            VI_FIELD_HZ
201        }
202    }
203
204    /// Master ticks per scan half-line, from the field rate ([`Vi::field_hz`]) and the
205    /// programmed `VI_V_TOTAL`. One division (not two) to avoid compounding the
206    /// truncation. Zero-guarded by `total_halflines() >= 1`.
207    fn ticks_per_halfline(&self) -> u64 {
208        crate::MASTER_HZ / (self.field_hz() * u64::from(self.total_halflines()))
209    }
210
211    /// Advance the scan position by the master ticks elapsed since the last call
212    /// and report whether the VI interrupt should fire.
213    ///
214    /// `VI_V_CURRENT` advances one half-line every `ticks_per_halfline` master
215    /// ticks (accumulating the fractional remainder), wrapping at `VI_V_TOTAL +
216    /// 1`. The interrupt fires when the position **lands on** `VI_V_INTR` — the
217    /// per-half-line step means no crossing is skipped even when a call spans
218    /// many half-lines — and only while the VI is on (`VI_CTRL.TYPE != 0`;
219    /// N64brew *Video Interface* §`VI_V_INTR`). A `VI_V_INTR` beyond the field
220    /// (`>= VI_V_TOTAL + 1`) is unreachable, so it never fires. The position is
221    /// kept relative, so a mid-run `VI_V_TOTAL` change re-bases without a scale
222    /// jump. The scheduler calls this each RCP step and raises `MI_INTR.vi` on a
223    /// `true` return.
224    pub fn tick(&mut self, master_ticks: u64) -> bool {
225        let delta = master_ticks.saturating_sub(self.prev_ticks);
226        self.prev_ticks = master_ticks;
227        // Below the smallest period any legal programming can produce, the `while`
228        // cannot execute whatever the registers hold, so the division that would
229        // produce the exact period is skipped. Exact, not approximate — and it is
230        // most of the work here, since a half-line elapses on about one call in
231        // 1,980 (`docs/performance.md`).
232        let acc = self.acc + delta;
233        if acc < VI_MIN_TICKS_PER_HALFLINE {
234            self.acc = acc;
235            return false;
236        }
237        let per_hl = self.ticks_per_halfline();
238        // Unreachable: `total_halflines()` is `(VI_V_TOTAL & 0x3FF) + 1`, so it is at
239        // least 1, and `field_hz()` is 50 or 60 — the divisor is therefore at most
240        // 61,440 against a 187.5 MHz numerator and the quotient cannot be zero.
241        //
242        // What it actually backstops is the `while` below, NOT a division: the divide
243        // has already happened by the time this runs, inside `ticks_per_halfline`. A
244        // zero period would make `self.acc >= per_hl` permanently true while
245        // `self.acc -= per_hl` made no progress — an infinite loop, not a trap. Two
246        // earlier comments here got this wrong in two different ways: the original
247        // called it "no timing until `VI_V_TOTAL` is programmed" (an unprogrammed
248        // `VI_V_TOTAL` is one half-line, a 3,750,000-tick period, never zero), and the
249        // first version of this rewrite called it a divide-by-zero guard, which it
250        // cannot be from this position. A review caught the second.
251        //
252        // It returns rather than `unreachable!()` deliberately. This runs inside the
253        // emulation core on every RCP step, and what it guards against would be
254        // produced by *emulated* register state; degrading to "no scan advance this
255        // step" keeps a malformed guest program from aborting the process, which a
256        // panic here would do in release. `debug_assert` gives the developer-facing
257        // signal without putting that risk in a shipped build.
258        debug_assert!(per_hl > 0, "the VI period is bounded below by construction");
259        if per_hl == 0 {
260            return false;
261        }
262        // Committed here, not beside the `let` above, so that the `per_hl == 0` path
263        // leaves `acc` exactly as the pre-split code did. That branch is unreachable,
264        // so hoisting the assignment would be *observably* identical — but only by
265        // way of the unreachability argument. Duplicating one store keeps this change
266        // behavior-identical without depending on that, which is the cheaper thing to
267        // be sure of.
268        self.acc = acc;
269        let halflines = self.total_halflines();
270        let v_intr = self.regs[VI_V_INTR as usize] & 0x3FF;
271        let on = self.regs[VI_CTRL as usize] & 0x3 != 0;
272        let mut fired = false;
273        while self.acc >= per_hl {
274            self.acc -= per_hl;
275            self.v_current = (self.v_current + 1) % halflines;
276            if on && self.v_current == v_intr {
277                fired = true;
278            }
279        }
280        fired
281    }
282
283    /// Read a VI register by word offset within the block (mirrored to 16).
284    /// `VI_V_CURRENT` reads back the scan position advanced by [`Vi::tick`].
285    #[must_use]
286    pub const fn read(&self, word_offset: u32) -> u32 {
287        let idx = (word_offset & 0xF) as usize;
288        if idx == VI_V_CURRENT as usize {
289            return self.v_current;
290        }
291        self.regs[idx]
292    }
293
294    /// Write a VI register by word offset. Returns `true` iff this write should
295    /// **acknowledge the VI interrupt** — a write to `VI_V_CURRENT`, which the
296    /// caller turns into `MI_INTR.vi = false`.
297    ///
298    /// `VI_V_CURRENT` is not otherwise latched here: its value reflects the scan
299    /// position, which the scheduler will drive; a software write only clears
300    /// the interrupt.
301    pub const fn write(&mut self, word_offset: u32, value: u32) -> bool {
302        let idx = (word_offset & 0xF) as usize;
303        if idx == VI_V_CURRENT as usize {
304            return true;
305        }
306        self.regs[idx] = value;
307        false
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    /// **The skip bound is a true lower bound across the whole programmable space.**
316    ///
317    /// `Vi::tick` returns before dividing when the accumulator is below
318    /// [`VI_MIN_TICKS_PER_HALFLINE`]. That is only sound if no legal
319    /// `(VI_V_TOTAL, field_hz)` pair can produce a *shorter* period — otherwise the
320    /// fast path swallows a half-line, `VI_V_CURRENT` runs slow, and the VI
321    /// interrupt arrives late, with nothing to indicate why.
322    ///
323    /// So this checks the bound against every encodable `VI_V_TOTAL` rather than
324    /// against the two the retail regions happen to use, walking all 1,024 values.
325    /// Mutation-checked: halving `VI_MAX_HALFLINES` (which doubles the bound) turns
326    /// this red.
327    #[test]
328    fn no_legal_programming_produces_a_shorter_halfline_than_the_skip_bound() {
329        let mut vi = Vi::new();
330        for encoded in 0..=0x3FFu32 {
331            vi.regs[VI_V_TOTAL as usize] = encoded;
332            let per_hl = vi.ticks_per_halfline();
333            assert!(
334                per_hl >= VI_MIN_TICKS_PER_HALFLINE,
335                "VI_V_TOTAL={encoded} gives a {per_hl}-tick half-line, below the \
336                 {VI_MIN_TICKS_PER_HALFLINE}-tick skip bound: tick() would skip it"
337            );
338            assert!(per_hl > 0, "VI_V_TOTAL={encoded} divides to a zero period");
339        }
340    }
341
342    /// **Ticking one master tick at a time lands on exactly the same half-line as
343    /// one big jump.** The skip must not lose an accumulated remainder.
344    ///
345    /// One call per master tick is **finer than the scheduler's own cadence**, which
346    /// is one RCP step every three (`RCP_DIVIDER`, ADR 0006) — deliberately so. This
347    /// is an equivalence oracle, not a reproduction of the real invocation pattern:
348    /// stepping in the smallest possible increment maximizes the number of times the
349    /// early-out runs between crossings, so a remainder the fast path loses shows up
350    /// as a different `VI_V_CURRENT`. The single jump takes the early-out never, so
351    /// the two paths agreeing is the property under test.
352    #[test]
353    fn stepping_one_tick_at_a_time_matches_one_jump() {
354        let mut stepped = Vi::new();
355        let mut jumped = Vi::new();
356        for vi in [&mut stepped, &mut jumped] {
357            vi.regs[VI_V_TOTAL as usize] = 524; // NTSC, 525 half-lines
358            vi.regs[VI_CTRL as usize] = 0x3; // VI on
359            vi.regs[VI_V_INTR as usize] = 2;
360        }
361        // Three half-lines' worth, so the early-out runs thousands of times.
362        let target = stepped.ticks_per_halfline() * 3 + 17;
363
364        let mut stepped_fired = false;
365        for now in 1..=target {
366            // Bound first: `tick` must run on *every* iteration, since advancing the
367            // scan is the point. Folding the call into `stepped_fired |= …` would
368            // still do that — `|=` on `bool` does not short-circuit — but it reads
369            // as if it might, and the test is worthless if any tick is skipped.
370            let fired = stepped.tick(now);
371            stepped_fired |= fired;
372        }
373        let jumped_fired = jumped.tick(target);
374
375        assert_eq!(
376            stepped.v_current, jumped.v_current,
377            "the per-tick cadence must land on the same half-line as one jump"
378        );
379        assert_eq!(stepped.acc, jumped.acc, "and carry the same remainder");
380        assert!(
381            stepped_fired,
382            "V_INTR == 2 is crossed within three half-lines"
383        );
384        assert_eq!(
385            stepped_fired, jumped_fired,
386            "and both paths must agree that it fired"
387        );
388    }
389
390    #[test]
391    fn power_on_is_all_zero_so_the_vi_is_off() {
392        let vi = Vi::new();
393        assert_eq!(vi.read(VI_CTRL) & 0x3, 0, "TYPE == 0: VI off at cold boot");
394        assert!(vi.regs.iter().all(|&r| r == 0));
395    }
396
397    #[test]
398    fn ordinary_registers_round_trip() {
399        let mut vi = Vi::new();
400        for (off, val) in [
401            (VI_ORIGIN, 0x0010_0000),
402            (VI_WIDTH, 320),
403            (VI_V_INTR, 2),
404            (VI_X_SCALE, 0x0000_0200),
405        ] {
406            assert!(!vi.write(off, val), "ordinary write does not ack the IRQ");
407            assert_eq!(vi.read(off), val);
408        }
409    }
410
411    #[test]
412    fn writing_v_current_signals_an_interrupt_ack_and_does_not_latch() {
413        let mut vi = Vi::new();
414        assert!(
415            vi.write(VI_V_CURRENT, 0x1234),
416            "a VI_V_CURRENT write acknowledges the interrupt"
417        );
418        assert_eq!(
419            vi.read(VI_V_CURRENT),
420            0,
421            "the written value is not latched into V_CURRENT"
422        );
423        assert_eq!(
424            vi.regs[VI_V_CURRENT as usize], 0,
425            "and nothing reached the backing storage either"
426        );
427    }
428
429    /// **`VI_V_CURRENT` advances with `master_ticks` and wraps at the field.**
430    /// With `V_TOTAL + 1 = 525` half-lines, one half-line is
431    /// `MASTER_HZ / 60 / 525` master ticks; the read-back tracks it and wraps to
432    /// 0 at the field boundary.
433    #[test]
434    fn v_current_advances_with_master_ticks_and_wraps() {
435        let mut vi = Vi::new();
436        vi.regs[VI_V_TOTAL as usize] = 524; // 525 half-lines
437        let per_hl = crate::MASTER_HZ / (VI_FIELD_HZ * 525);
438        vi.tick(0);
439        assert_eq!(vi.read(VI_V_CURRENT), 0);
440        vi.tick(per_hl);
441        assert_eq!(vi.read(VI_V_CURRENT), 1, "one half-line later");
442        vi.tick(per_hl * 524);
443        assert_eq!(vi.read(VI_V_CURRENT), 524, "last half-line of the field");
444        vi.tick(per_hl * 525);
445        assert_eq!(vi.read(VI_V_CURRENT), 0, "wraps to 0 at the field boundary");
446    }
447
448    /// **The VI interrupt fires once per field as `VI_V_INTR` is crossed.**
449    /// It does not re-fire within the same field, and re-fires the next field.
450    #[test]
451    fn the_vi_interrupt_fires_once_per_field_at_v_intr() {
452        let mut vi = Vi::new();
453        vi.regs[VI_V_TOTAL as usize] = 524;
454        vi.regs[VI_V_INTR as usize] = 2;
455        vi.regs[VI_CTRL as usize] = 2; // 16-bit type, VI on
456        let per_hl = crate::MASTER_HZ / (VI_FIELD_HZ * 525);
457        assert!(!vi.tick(0), "before V_INTR: no interrupt");
458        assert!(vi.tick(per_hl * 2), "crossing half-line 2 fires");
459        assert!(!vi.tick(per_hl * 3), "already fired this field");
460        assert!(vi.tick(per_hl * (525 + 2)), "the next field fires again");
461    }
462
463    /// **A disabled VI (`TYPE == 0`) never interrupts**, even past `VI_V_INTR`.
464    #[test]
465    fn a_disabled_vi_never_interrupts() {
466        let mut vi = Vi::new();
467        vi.regs[VI_V_TOTAL as usize] = 524;
468        vi.regs[VI_V_INTR as usize] = 2;
469        vi.regs[VI_CTRL as usize] = 0; // VI off
470        let per_hl = crate::MASTER_HZ / (VI_FIELD_HZ * 525);
471        assert!(!vi.tick(0));
472        assert!(!vi.tick(per_hl * 3), "off: no interrupt even past V_INTR");
473    }
474
475    /// **`VI_V_INTR` beyond the field never fires.** `VI_V_CURRENT` wraps at
476    /// `VI_V_TOTAL + 1`, so an interrupt line the scan can never reach is inert —
477    /// no spurious `v_intr % halflines` phantom.
478    #[test]
479    fn a_v_intr_past_the_field_never_fires() {
480        let mut vi = Vi::new();
481        vi.regs[VI_V_TOTAL as usize] = 262; // 263 half-lines
482        vi.regs[VI_V_INTR as usize] = 300; // > 263: unreachable
483        vi.regs[VI_CTRL as usize] = 2;
484        let per_hl = crate::MASTER_HZ / (VI_FIELD_HZ * 263);
485        // Run several full fields; the interrupt must never fire.
486        for k in 1..=(263 * 3) {
487            assert!(!vi.tick(per_hl * k), "unreachable V_INTR never fires");
488        }
489    }
490
491    /// **A mid-run `VI_V_TOTAL` change re-bases cleanly** — because the position
492    /// is relative, changing the field length does not scale-jump the counter or
493    /// spuriously fire; the scan just continues and wraps at the new length.
494    #[test]
495    fn a_mid_run_v_total_change_rebases_without_a_spurious_interrupt() {
496        let mut vi = Vi::new();
497        vi.regs[VI_V_TOTAL as usize] = 524; // 525 half-lines
498        vi.regs[VI_V_INTR as usize] = 600; // unreachable in either config
499        vi.regs[VI_CTRL as usize] = 2;
500        let per_hl = crate::MASTER_HZ / (VI_FIELD_HZ * 525);
501        assert!(!vi.tick(per_hl * 100)); // advance exactly 100 half-lines
502        let mid = vi.read(VI_V_CURRENT);
503        assert_eq!(mid, 100, "100 half-lines into the 525-line field");
504        // Shrink the field; VI_V_INTR (600) is unreachable in both, so no fire.
505        vi.regs[VI_V_TOTAL as usize] = 262; // now 263 half-lines
506        let per_hl2 = crate::MASTER_HZ / (VI_FIELD_HZ * 263);
507        assert!(
508            !vi.tick(per_hl * 100 + per_hl2 * 10),
509            "no spurious fire on rebase"
510        );
511        // Relative: 10 more half-lines from 100, wrapped at the *new* 263 — an
512        // absolute-time implementation would scale-jump to 60 here instead.
513        assert_eq!(
514            vi.read(VI_V_CURRENT),
515            (mid + 10) % 263,
516            "position continues relative across the field-length change"
517        );
518    }
519
520    /// **A PAL-length field scans at 50 Hz, not 60 (R-6).** With `V_TOTAL + 1 = 625`
521    /// half-lines (PAL, `> 550`), one half-line is `MASTER_HZ / 50 / 625 = 6000`
522    /// master ticks — distinct from the `MASTER_HZ / 60 / 625 = 5000` an NTSC-rate
523    /// field would give, so this is non-vacuous: at `pal_per_hl` ticks the counter
524    /// has advanced exactly one half-line, while at the 60 Hz period it would already
525    /// be past it. Also confirms an NTSC-length field is unaffected.
526    #[test]
527    fn a_pal_length_field_scans_at_50hz() {
528        let mut vi = Vi::new();
529        vi.regs[VI_V_TOTAL as usize] = 624; // 625 half-lines → PAL (> 550)
530        let pal_per_hl = crate::MASTER_HZ / (VI_FIELD_HZ_PAL * 625);
531        let ntsc_per_hl = crate::MASTER_HZ / (VI_FIELD_HZ * 625);
532        assert_eq!(pal_per_hl, 6000);
533        assert_eq!(ntsc_per_hl, 5000);
534        vi.tick(0);
535        assert_eq!(vi.read(VI_V_CURRENT), 0);
536        // One PAL half-line period advances exactly one half-line.
537        vi.tick(pal_per_hl);
538        assert_eq!(
539            vi.read(VI_V_CURRENT),
540            1,
541            "advances at the 50 Hz PAL cadence"
542        );
543        // A fresh VI clocked for the same wall-ticks at the (wrong) 60 Hz period would
544        // already be on half-line 1 well before `pal_per_hl`; assert the PAL VI is
545        // still on 0 at the 60 Hz period, proving it is genuinely slower.
546        let mut vi2 = Vi::new();
547        vi2.regs[VI_V_TOTAL as usize] = 624;
548        vi2.tick(0);
549        vi2.tick(ntsc_per_hl);
550        assert_eq!(
551            vi2.read(VI_V_CURRENT),
552            0,
553            "at the 60 Hz period the PAL field has not yet crossed a half-line"
554        );
555
556        // An NTSC-length field is unaffected — still 60 Hz.
557        let mut ntsc = Vi::new();
558        ntsc.regs[VI_V_TOTAL as usize] = 524; // 525 half-lines → NTSC
559        let ntsc525 = crate::MASTER_HZ / (VI_FIELD_HZ * 525);
560        ntsc.tick(0);
561        ntsc.tick(ntsc525);
562        assert_eq!(ntsc.read(VI_V_CURRENT), 1, "NTSC field still 60 Hz");
563    }
564
565    /// **The PAL/NTSC split is exactly `VI_V_TOTAL > 550`.** Pins the boundary so an
566    /// off-by-one (`>=` vs `>`, or a shifted threshold) is caught: a field with
567    /// `VI_V_TOTAL == 550` is still NTSC (60 Hz), and `== 551` is already PAL (50 Hz).
568    #[test]
569    fn the_pal_threshold_is_exactly_550() {
570        let mut vi = Vi::new();
571        vi.regs[VI_V_TOTAL as usize] = VI_PAL_V_TOTAL_THRESHOLD; // 550: still NTSC
572        assert_eq!(vi.field_hz(), VI_FIELD_HZ, "V_TOTAL == 550 is NTSC");
573        vi.regs[VI_V_TOTAL as usize] = VI_PAL_V_TOTAL_THRESHOLD + 1; // 551: PAL
574        assert_eq!(vi.field_hz(), VI_FIELD_HZ_PAL, "V_TOTAL == 551 is PAL");
575    }
576}