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}