Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_gpu/
display.rs

1//! What the GPU shows: video standard, resolution, and where the picture
2//! sits in the TV signal.
3//!
4//! A [`DisplayConfig`] is a plain value; [`crate::Gpu::new`] programs it at
5//! reset and [`crate::Gpu::set_display`] reprograms it, so a "screen
6//! position" option changes one field and calls one method instead of
7//! repeating the arguments `init` was given.
8
9use psx_hw::gpu::gp1;
10
11/// Video standard.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum VideoMode {
14    /// 60 Hz NTSC.
15    Ntsc,
16    /// 50 Hz PAL.
17    Pal,
18}
19
20/// A display resolution the GPU can produce.
21///
22/// Only the presets exist, so an invalid width or a 480-line mode without
23/// interlacing can't be asked for.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub struct Resolution {
26    width: u16,
27    height: u16,
28}
29
30impl Resolution {
31    /// 320×240, the default for most PS1 games.
32    pub const R320X240: Self = Self::new(320, 240);
33    /// 256×240.
34    pub const R256X240: Self = Self::new(256, 240);
35    /// 512×240.
36    pub const R512X240: Self = Self::new(512, 240);
37    /// 640×240.
38    pub const R640X240: Self = Self::new(640, 240);
39    /// 320×256, PAL's natural vertical resolution.
40    pub const R320X256: Self = Self::new(320, 256);
41    /// 640×480, interlaced.
42    pub const R640X480: Self = Self::new(640, 480);
43
44    const fn new(width: u16, height: u16) -> Self {
45        Self { width, height }
46    }
47
48    /// Width in pixels.
49    pub const fn width(self) -> u16 {
50        self.width
51    }
52
53    /// Height in pixels.
54    pub const fn height(self) -> u16 {
55        self.height
56    }
57
58    /// True for the 480-line modes, which the GPU only shows interlaced.
59    pub const fn is_interlaced(self) -> bool {
60        self.height >= 480
61    }
62
63    /// Scanlines per field: the height, or half of it when interlaced.
64    /// GP1(07h) counts scanlines of one field, so a 480-line picture spans
65    /// 240 of them (psx-spx, GP1(07h)).
66    pub(crate) const fn field_lines(self) -> u32 {
67        if self.is_interlaced() {
68            self.height as u32 / 2
69        } else {
70            self.height as u32
71        }
72    }
73
74    /// GPU clocks per pixel in this width's dot clock: the GPU clock
75    /// divided by 10, 8, 5 or 4 for 256, 320, 512 or 640 pixels (psx-spx,
76    /// GPU Timings). Every width therefore spans the same 2,560 clocks of
77    /// GP1(06h) range, as psx-spx's table of standard X1/X2 values shows.
78    pub(crate) const fn clocks_per_pixel(self) -> i32 {
79        match self.width {
80            256 => 10,
81            320 => 8,
82            512 => 5,
83            _ => 4,
84        }
85    }
86
87    /// The GP1(08h) horizontal-resolution field.
88    const fn horizontal_field(self) -> u32 {
89        match self.width {
90            256 => 0,
91            320 => 1,
92            512 => 2,
93            _ => 3,
94        }
95    }
96}
97
98/// Default left edge (GP1 06h X1) of the horizontal display window, in GPU
99/// clocks from start-of-line: the standard centred NTSC picture.
100const H_DISPLAY_WINDOW_START: i32 = 0x260;
101/// Default top edge (GP1 07h Y1) of the NTSC vertical display window.
102const NTSC_V_DISPLAY_WINDOW_START: i32 = 0x10;
103/// Default top edge (GP1 07h Y1) of the PAL vertical display window.
104const PAL_V_DISPLAY_WINDOW_START: i32 = 0x23;
105
106/// Video standard, resolution and picture position, as one value.
107///
108/// ```
109/// use psx_gpu::display::{DisplayConfig, Resolution, VideoMode};
110/// let centred = DisplayConfig::new(VideoMode::Ntsc, Resolution::R320X240);
111/// let nudged = centred.with_offset((4, -2));
112/// assert_eq!(nudged.offset, (4, -2));
113/// ```
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
115pub struct DisplayConfig {
116    /// Video standard.
117    pub mode: VideoMode,
118    /// Resolution.
119    pub resolution: Resolution,
120    /// Picture shift from the standard position: pixels right, scanlines
121    /// down. It moves the picture in the TV signal (GP1(06h) and GP1(07h)),
122    /// the way period games recentred an image inside a CRT's overscan;
123    /// VRAM and the draw offset are untouched. The window never starts
124    /// before the blanking edge.
125    pub offset: (i16, i16),
126}
127
128impl DisplayConfig {
129    /// The standard, centred picture for `mode` and `resolution`.
130    pub const fn new(mode: VideoMode, resolution: Resolution) -> Self {
131        Self {
132            mode,
133            resolution,
134            offset: (0, 0),
135        }
136    }
137
138    /// The same display with the picture shifted by `offset`.
139    pub const fn with_offset(self, offset: (i16, i16)) -> Self {
140        Self { offset, ..self }
141    }
142
143    /// GP1(08h): resolution, standard, colour depth, interlace.
144    ///
145    /// psx-spx, GP1(08h): bit 2 selects 480 lines only "when Bit5=1", so a
146    /// 480-line resolution sets the interlace bit as well.
147    pub(crate) const fn mode_command(self) -> u32 {
148        let interlaced = self.resolution.is_interlaced();
149        gp1::display_mode(
150            self.resolution.horizontal_field(),
151            interlaced as u32,
152            matches!(self.mode, VideoMode::Pal),
153            false,
154            interlaced,
155        )
156    }
157
158    /// GP1(06h): the horizontal display window.
159    pub(crate) const fn horizontal_range_command(self) -> u32 {
160        let clocks_per_pixel = self.resolution.clocks_per_pixel();
161        let start = H_DISPLAY_WINDOW_START + self.offset.0 as i32 * clocks_per_pixel;
162        let start = if start < 0 { 0 } else { start as u32 };
163        let end = start + (self.resolution.width as i32 * clocks_per_pixel) as u32;
164        gp1::h_display_range(start, end)
165    }
166
167    /// GP1(07h): the vertical display window, in scanlines of one field.
168    pub(crate) const fn vertical_range_command(self) -> u32 {
169        let top = match self.mode {
170            VideoMode::Ntsc => NTSC_V_DISPLAY_WINDOW_START,
171            VideoMode::Pal => PAL_V_DISPLAY_WINDOW_START,
172        };
173        let start = top + self.offset.1 as i32;
174        let start = if start < 0 { 0 } else { start as u32 };
175        gp1::v_display_range(start, start + self.resolution.field_lines())
176    }
177}
178
179/// VRAM lines the GPU has; two buffers must fit in them.
180const VRAM_LINES: u16 = 512;
181
182/// Two framebuffers stacked in VRAM: the GPU shows one while the other is
183/// drawn, and [`swap`](Self::swap) exchanges them.
184///
185/// The first buffer sits at VRAM line 0 and the second `stride` lines
186/// below it, so a 320×240 pair fits beside textures in the 1024×512 VRAM.
187///
188/// ```
189/// use psx_gpu::display::{DoubleBuffer, Resolution};
190/// let buffers = DoubleBuffer::with_stride(Resolution::R320X240, 256);
191/// assert_eq!(buffers.draw_origin(), (0, 0));
192/// assert_eq!(buffers.display_origin(), (0, 256));
193/// ```
194#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
195pub struct DoubleBuffer {
196    size: (u16, u16),
197    stride: u16,
198    drawing_second: bool,
199}
200
201impl DoubleBuffer {
202    /// Buffers of `resolution`, the second directly below the first.
203    pub const fn new(resolution: Resolution) -> Self {
204        Self::with_stride(resolution, resolution.height)
205    }
206
207    /// Buffers `stride` VRAM lines apart, leaving the lines between them
208    /// free (palettes, say). A stride below the height is raised to it so
209    /// the buffers never overlap.
210    ///
211    /// # Panics
212    ///
213    /// If the second buffer would end past VRAM line 512 (a 480-line
214    /// resolution can't be double-buffered this way).
215    pub const fn with_stride(resolution: Resolution, stride: u16) -> Self {
216        let height = resolution.height;
217        let stride = if stride < height { height } else { stride };
218        assert!(
219            stride + height <= VRAM_LINES,
220            "two buffers must fit in VRAM's 512 lines"
221        );
222        Self {
223            size: (resolution.width, height),
224            stride,
225            drawing_second: false,
226        }
227    }
228
229    /// Width and height of each buffer, in pixels.
230    pub const fn size(&self) -> (u16, u16) {
231        self.size
232    }
233
234    /// VRAM lines between the two buffers' top edges.
235    pub const fn stride(&self) -> u16 {
236        self.stride
237    }
238
239    /// Top-left VRAM corner of the buffer being drawn.
240    pub const fn draw_origin(&self) -> (u16, u16) {
241        (0, self.top(self.drawing_second))
242    }
243
244    /// Top-left VRAM corner of the buffer being shown.
245    pub const fn display_origin(&self) -> (u16, u16) {
246        (0, self.top(!self.drawing_second))
247    }
248
249    const fn top(&self, second: bool) -> u16 {
250        if second {
251            self.stride
252        } else {
253            0
254        }
255    }
256
257    /// Show the buffer just drawn and draw into the other one from now on.
258    ///
259    /// Flips at once (GP1(05h)); the hardware latches the start at the next
260    /// frame. Drawing must be finished ([`crate::Gpu::wait_idle`]) first.
261    #[doc(alias = "PutDispEnv")]
262    pub fn swap(&mut self, gpu: &mut crate::Gpu) {
263        let shown = self.draw_origin();
264        self.drawing_second = !self.drawing_second;
265        gpu.set_display_start(shown);
266        self.apply_draw_target(gpu);
267    }
268
269    /// Switch the draw side now and program its draw area and offset, and
270    /// return the GP1(05h) word that shows the finished buffer, for the
271    /// caller to apply at a blank edge (psx-rt's queued flip). Drawing must
272    /// be finished ([`crate::Gpu::wait_idle`]) first.
273    pub fn begin_swap(&mut self, gpu: &mut crate::Gpu) -> u32 {
274        let display = self.begin_deferred_swap();
275        self.apply_draw_target(gpu);
276        display
277    }
278
279    /// Select the next draw buffer without touching the GPU, and return the
280    /// GP1(05h) word that shows the finished one.
281    ///
282    /// The non-blocking first half of a pipelined swap: queue the word for a
283    /// VBlank edge whose handler applies it once the frame's closing
284    /// GP0(1Fh) has run (`psx_rt::interrupts::queue_display_control_at_vblank`,
285    /// see [`crate::is_draw_done`]), wait until it has been applied, then
286    /// call [`apply_draw_target`](Self::apply_draw_target) before drawing.
287    /// Safe to call while the previous frame still rasterises.
288    pub fn begin_deferred_swap(&mut self) -> u32 {
289        let (x, y) = self.draw_origin();
290        self.drawing_second = !self.drawing_second;
291        gp1::display_start(x as u32, y as u32)
292    }
293
294    /// Program the draw area and draw offset for the buffer being drawn.
295    pub fn apply_draw_target(&self, _gpu: &mut crate::Gpu) {
296        let (_, top) = self.draw_origin();
297        let (width, height) = self.size;
298        crate::gpu::write_draw_target(top, width, height);
299    }
300
301    /// Fill the buffer being drawn with `color` (GP0(02h)).
302    pub fn clear(&self, gpu: &mut crate::Gpu, color: (u8, u8, u8)) {
303        gpu.draw(&crate::prim::FillRect::new(
304            self.draw_origin(),
305            self.size,
306            color,
307        ));
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    const INTERLACE: u32 = 1 << 5;
316    const LINES_480: u32 = 1 << 2;
317
318    #[test]
319    fn a_240_line_mode_is_progressive() {
320        let word = DisplayConfig::new(VideoMode::Ntsc, Resolution::R320X240).mode_command();
321        assert_eq!(word & (INTERLACE | LINES_480), 0);
322        assert_eq!(word & 3, 1, "320-pixel horizontal mode");
323    }
324
325    #[test]
326    fn a_480_line_mode_sets_the_interlace_bit_it_needs() {
327        let word = DisplayConfig::new(VideoMode::Ntsc, Resolution::R640X480).mode_command();
328        assert_eq!(word & (INTERLACE | LINES_480), INTERLACE | LINES_480);
329        assert_eq!(word & 3, 3, "640-pixel horizontal mode");
330    }
331
332    #[test]
333    fn a_480_line_mode_spans_one_field_of_scanlines() {
334        // psx-spx GP1(07h): NTSC Y1/Y2 = 88h -/+ 240/2 in either line mode.
335        let word =
336            DisplayConfig::new(VideoMode::Ntsc, Resolution::R640X480).vertical_range_command();
337        assert_eq!(word, gp1::v_display_range(0x88 - 120, 0x88 + 120));
338    }
339
340    #[test]
341    fn the_standard_ntsc_picture_starts_at_260h() {
342        // psx-spx GP1(06h): 260h is the first visible pixel on normal TVs,
343        // and 320-pixel mode spans 320 * 8 clocks.
344        let word =
345            DisplayConfig::new(VideoMode::Ntsc, Resolution::R320X240).horizontal_range_command();
346        assert_eq!(word, gp1::h_display_range(0x260, 0x260 + 320 * 8));
347    }
348
349    #[test]
350    fn an_offset_moves_both_windows_and_stops_at_the_blanking_edge() {
351        let display = DisplayConfig::new(VideoMode::Pal, Resolution::R320X256);
352        let moved = display.with_offset((2, 3));
353        assert_eq!(
354            moved.horizontal_range_command(),
355            gp1::h_display_range(0x260 + 16, 0x260 + 16 + 320 * 8)
356        );
357        assert_eq!(
358            moved.vertical_range_command(),
359            gp1::v_display_range(0x23 + 3, 0x23 + 3 + 256)
360        );
361        let clamped = display.with_offset((-200, -100));
362        assert_eq!(
363            clamped.horizontal_range_command(),
364            gp1::h_display_range(0, 320 * 8)
365        );
366        assert_eq!(
367            clamped.vertical_range_command(),
368            gp1::v_display_range(0, 256)
369        );
370    }
371
372    #[test]
373    fn a_deferred_swap_alternates_buffers_without_touching_the_gpu() {
374        let mut buffers = DoubleBuffer::with_stride(Resolution::R320X240, 256);
375        assert_eq!(buffers.begin_deferred_swap(), gp1::display_start(0, 0));
376        assert_eq!(buffers.draw_origin(), (0, 256));
377        assert_eq!(buffers.display_origin(), (0, 0));
378        assert_eq!(buffers.begin_deferred_swap(), gp1::display_start(0, 256));
379        assert_eq!(buffers.draw_origin(), (0, 0));
380    }
381
382    #[test]
383    fn a_short_stride_is_raised_so_buffers_never_overlap() {
384        let buffers = DoubleBuffer::with_stride(Resolution::R320X240, 128);
385        assert_eq!(buffers.stride(), 240);
386        assert_eq!(buffers.size(), (320, 240));
387    }
388
389    #[test]
390    #[should_panic(expected = "two buffers must fit")]
391    fn a_480_line_double_buffer_does_not_fit_vram() {
392        let _ = DoubleBuffer::new(Resolution::R640X480);
393    }
394
395    #[test]
396    fn every_width_spans_the_same_2560_clocks() {
397        // psx-spx GP1(06h): the standard NTSC and PAL X2 - X1 is 2560 for
398        // 256, 320, 512 and 640 pixels; each width's dot clock divides the
399        // GPU clock by 10, 8, 5 or 4 (psx-spx, GPU Timings).
400        for resolution in [
401            Resolution::R256X240,
402            Resolution::R320X240,
403            Resolution::R512X240,
404            Resolution::R640X240,
405            Resolution::R640X480,
406        ] {
407            let word = DisplayConfig::new(VideoMode::Ntsc, resolution).horizontal_range_command();
408            let (x1, x2) = (word & 0xFFF, (word >> 12) & 0xFFF);
409            assert_eq!((x1, x2 - x1), (0x260, 2560), "{resolution:?}");
410        }
411    }
412
413    #[test]
414    fn an_offset_moves_by_whole_pixels_of_the_current_width() {
415        let display = DisplayConfig::new(VideoMode::Ntsc, Resolution::R512X240);
416        let word = display.with_offset((3, 0)).horizontal_range_command();
417        assert_eq!(word & 0xFFF, 0x260 + 3 * 5);
418    }
419}