Skip to main content

game_toolkit_dosvideo/
crt.rs

1//! The CRT: raster timing, the electron beam, and the "phosphor" image it paints.
2//!
3//! Scanout is *lazy*: the beam position is a pure function of the machine clock, and the image
4//! is only painted when something observable happens (a VRAM write, a register write, the host
5//! asking for a frame). [`Crt::advance`] then paints every dot between the last painted position
6//! and the current beam position using the card's *current* state. That is exactly what makes
7//! racing the beam work: a palette write half-way through a line changes the rest of that line
8//! and nothing before it, and a VRAM write just behind the beam only shows up next frame.
9
10/// Raster timing of the current video mode, in dots (pixel clock ticks) and scanlines.
11///
12/// Coordinates are relative to the first active dot of the first active line. A line runs
13/// `0..h_total`: active display `0..h_active`, right border up to `h_blank_start`, blanking
14/// (with horizontal retrace inside it) up to `h_blank_end`, and the *next* line's left border up
15/// to `h_total`. Vertically the same: active `0..v_active`, bottom border up to
16/// `v_blank_start`, blanking (with vertical retrace) up to `v_blank_end`, then the top border of
17/// the next frame up to `v_total`.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct Timing {
20    /// Dot clock in Hz (25.175 MHz for most VGA modes, 14.318 MHz for CGA, ...).
21    pub dot_clock_hz: f64,
22    pub h_total: u32,
23    pub h_active: u32,
24    pub h_blank_start: u32,
25    pub h_blank_end: u32,
26    pub h_retrace_start: u32,
27    pub h_retrace_end: u32,
28    pub v_total: u32,
29    pub v_active: u32,
30    pub v_blank_start: u32,
31    pub v_blank_end: u32,
32    pub v_retrace_start: u32,
33    pub v_retrace_end: u32,
34}
35
36impl Timing {
37    /// Clamp the values into a consistent order (registers can be programmed with nonsense).
38    pub fn normalized(mut self) -> Self {
39        self.h_total = self.h_total.max(8);
40        self.h_active = self.h_active.clamp(1, self.h_total);
41        self.h_blank_start = self.h_blank_start.clamp(self.h_active, self.h_total);
42        self.h_blank_end = self.h_blank_end.clamp(self.h_blank_start, self.h_total);
43        self.h_retrace_start = self.h_retrace_start.min(self.h_total);
44        self.h_retrace_end = self.h_retrace_end.clamp(self.h_retrace_start, self.h_total);
45        self.v_total = self.v_total.max(2);
46        self.v_active = self.v_active.clamp(1, self.v_total);
47        self.v_blank_start = self.v_blank_start.clamp(self.v_active, self.v_total);
48        self.v_blank_end = self.v_blank_end.clamp(self.v_blank_start, self.v_total);
49        self.v_retrace_start = self.v_retrace_start.min(self.v_total);
50        self.v_retrace_end = self.v_retrace_end.clamp(self.v_retrace_start, self.v_total);
51        if self.dot_clock_hz.is_nan() || self.dot_clock_hz <= 1.0 {
52            self.dot_clock_hz = 1.0;
53        }
54        self
55    }
56
57    /// Horizontal scan rate in Hz.
58    pub fn line_rate_hz(&self) -> f64 {
59        self.dot_clock_hz / self.h_total as f64
60    }
61
62    /// Vertical refresh rate in Hz.
63    pub fn refresh_hz(&self) -> f64 {
64        self.line_rate_hz() / self.v_total as f64
65    }
66
67    /// Duration of one scanline in nanoseconds.
68    pub fn line_ns(&self) -> f64 {
69        1e9 / self.line_rate_hz()
70    }
71
72    /// Duration of one frame in nanoseconds.
73    pub fn frame_ns(&self) -> f64 {
74        1e9 / self.refresh_hz()
75    }
76
77    pub(crate) fn frame_dots(&self) -> u64 {
78        self.h_total as u64 * self.v_total as u64
79    }
80
81    fn left_border(&self) -> u32 {
82        self.h_total - self.h_blank_end
83    }
84
85    fn top_border(&self) -> u32 {
86        self.v_total - self.v_blank_end
87    }
88
89    /// Size of the visible picture (active area plus overscan border) in dots x lines.
90    pub fn visible_size(&self) -> (u32, u32) {
91        (
92            self.left_border() + self.h_blank_start,
93            self.top_border() + self.v_blank_start,
94        )
95    }
96}
97
98/// Where the beam is at some instant.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub struct Beam {
101    /// Frames started since the mode was set.
102    pub frame: u64,
103    /// Scanline, `0` = first active line.
104    pub line: u32,
105    /// Dot within the line, `0` = first active dot.
106    pub dot: u32,
107    /// Inside the active display area (not border, not blanking).
108    pub display_active: bool,
109    pub h_retrace: bool,
110    pub v_retrace: bool,
111    pub h_blank: bool,
112    pub v_blank: bool,
113}
114
115/// The hooks a video card implements so the CRT can paint it.
116pub(crate) trait Scanout {
117    /// Called at dot 0 of line 0, before [`Scanout::line_start`] for that line.
118    fn frame_start(&mut self) {}
119    /// Called at dot 0 of every line (`0..v_total`).
120    fn line_start(&mut self, _line: u32) {}
121    /// Called at the start of the line on which vertical retrace begins.
122    fn vretrace_start(&mut self) {}
123    /// Paint active-display dots `x0 .. x0 + out.len()` of `line`.
124    fn render_active(&mut self, line: u32, x0: u32, out: &mut [[u8; 4]]);
125    /// Current overscan (border) color.
126    fn border(&self) -> [u8; 4];
127}
128
129/// One finished frame, as RGBA8 in visible-area dots.
130#[derive(Clone, Copy, Debug)]
131pub struct FrameView<'a> {
132    /// Width of the visible area (active + border) in dots.
133    pub width: u32,
134    pub height: u32,
135    /// RGBA8 rows, `width * 4` bytes each.
136    pub rgba: &'a [u8],
137    /// Active display rectangle inside the image: `[x, y, w, h]`.
138    pub active: [u32; 4],
139    /// Number of the frame (counts completed frames since power-on).
140    pub number: u64,
141}
142
143pub(crate) struct Crt {
144    timing: Timing,
145    dots_per_ns: f64,
146    /// Machine time (ns) of global dot 0.
147    epoch_ns: f64,
148    /// Next global dot to paint.
149    painted: u64,
150    back: Vec<[u8; 4]>,
151    front: Vec<[u8; 4]>,
152    width: u32,
153    height: u32,
154    frames: u64,
155}
156
157impl Crt {
158    pub fn new(timing: Timing, now_ns: f64) -> Self {
159        let timing = timing.normalized();
160        let (width, height) = timing.visible_size();
161        let n = (width * height) as usize;
162        Self {
163            dots_per_ns: timing.dot_clock_hz / 1e9,
164            timing,
165            epoch_ns: now_ns,
166            painted: 0,
167            back: vec![[0, 0, 0, 255]; n],
168            front: vec![[0, 0, 0, 255]; n],
169            width,
170            height,
171            frames: 0,
172        }
173    }
174
175    pub fn timing(&self) -> &Timing {
176        &self.timing
177    }
178
179    /// Global dot index at machine time `now_ns`.
180    pub fn dot_at(&self, now_ns: f64) -> u64 {
181        let d = (now_ns - self.epoch_ns) * self.dots_per_ns;
182        if d <= 0.0 { 0 } else { d as u64 }
183    }
184
185    /// Machine time of global dot `dot`.
186    pub fn time_of(&self, dot: u64) -> f64 {
187        self.epoch_ns + dot as f64 / self.dots_per_ns
188    }
189
190    pub fn beam_at(&self, now_ns: f64) -> Beam {
191        self.beam_of(self.dot_at(now_ns))
192    }
193
194    pub fn beam_of(&self, g: u64) -> Beam {
195        let t = &self.timing;
196        let fd = t.frame_dots();
197        let in_frame = g % fd;
198        let line = (in_frame / t.h_total as u64) as u32;
199        let dot = (in_frame % t.h_total as u64) as u32;
200        Beam {
201            frame: g / fd,
202            line,
203            dot,
204            display_active: line < t.v_active && dot < t.h_active,
205            h_retrace: dot >= t.h_retrace_start && dot < t.h_retrace_end,
206            v_retrace: line >= t.v_retrace_start && line < t.v_retrace_end,
207            h_blank: dot >= t.h_blank_start && dot < t.h_blank_end,
208            v_blank: line >= t.v_blank_start && line < t.v_blank_end,
209        }
210    }
211
212    /// Global dot of the next time the beam reaches `(line, dot)` at or after global dot `from`.
213    pub fn next_dot(&self, from: u64, line: u32, dot: u32) -> u64 {
214        let t = &self.timing;
215        let fd = t.frame_dots();
216        let target =
217            line.min(t.v_total - 1) as u64 * t.h_total as u64 + dot.min(t.h_total - 1) as u64;
218        let base = from - from % fd;
219        let cand = base + target;
220        if cand >= from { cand } else { cand + fd }
221    }
222
223    /// Switch to new timing without losing the beam position (clamped into the new raster).
224    pub fn retime<C: Scanout>(&mut self, card: &mut C, timing: Timing, now_ns: f64) {
225        let timing = timing.normalized();
226        if timing == self.timing {
227            return;
228        }
229        self.advance(card, self.dot_at(now_ns));
230        let b = self.beam_at(now_ns);
231        let line = b.line.min(timing.v_total - 1);
232        let dot = b.dot.min(timing.h_total - 1);
233        self.timing = timing;
234        self.dots_per_ns = timing.dot_clock_hz / 1e9;
235        let g = line as u64 * timing.h_total as u64 + dot as u64;
236        self.epoch_ns = now_ns - g as f64 / self.dots_per_ns;
237        self.painted = g;
238        let (w, h) = timing.visible_size();
239        if (w, h) != (self.width, self.height) {
240            self.width = w;
241            self.height = h;
242            let n = (w * h) as usize;
243            self.back = vec![[0, 0, 0, 255]; n];
244            self.front = vec![[0, 0, 0, 255]; n];
245        }
246    }
247
248    /// Restart the raster at line 0 (mode set).
249    pub fn reset<C: Scanout>(&mut self, card: &mut C, timing: Timing, now_ns: f64) {
250        let frames = self.frames;
251        *self = Crt::new(timing, now_ns);
252        self.frames = frames;
253        let _ = card;
254    }
255
256    pub fn frame(&self) -> FrameView<'_> {
257        let t = &self.timing;
258        FrameView {
259            width: self.width,
260            height: self.height,
261            rgba: self.front.as_flattened(),
262            active: [t.left_border(), t.top_border(), t.h_active, t.v_active],
263            number: self.frames,
264        }
265    }
266
267    /// The image as painted so far, including the half-finished current frame.
268    pub fn live(&self) -> FrameView<'_> {
269        let mut f = self.frame();
270        f.rgba = self.back.as_flattened();
271        f
272    }
273
274    pub fn frames_completed(&self) -> u64 {
275        self.frames
276    }
277
278    /// Paint everything from the last painted dot up to (not including) global dot `target`.
279    pub fn advance<C: Scanout>(&mut self, card: &mut C, target: u64) {
280        if target <= self.painted {
281            return;
282        }
283        let t = self.timing;
284        let fd = t.frame_dots();
285        // Far behind (the program idled for a while): only the last two frames can be seen.
286        if target - self.painted > 2 * fd {
287            let start = (target / fd).saturating_sub(1) * fd;
288            if start > self.painted {
289                self.painted = start;
290            }
291        }
292        let ht = t.h_total as u64;
293        while self.painted < target {
294            let in_frame = self.painted % fd;
295            let line = (in_frame / ht) as u32;
296            let x = (in_frame % ht) as u32;
297            if x == 0 {
298                if line == 0 {
299                    card.frame_start();
300                }
301                card.line_start(line);
302                if line == t.v_retrace_start {
303                    card.vretrace_start();
304                }
305                if line == t.v_blank_start {
306                    std::mem::swap(&mut self.back, &mut self.front);
307                    self.frames += 1;
308                }
309            }
310            let x1 = (x as u64 + (target - self.painted)).min(ht) as u32;
311            self.paint_segment(card, line, x, x1);
312            self.painted += (x1 - x) as u64;
313        }
314    }
315
316    fn row_of_line(&self, line: u32) -> Option<u32> {
317        let t = &self.timing;
318        if line < t.v_blank_start {
319            Some(t.top_border() + line)
320        } else if line >= t.v_blank_end {
321            Some(line - t.v_blank_end)
322        } else {
323            None
324        }
325    }
326
327    fn paint_segment<C: Scanout>(&mut self, card: &mut C, line: u32, x0: u32, x1: u32) {
328        let t = self.timing;
329        let w = self.width as usize;
330        let lb = t.left_border();
331        // Active display + right border, on this line's row.
332        if x0 < t.h_blank_start
333            && let Some(row) = self.row_of_line(line)
334        {
335            let row_off = row as usize * w + lb as usize;
336            let a0 = x0;
337            let a1 = x1.min(t.h_blank_start);
338            if line < t.v_active {
339                let e = a1.min(t.h_active);
340                if a0 < e {
341                    let out = &mut self.back[row_off + a0 as usize..row_off + e as usize];
342                    card.render_active(line, a0, out);
343                }
344                let b0 = a0.max(t.h_active);
345                if b0 < a1 {
346                    let c = card.border();
347                    self.back[row_off + b0 as usize..row_off + a1 as usize].fill(c);
348                }
349            } else if a0 < a1 {
350                let c = card.border();
351                self.back[row_off + a0 as usize..row_off + a1 as usize].fill(c);
352            }
353        }
354        // Left border of the next line.
355        if x1 > t.h_blank_end {
356            let next = (line + 1) % t.v_total;
357            if let Some(row) = self.row_of_line(next) {
358                let s = x0.max(t.h_blank_end) - t.h_blank_end;
359                let e = x1 - t.h_blank_end;
360                let off = row as usize * w;
361                let c = card.border();
362                self.back[off + s as usize..off + e as usize].fill(c);
363            }
364        }
365    }
366}