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

psx_vram/
lib.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2//! Typed VRAM primitives.
3//!
4//! PS1 VRAM is a 1024×512 halfword frame, and getting a sprite
5//! on screen is three interlocking decisions: *where* the texel
6//! bytes live, *where* the CLUT lives, and *what* format you're
7//! in. PsyQ leaves those as `short` variables you pass around by
8//! convention. This crate promotes them to types.
9//!
10//! ## What's here
11//!
12//! - [`Color555`] -- a 15bpp BGR pixel. Constructed via
13//!   [`Color555::rgb8`] (`u8` RGB → 5-bit truncation), or
14//!   [`Color555::rgb5`] (already 5-bit), or the `const` raw
15//!   constructor [`Color555::raw`].
16//! - [`VramRect`] -- an `(x, y, w, h)` in VRAM pixels with const
17//!   validation of the bounds. Can't construct one that would
18//!   overflow VRAM.
19//! - [`TexturePage`] -- a texture-page handle. Const constructor
20//!   enforces the PSX alignment rules (`x % 64 == 0`, `y ∈ {0, 256}`),
21//!   knows its bit-depth, and emits the GP0(E1h) draw-mode word
22//!   or the 16-bit tpage field embedded in textured-rect UV words.
23//! - [`Clut`] -- a CLUT handle. `x % 16 == 0`, `y ∈ 0..512`. Emits
24//!   the 16-bit clut field for UV words. 4bpp and 8bpp CLUTs are
25//!   the same underlying type -- the calling primitive's tpage
26//!   picks which entry count it uses.
27//! - [`upload_16bpp`] -- safe upload wrapper: checks `pixels.len()`
28//!   matches `rect.w * rect.h`, packs to GP0 0xA0 + word stream.
29//!
30//! ## What's NOT here (yet)
31//!
32//! - Compile-time tpage-overlap detection. A later pass can wrap
33//!   allocations in a const-generic `Layout<...>` that enforces
34//!   non-overlap across a fixed set of TexturePage/Clut declarations;
35//!   kept out for now because the rules around the 16-pixel CLUT
36//!   stride + tpage-row sharing get messy to express in bare
37//!   Rust const generics, and a build-time allocator tool is a
38//!   cleaner path when we're ready.
39//! - TIM parsing. Runtime / proc-macro loading lives in
40//!   `psx-asset` when it lands.
41//! - 4bpp / 8bpp upload helpers. PsyQ packs those as 16-bit
42//!   halfwords too (4 / 2 texels per halfword); the same
43//!   `upload_16bpp` works once the caller has pre-packed the
44//!   indices. A typed `upload_4bpp(rect, &[u8], clut)` would be
45//!   nicer; it's one of the first obvious follow-ups.
46//!
47//! ## Why these types over PsyQ's shorts
48//!
49//! Every field has rules PsyQ leaves to the programmer. TexturePage X
50//! must be a multiple of 64; Y must be 0 or 256. CLUT X must be
51//! a multiple of 16. Upload sizes are clamped on hardware but
52//! wrap (wrap, not truncate) on the real DMA controller if you
53//! oversize them. Getting any of these wrong in PsyQ silently
54//! corrupts VRAM. Const-validating them at construction means
55//! the bug can't compile -- which is the whole point of writing
56//! a Rust SDK instead of a C one.
57
58#![no_std]
59#![deny(unsafe_op_in_unsafe_fn)]
60#![warn(missing_docs)]
61
62use psx_hw::gpu::{gp0, gp1, pack_xy};
63use psx_io::dma::{self, Channel};
64use psx_io::gpu::{pause_recording, wait_command_ready, write_command, write_display_control};
65use psx_io::periph::GpuDma;
66
67/// VRAM framebuffer width in pixels.
68pub const VRAM_WIDTH: u16 = 1024;
69/// VRAM framebuffer height in pixels.
70pub const VRAM_HEIGHT: u16 = 512;
71
72// ======================================================================
73// Color
74// ======================================================================
75
76/// A 15-bit BGR pixel as stored in VRAM.
77///
78/// PSX VRAM stores every halfword in BGR-555-with-mask-bit layout:
79///
80/// ```text
81///   bit  15   14..10   9..5   4..0
82///        m    B       G      R
83/// ```
84///
85/// The mask bit is bit 15; [`Color555::rgb8`] constructs with the
86/// mask bit clear (most common). The "transparency" that games rely
87/// on for CLUT sprites is `Color555::raw(0)` -- PSX treats an
88/// all-zero texel as transparent in direct-color mode.
89#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
90#[repr(transparent)]
91pub struct Color555(u16);
92
93impl Color555 {
94    /// Fully-transparent texel (value 0). PSX treats this as "skip"
95    /// when sampling a textured primitive, regardless of mode.
96    pub const TRANSPARENT: Self = Self(0);
97    /// Opaque black with mask bit clear.
98    pub const BLACK: Self = Self(0);
99    /// Opaque white -- all five bits set in each channel.
100    pub const WHITE: Self = Self(0x7FFF);
101
102    /// Build a color from 8-bit channels, truncating the low 3 bits
103    /// of each. `rgb8(255, 255, 255) == WHITE`.
104    ///
105    /// Matches PsyQ's `getTPage(0, r, g, b)` and
106    /// `setRGB0(prim, r, g, b)` convention where the caller writes
107    /// 8-bit values; hardware sees 5 bits per channel.
108    pub const fn rgb8(r: u8, g: u8, b: u8) -> Self {
109        let r = (r as u16) >> 3;
110        let g = (g as u16) >> 3;
111        let b = (b as u16) >> 3;
112        Self(r | (g << 5) | (b << 10))
113    }
114
115    /// Build a color from already-5-bit channels. Asserts each
116    /// fits in 5 bits. `rgb5(31, 31, 31) == WHITE`.
117    pub const fn rgb5(r: u8, g: u8, b: u8) -> Self {
118        assert!(r < 32, "rgb5: red must be < 32");
119        assert!(g < 32, "rgb5: green must be < 32");
120        assert!(b < 32, "rgb5: blue must be < 32");
121        Self((r as u16) | ((g as u16) << 5) | ((b as u16) << 10))
122    }
123
124    /// Construct from the raw VRAM halfword. Useful when decoding
125    /// CLUT bytes or loading pre-packed asset data.
126    pub const fn raw(value: u16) -> Self {
127        Self(value)
128    }
129
130    /// Underlying VRAM halfword.
131    pub const fn as_u16(self) -> u16 {
132        self.0
133    }
134
135    /// Set the mask bit (bit 15). Matters when `GP0 0xE6` has
136    /// "check mask on draw" enabled -- writes are skipped where
137    /// existing mask is set. Games use this for UI overlays.
138    ///
139    /// In a **CLUT entry** bit 15 means something else entirely: see
140    /// [`Self::with_semi_transparency`].
141    pub const fn with_mask_bit(self) -> Self {
142        Self(self.0 | 0x8000)
143    }
144
145    /// Set the semi-transparency bit (STP, bit 15) for use in a **CLUT entry**.
146    ///
147    /// This is the same bit as [`Self::with_mask_bit`], but when the halfword
148    /// is a palette entry rather than a framebuffer pixel the GPU reads it as
149    /// "blend this texel". Selecting a blend mode on a textured primitive is
150    /// not enough on its own: the mode says *how* to blend, STP says *which
151    /// texels* blend. A palette without it draws fully opaque whatever blend
152    /// mode the primitive carries, and nothing reports it -- a silent failure
153    /// that reads like a brightness bug rather than a blending one.
154    ///
155    /// Set it on every entry of an additive or translucent palette, leaving
156    /// the fully transparent index (usually 0) at [`Self::TRANSPARENT`].
157    #[doc(alias = "STP")]
158    pub const fn with_semi_transparency(self) -> Self {
159        Self(self.0 | 0x8000)
160    }
161
162    /// Whether bit 15 is set (the mask bit, or STP in a CLUT entry).
163    #[doc(alias = "STP")]
164    pub const fn is_semi_transparent(self) -> bool {
165        self.0 & 0x8000 != 0
166    }
167}
168
169// ======================================================================
170// Rect
171// ======================================================================
172
173/// An `(x, y, w, h)` rectangle in VRAM coordinates.
174///
175/// Construction asserts the rect fits in VRAM. Subsequent code can
176/// pass `VramRect` around without re-checking -- it's by-construction
177/// safe.
178#[derive(Copy, Clone, Debug, PartialEq, Eq)]
179pub struct VramRect {
180    /// Top-left X in VRAM pixels (0..VRAM_WIDTH).
181    pub x: u16,
182    /// Top-left Y in VRAM pixels (0..VRAM_HEIGHT).
183    pub y: u16,
184    /// Width in pixels. `x + w <= VRAM_WIDTH`.
185    pub w: u16,
186    /// Height in pixels. `y + h <= VRAM_HEIGHT`.
187    pub h: u16,
188}
189
190impl VramRect {
191    /// Build a rect, asserting it fits in VRAM.
192    pub const fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
193        assert!(w > 0, "VramRect: width must be > 0");
194        assert!(h > 0, "VramRect: height must be > 0");
195        assert!(x <= VRAM_WIDTH, "VramRect: x past VRAM right edge");
196        assert!(y <= VRAM_HEIGHT, "VramRect: y past VRAM bottom edge");
197        assert!(
198            (x as u32) + (w as u32) <= VRAM_WIDTH as u32,
199            "VramRect: (x + w) overflows VRAM width",
200        );
201        assert!(
202            (y as u32) + (h as u32) <= VRAM_HEIGHT as u32,
203            "VramRect: (y + h) overflows VRAM height",
204        );
205        Self { x, y, w, h }
206    }
207
208    /// Total pixel count. `w * h`.
209    pub const fn pixel_count(self) -> u32 {
210        (self.w as u32) * (self.h as u32)
211    }
212
213    /// Do these two rectangles share a pixel?
214    ///
215    /// Const so a game can prove its fixed VRAM layout at compile time. See
216    /// [`first_vram_overlap`].
217    pub const fn overlaps(self, other: Self) -> bool {
218        self.x < other.x + other.w
219            && other.x < self.x + self.w
220            && self.y < other.y + other.h
221            && other.y < self.y + self.h
222    }
223}
224
225/// Index of the first pair of rectangles in `rects` that share a pixel.
226///
227/// This exists because VRAM is the one PS1 resource with no allocator between
228/// the writer and the hardware: a picture uploaded to the wrong row silently
229/// reinterprets whatever was there, and the symptom is a wrong-looking texture
230/// somewhere else entirely, often only on the map that fills VRAM deepest.
231///
232/// A game that hardcodes its layout should list every fixed rectangle it owns
233/// (framebuffers, texture-page bands, CLUT rows, HUD and font strips) and pin
234/// the list at compile time, which costs no image bytes:
235///
236/// ```
237/// use psx_vram::{first_vram_overlap, VramRect};
238///
239/// const LAYOUT: [VramRect; 3] = [
240///     VramRect::new(0, 0, 320, 240),     // framebuffer A
241///     VramRect::new(0, 240, 320, 240),   // framebuffer B
242///     VramRect::new(0, 480, 320, 32),    // CLUT block
243/// ];
244/// const _: () = assert!(
245///     matches!(first_vram_overlap(&LAYOUT), None),
246///     "VRAM layout overlaps",
247/// );
248/// ```
249///
250/// A dynamic allocator's reachable output is a rectangle too. Bound it and
251/// list that bound, so a later change to the bound, the band or the row count
252/// fails the build rather than corrupting VRAM on one map.
253pub const fn first_vram_overlap(rects: &[VramRect]) -> Option<(usize, usize)> {
254    let mut i = 0;
255    while i < rects.len() {
256        let mut j = i + 1;
257        while j < rects.len() {
258            if rects[i].overlaps(rects[j]) {
259                return Some((i, j));
260            }
261            j += 1;
262        }
263        i += 1;
264    }
265    None
266}
267
268/// True when no two rectangles in `rects` share a pixel. See
269/// [`first_vram_overlap`] for the pattern and why it is worth pinning.
270pub const fn vram_layout_is_disjoint(rects: &[VramRect]) -> bool {
271    first_vram_overlap(rects).is_none()
272}
273
274// ======================================================================
275// Texture-window atlas allocation
276// ======================================================================
277
278/// Texture-window granularity in texels.
279///
280/// GP0(E2) stores texture-window masks and offsets in 8-texel units,
281/// so small atlas placements only need an 8-texel occupancy grid.
282pub const TEXTURE_WINDOW_UNIT_TEXELS: u16 = 8;
283
284/// Texture-page width and height in texels.
285pub const TEXTURE_PAGE_TEXELS: u16 = 256;
286
287const TEXTURE_PAGE_UNITS: usize = (TEXTURE_PAGE_TEXELS / TEXTURE_WINDOW_UNIT_TEXELS) as usize;
288
289/// Placement returned by [`TextureWindowAtlas::allocate`].
290#[derive(Copy, Clone, Debug, PartialEq, Eq)]
291pub struct TextureWindowPlacement {
292    page_index: u16,
293    origin_u: u8,
294    origin_v: u8,
295    width: u8,
296    height: u8,
297}
298
299impl TextureWindowPlacement {
300    /// Zero-based page index within the caller-owned atlas page band.
301    pub const fn page_index(self) -> u16 {
302        self.page_index
303    }
304
305    /// U origin in texels within the selected texture page.
306    pub const fn origin_u(self) -> u8 {
307        self.origin_u
308    }
309
310    /// V origin in texels within the selected texture page.
311    pub const fn origin_v(self) -> u8 {
312        self.origin_v
313    }
314
315    /// Allocated width in texels.
316    pub const fn width(self) -> u8 {
317        self.width
318    }
319
320    /// Allocated height in texels.
321    pub const fn height(self) -> u8 {
322        self.height
323    }
324}
325
326/// Tiny no-alloc atlas for PS1 texture-window-sized subtextures.
327///
328/// Each 256x256 texture page is tracked as a 32x32 bit grid where one
329/// bit represents an 8x8 texel block. Allocations are power-of-two
330/// rectangles that fit GP0(E2) texture-window constraints and are
331/// placed on their own width/height grid.
332#[derive(Copy, Clone, Debug, PartialEq, Eq)]
333pub struct TextureWindowAtlas<const PAGE_COUNT: usize> {
334    rows: [[u32; TEXTURE_PAGE_UNITS]; PAGE_COUNT],
335}
336
337impl<const PAGE_COUNT: usize> TextureWindowAtlas<PAGE_COUNT> {
338    /// Build an empty atlas.
339    pub const fn new() -> Self {
340        Self {
341            rows: [[0; TEXTURE_PAGE_UNITS]; PAGE_COUNT],
342        }
343    }
344
345    /// Clear every occupied bit.
346    pub fn clear(&mut self) {
347        for page in &mut self.rows {
348            page.fill(0);
349        }
350    }
351
352    /// Allocate a power-of-two texture-window rectangle.
353    ///
354    /// Dimensions are texels. They must be 8-texel aligned,
355    /// power-of-two, and no larger than 128 texels, matching the
356    /// hardware texture-window range.
357    pub fn allocate(&mut self, width: u16, height: u16) -> Option<TextureWindowPlacement> {
358        let width_units = texture_window_units(width)?;
359        let height_units = texture_window_units(height)?;
360        let max_y = TEXTURE_PAGE_UNITS.checked_sub(height_units)?;
361        let max_x = TEXTURE_PAGE_UNITS.checked_sub(width_units)?;
362
363        for page_index in 0..PAGE_COUNT {
364            let mut y = 0usize;
365            while y <= max_y {
366                let mut x = 0usize;
367                while x <= max_x {
368                    if texture_region_free(&self.rows[page_index], x, y, width_units, height_units)
369                    {
370                        texture_region_mark(
371                            &mut self.rows[page_index],
372                            x,
373                            y,
374                            width_units,
375                            height_units,
376                        );
377                        return Some(TextureWindowPlacement {
378                            page_index: u16::try_from(page_index).ok()?,
379                            origin_u: u8::try_from(x * TEXTURE_WINDOW_UNIT_TEXELS as usize).ok()?,
380                            origin_v: u8::try_from(y * TEXTURE_WINDOW_UNIT_TEXELS as usize).ok()?,
381                            width: u8::try_from(width).ok()?,
382                            height: u8::try_from(height).ok()?,
383                        });
384                    }
385                    x += width_units;
386                }
387                y += height_units;
388            }
389        }
390        None
391    }
392
393    /// Reserve one completely empty texture page and return its page index.
394    ///
395    /// This is for assets that use a whole page without GP0(E2) texture
396    /// windows, such as large UI/background strips. It shares the atlas
397    /// bookkeeping so later windowed material allocations cannot overlap the
398    /// reserved page.
399    pub fn reserve_empty_page(&mut self) -> Option<usize> {
400        for page_index in 0..PAGE_COUNT {
401            if self.rows[page_index].iter().all(|row| *row == 0) {
402                self.rows[page_index].fill(u32::MAX);
403                return Some(page_index);
404            }
405        }
406        None
407    }
408
409    /// Release a placement previously returned by [`allocate`](Self::allocate),
410    /// clearing its occupancy bits so the space can be reused. The
411    /// `width`/`height` are the texel dimensions the placement was allocated
412    /// with (the placement records them).
413    pub fn release(&mut self, placement: TextureWindowPlacement) {
414        let page = placement.page_index() as usize;
415        let (Some(width_units), Some(height_units)) = (
416            texture_window_units(placement.width() as u16),
417            texture_window_units(placement.height() as u16),
418        ) else {
419            return;
420        };
421        let x = (placement.origin_u() / TEXTURE_WINDOW_UNIT_TEXELS as u8) as usize;
422        let y = (placement.origin_v() / TEXTURE_WINDOW_UNIT_TEXELS as u8) as usize;
423        if page >= PAGE_COUNT {
424            return;
425        }
426        let mask = texture_region_mask(x, width_units);
427        for row in &mut self.rows[page][y..y + height_units] {
428            *row &= !mask;
429        }
430    }
431
432    /// Release a whole page previously taken by
433    /// [`reserve_empty_page`](Self::reserve_empty_page).
434    pub fn release_page(&mut self, page_index: usize) {
435        if page_index < PAGE_COUNT {
436            self.rows[page_index].fill(0);
437        }
438    }
439
440    /// Whether a logical atlas page contains no live window or full-page
441    /// reservation. Used by the physical allocator to return lazy backing at
442    /// scene boundaries.
443    pub fn is_page_empty(&self, page_index: usize) -> bool {
444        self.rows
445            .get(page_index)
446            .is_some_and(|rows| rows.iter().all(|row| *row == 0))
447    }
448}
449
450impl<const PAGE_COUNT: usize> Default for TextureWindowAtlas<PAGE_COUNT> {
451    fn default() -> Self {
452        Self::new()
453    }
454}
455
456fn texture_window_units(size: u16) -> Option<usize> {
457    if !(TEXTURE_WINDOW_UNIT_TEXELS..=128).contains(&size)
458        || !size.is_power_of_two()
459        || !size.is_multiple_of(TEXTURE_WINDOW_UNIT_TEXELS)
460    {
461        return None;
462    }
463    Some((size / TEXTURE_WINDOW_UNIT_TEXELS) as usize)
464}
465
466fn texture_region_mask(x: usize, width: usize) -> u32 {
467    ((1u32 << width) - 1) << x
468}
469
470fn texture_region_free(
471    rows: &[u32; TEXTURE_PAGE_UNITS],
472    x: usize,
473    y: usize,
474    width: usize,
475    height: usize,
476) -> bool {
477    let mask = texture_region_mask(x, width);
478    for row in &rows[y..y + height] {
479        if row & mask != 0 {
480            return false;
481        }
482    }
483    true
484}
485
486fn texture_region_mark(
487    rows: &mut [u32; TEXTURE_PAGE_UNITS],
488    x: usize,
489    y: usize,
490    width: usize,
491    height: usize,
492) {
493    let mask = texture_region_mask(x, width);
494    for row in &mut rows[y..y + height] {
495        *row |= mask;
496    }
497}
498
499// ======================================================================
500// TexturePage + Clut
501// ======================================================================
502
503/// Texture color depth -- the last field of GP0(E1h) and bits 7..8
504/// of a primitive's tpage word.
505#[derive(Copy, Clone, Debug, PartialEq, Eq)]
506#[repr(u8)]
507pub enum TextureDepth {
508    /// 4-bit CLUT-indexed. 4 texels per 16-bit halfword.
509    Bit4 = 0,
510    /// 8-bit CLUT-indexed. 2 texels per halfword.
511    Bit8 = 1,
512    /// 15-bit direct color. 1 texel per halfword.
513    Bit15 = 2,
514}
515
516impl TextureDepth {
517    const fn as_u16(self) -> u16 {
518        self as u16
519    }
520
521    /// Texels per 16-bit halfword at this depth. 4 for 4bpp,
522    /// 2 for 8bpp, 1 for 15bpp.
523    pub const fn texels_per_halfword(self) -> u16 {
524        match self {
525            TextureDepth::Bit4 => 4,
526            TextureDepth::Bit8 => 2,
527            TextureDepth::Bit15 => 1,
528        }
529    }
530}
531
532/// A texture page: a 256×256 region at a specific VRAM origin,
533/// plus a depth.
534///
535/// Hardware constraints (PSX-SPX "GP0 0xE1"):
536/// - `x` is multiples of 64 pixels → one of 0, 64, 128, …, 960.
537/// - `y` is either 0 or 256.
538/// - The effective page width shrinks with depth: 64 pixels for
539///   4bpp, 128 for 8bpp, 256 for 15bpp. (Because the texel-fetch
540///   address is `tpage_x + u / texels_per_halfword`, so smaller
541///   texels pack into fewer halfwords and hence a narrower span.)
542///
543/// The const constructor catches misaligned X/Y at compile time.
544/// A const check across multiple TexturePage consts (to enforce non-
545/// overlap) needs const generics that are noisy to express here;
546/// runtime collision tooling lives in a follow-up.
547#[derive(Copy, Clone, Debug, PartialEq, Eq)]
548pub struct TexturePage {
549    x: u16,
550    y: u16,
551    depth: TextureDepth,
552}
553
554impl TexturePage {
555    /// Build a TexturePage. Compile-time assertion that `x` is a
556    /// multiple of 64 and `y` is 0 or 256.
557    #[allow(clippy::manual_is_multiple_of)]
558    pub const fn new(x: u16, y: u16, depth: TextureDepth) -> Self {
559        assert!(x % 64 == 0, "TexturePage: x must be a multiple of 64");
560        assert!(x < VRAM_WIDTH, "TexturePage: x past VRAM");
561        assert!(y == 0 || y == 256, "TexturePage: y must be 0 or 256");
562        Self { x, y, depth }
563    }
564
565    /// VRAM X origin (0, 64, …, 960).
566    pub const fn x(self) -> u16 {
567        self.x
568    }
569    /// VRAM Y origin (0 or 256).
570    pub const fn y(self) -> u16 {
571        self.y
572    }
573    /// Color depth.
574    pub const fn depth(self) -> TextureDepth {
575        self.depth
576    }
577
578    /// VRAM rect this tpage would actually cover at its depth.
579    /// Useful for sanity-checking that uploaded texture data fits.
580    pub const fn covering_rect(self) -> VramRect {
581        let w = match self.depth {
582            TextureDepth::Bit4 => 64,
583            TextureDepth::Bit8 => 128,
584            TextureDepth::Bit15 => 256,
585        };
586        // covering_rect is for upload sizing; height is always the
587        // 256-row page height. The assert in VramRect::new guards
588        // the actual bound at compile time.
589        VramRect::new(self.x, self.y, w, 256)
590    }
591
592    /// Encode as the low-16-bits of a textured-primitive UV word
593    /// (the "tpage" embedded field). Same byte layout as GP0(E1h)'s
594    /// low 10 bits minus display-disable / dither bits, which we
595    /// leave to the draw-mode setter. Bits:
596    ///
597    /// ```text
598    ///   bits 0..3   : tpage X / 64
599    ///   bit  4      : tpage Y / 256
600    ///   bits 5..6   : semi-transparency mode (0 = half + half)
601    ///   bits 7..8   : depth (0=4bpp, 1=8bpp, 2=15bpp)
602    /// ```
603    ///
604    /// `semi_trans` picks the GPU blend mode when a texel's mask
605    /// bit is set. 0 is "0.5·bg + 0.5·fg" -- the most common.
606    #[doc(alias = "getTPage")]
607    pub const fn uv_word(self, semi_trans: u8) -> u16 {
608        assert!(semi_trans < 4, "semi_trans must be 0..4");
609        let tpx = (self.x / 64) & 0xF;
610        let tpy = if self.y == 256 { 1 } else { 0 };
611        let depth = self.depth.as_u16();
612        tpx | (tpy << 4) | ((semi_trans as u16) << 5) | (depth << 7)
613    }
614
615    /// The full GP0(E1h) draw-mode word. Use this to set the
616    /// "current" tpage for sprite / rect primitives that don't
617    /// embed a tpage of their own.
618    pub fn apply_as_draw_mode(self) {
619        wait_command_ready();
620        write_command(gp0::draw_mode(
621            (self.x / 64) as u32,
622            if self.y == 256 { 1 } else { 0 },
623            0,
624            self.depth as u32,
625            false,
626            true,
627        ));
628        // Plain tpage application means "sample the page directly".
629        // Material-aware helpers re-apply their own texture window after
630        // setting draw mode.
631        wait_command_ready();
632        write_command(gp0::tex_window(0, 0, 0, 0));
633    }
634}
635
636/// A CLUT slot -- 16 consecutive halfwords at `(x, y)` for 4bpp, or
637/// 256 consecutive halfwords for 8bpp. Const constructor asserts
638/// the 16-pixel X alignment hardware requires.
639#[derive(Copy, Clone, Debug, PartialEq, Eq)]
640pub struct Clut {
641    x: u16,
642    y: u16,
643}
644
645impl Clut {
646    /// Build a CLUT. `x` must be a multiple of 16; `y` is any row.
647    #[allow(clippy::manual_is_multiple_of)]
648    pub const fn new(x: u16, y: u16) -> Self {
649        assert!(x % 16 == 0, "Clut: x must be a multiple of 16");
650        assert!(x < VRAM_WIDTH, "Clut: x past VRAM");
651        assert!(y < VRAM_HEIGHT, "Clut: y past VRAM");
652        Self { x, y }
653    }
654
655    /// VRAM X origin (0, 16, 32, …).
656    pub const fn x(self) -> u16 {
657        self.x
658    }
659    /// VRAM Y origin (0..512).
660    pub const fn y(self) -> u16 {
661        self.y
662    }
663
664    /// Encode as the high-16-bits of a textured-primitive's first
665    /// UV word. Bits:
666    ///
667    /// ```text
668    ///   bits 0..5   : CLUT X / 16
669    ///   bits 6..14  : CLUT Y
670    /// ```
671    #[doc(alias = "getClut")]
672    pub const fn uv_word(self) -> u16 {
673        let cx = (self.x / 16) & 0x3F;
674        let cy = self.y & 0x1FF;
675        cx | (cy << 6)
676    }
677}
678
679// ======================================================================
680// Unified VRAM allocator
681// ======================================================================
682
683/// Coarse VRAM occupancy cell width in pixels (one texture-page column).
684const ALLOC_COL_W: u16 = 64;
685/// Coarse VRAM occupancy cell height in pixels.
686const ALLOC_ROW_H: u16 = 16;
687/// Number of 64-pixel columns across VRAM (16).
688pub const VRAM_ALLOC_COLS: usize = (VRAM_WIDTH / ALLOC_COL_W) as usize;
689/// Number of 16-pixel rows down VRAM (32).
690pub const VRAM_ALLOC_ROWS: usize = (VRAM_HEIGHT / ALLOC_ROW_H) as usize;
691/// Page height in coarse rows (a texture page is 256 px tall).
692const ALLOC_PAGE_ROWS: usize = (TEXTURE_PAGE_TEXELS / ALLOC_ROW_H) as usize;
693/// CLUT slots per VRAM row (1024 px / 16 px).
694const CLUT_SLOTS_PER_ROW: u16 = VRAM_WIDTH / 16;
695
696/// A freeable handle to a VRAM reservation handed out by [`VramAllocator`].
697///
698/// Returned alongside the hardware [`TexturePage`] / [`Clut`] so a caller can both
699/// use the reservation immediately and release it later via
700/// [`VramAllocator::free`]. `Copy` so it can sit in fixed slot tables.
701#[derive(Copy, Clone, Debug, PartialEq, Eq)]
702pub enum VramHandle {
703    /// No reservation (allocation failed, or a default slot).
704    Empty,
705    /// A coarse-grid rectangle: texture pages, model strips, the framebuffer.
706    Rect(VramRect),
707    /// A room-material window inside the composed [`TextureWindowAtlas`].
708    Window(TextureWindowPlacement),
709    /// A whole reserved room-material page (its page index).
710    RoomPage(u16),
711    /// A CLUT-row sub-allocation: `entries` palette slots at `(x, y)`.
712    Clut {
713        /// VRAM X (multiple of 16).
714        x: u16,
715        /// VRAM Y.
716        y: u16,
717        /// Palette entry count (16 or 256).
718        entries: u16,
719    },
720}
721
722/// Source of VRAM regions for crates that should not depend on the whole
723/// [`VramAllocator`] type (e.g. `psx-font`). [`VramAllocator`] implements it.
724pub trait VramRegionSource {
725    /// Reserve `count` contiguous texture pages at page row `page_y`
726    /// (0 or 256), returning the base [`TexturePage`] and a free handle.
727    fn alloc_page_run(
728        &mut self,
729        count: u16,
730        depth: TextureDepth,
731        page_y: u16,
732    ) -> Option<(TexturePage, VramHandle)>;
733
734    /// Reserve a CLUT of `entries` palette slots (16 for 4bpp, 256 for 8bpp).
735    fn alloc_clut(&mut self, entries: u16) -> Option<(Clut, VramHandle)>;
736}
737
738/// CLUT-row sub-allocator over a reserved band of `ROWS` VRAM rows.
739///
740/// Each row holds [`CLUT_SLOTS_PER_ROW`] 16-pixel slots tracked as a `u64`
741/// bitmap, so a 4bpp (16-entry) CLUT takes 1 slot and an 8bpp (256-entry)
742/// CLUT takes 16 contiguous slots.
743#[derive(Copy, Clone, Debug)]
744pub struct ClutRowAllocator<const ROWS: usize> {
745    base_y: u16,
746    // psx-numeric-allow-next-line: 64-slot occupancy bitmask is a u64 by definition
747    rows: [u64; ROWS],
748}
749
750impl<const ROWS: usize> ClutRowAllocator<ROWS> {
751    /// Build an allocator over rows `[base_y, base_y + ROWS)`.
752    pub const fn new(base_y: u16) -> Self {
753        Self {
754            base_y,
755            rows: [0; ROWS],
756        }
757    }
758
759    fn slot_count(entries: u16) -> usize {
760        (entries.max(1) as usize).div_ceil(16)
761    }
762
763    /// Allocate a CLUT of `entries` (16 or 256) palette slots.
764    pub fn alloc(&mut self, entries: u16) -> Option<Clut> {
765        let slots = Self::slot_count(entries);
766        let cap = CLUT_SLOTS_PER_ROW as usize;
767        if slots == 0 || slots > cap.min(64) {
768            return None;
769        }
770        // psx-numeric-allow-next-line: 64-slot occupancy bitmask is a u64 by definition
771        let want: u64 = if slots == 64 {
772            // psx-numeric-allow-next-line: 64-slot occupancy bitmask is a u64 by definition
773            u64::MAX
774        } else {
775            (1u64 << slots) - 1
776        };
777        for (r, row) in self.rows.iter_mut().enumerate() {
778            let mut s = 0usize;
779            while s + slots <= cap.min(64) {
780                let mask = want << s;
781                if *row & mask == 0 {
782                    *row |= mask;
783                    return Some(Clut::new((s as u16) * 16, self.base_y + r as u16));
784                }
785                s += 1;
786            }
787        }
788        None
789    }
790
791    /// Release a CLUT previously returned by [`alloc`](Self::alloc).
792    pub fn free(&mut self, clut: Clut, entries: u16) {
793        let slots = Self::slot_count(entries);
794        let r = clut.y().saturating_sub(self.base_y) as usize;
795        let s = (clut.x() / 16) as usize;
796        if r >= ROWS || s + slots > 64 {
797            return;
798        }
799        // psx-numeric-allow-next-line: 64-slot occupancy bitmask is a u64 by definition
800        let mask: u64 = if slots == 64 {
801            // psx-numeric-allow-next-line: 64-slot occupancy bitmask is a u64 by definition
802            u64::MAX
803        } else {
804            (1u64 << slots) - 1
805        } << s;
806        self.rows[r] &= !mask;
807    }
808}
809
810/// One owner for all of VRAM: texture pages, room-material windows, model
811/// strips and CLUT rows, with `free`. A coarse 64×16-pixel occupancy grid
812/// keeps page/strip/framebuffer reservations from overlapping; a composed
813/// [`TextureWindowAtlas`] sub-allocates room-material windows inside a
814/// reserved band; a [`ClutRowAllocator`] hands out CLUT rows.
815#[derive(Copy, Clone, Debug)]
816pub struct VramAllocator<const ROOM_PAGES: usize, const CLUT_ROWS: usize> {
817    grid: [u16; VRAM_ALLOC_ROWS],
818    clut: ClutRowAllocator<CLUT_ROWS>,
819    room: TextureWindowAtlas<ROOM_PAGES>,
820    /// Physical backing selected lazily for each logical room-atlas page.
821    /// Unused capacity therefore consumes no VRAM, and a page is returned when
822    /// its final window (or full-page image) is released at a scene boundary.
823    room_pages: [Option<TexturePage>; ROOM_PAGES],
824    room_page_handles: [VramHandle; ROOM_PAGES],
825    room_base_x: u16,
826    room_base_y: u16,
827}
828
829impl<const ROOM_PAGES: usize, const CLUT_ROWS: usize> VramAllocator<ROOM_PAGES, CLUT_ROWS> {
830    /// Build an empty allocator. `clut_base_y` is the first CLUT-band row.
831    pub const fn new(clut_base_y: u16) -> Self {
832        Self {
833            grid: [0; VRAM_ALLOC_ROWS],
834            clut: ClutRowAllocator::new(clut_base_y),
835            room: TextureWindowAtlas::new(),
836            room_pages: [None; ROOM_PAGES],
837            room_page_handles: [VramHandle::Empty; ROOM_PAGES],
838            room_base_x: 0,
839            room_base_y: 0,
840        }
841    }
842
843    fn span(x: u16, y: u16, w: u16, h: u16) -> (usize, usize, usize, usize) {
844        let c0 = (x / ALLOC_COL_W) as usize;
845        let c1 = ((x as usize + w as usize).div_ceil(ALLOC_COL_W as usize)).min(VRAM_ALLOC_COLS);
846        let r0 = (y / ALLOC_ROW_H) as usize;
847        let r1 = ((y as usize + h as usize).div_ceil(ALLOC_ROW_H as usize)).min(VRAM_ALLOC_ROWS);
848        (c0, c1, r0, r1)
849    }
850
851    #[allow(dead_code)] // used by later stages for overlap assertions
852    fn rect_free(&self, x: u16, y: u16, w: u16, h: u16) -> bool {
853        let (c0, c1, r0, r1) = Self::span(x, y, w, h);
854        for r in r0..r1 {
855            for c in c0..c1 {
856                if self.grid[r] & (1 << c) != 0 {
857                    return false;
858                }
859            }
860        }
861        true
862    }
863
864    fn set_rect(&mut self, x: u16, y: u16, w: u16, h: u16, occupied: bool) {
865        let (c0, c1, r0, r1) = Self::span(x, y, w, h);
866        for r in r0..r1 {
867            for c in c0..c1 {
868                if occupied {
869                    self.grid[r] |= 1 << c;
870                } else {
871                    self.grid[r] &= !(1 << c);
872                }
873            }
874        }
875    }
876
877    /// Reserve a fixed rectangle (e.g. the double-buffered framebuffer, or a
878    /// region still owned by legacy hardcoded uploads). Returns a handle that
879    /// can be `free`d but is normally permanent.
880    pub fn reserve_rect(&mut self, rect: VramRect) -> VramHandle {
881        self.set_rect(rect.x, rect.y, rect.w, rect.h, true);
882        VramHandle::Rect(rect)
883    }
884
885    /// Select the preferred room-material page band. Physical pages are
886    /// reserved lazily as the logical atlas first touches them, so a six-page
887    /// capacity no longer pins six pages for a scene that uses only one.
888    pub fn reserve_room_band(&mut self, base_x: u16, base_y: u16) {
889        self.room_base_x = base_x;
890        self.room_base_y = base_y;
891    }
892
893    fn ensure_room_page(&mut self, page_index: usize) -> Option<TexturePage> {
894        if let Some(tpage) = self.room_pages.get(page_index).copied().flatten() {
895            return Some(tpage);
896        }
897        if page_index >= ROOM_PAGES {
898            return None;
899        }
900        let preferred_x = self
901            .room_base_x
902            .checked_add((page_index as u16).checked_mul(ALLOC_COL_W)?)?;
903        let x = if preferred_x + ALLOC_COL_W <= VRAM_WIDTH
904            && self.rect_free(
905                preferred_x,
906                self.room_base_y,
907                ALLOC_COL_W,
908                TEXTURE_PAGE_TEXELS,
909            ) {
910            preferred_x
911        } else {
912            self.find_page_run(1, self.room_base_y)?
913        };
914        self.set_rect(x, self.room_base_y, ALLOC_COL_W, TEXTURE_PAGE_TEXELS, true);
915        let tpage = TexturePage::new(x, self.room_base_y, TextureDepth::Bit4);
916        self.room_pages[page_index] = Some(tpage);
917        self.room_page_handles[page_index] = VramHandle::Rect(VramRect::new(
918            x,
919            self.room_base_y,
920            ALLOC_COL_W,
921            TEXTURE_PAGE_TEXELS,
922        ));
923        Some(tpage)
924    }
925
926    fn release_room_page_if_empty(&mut self, page_index: usize) {
927        if page_index >= ROOM_PAGES || !self.room.is_page_empty(page_index) {
928            return;
929        }
930        let handle = core::mem::replace(&mut self.room_page_handles[page_index], VramHandle::Empty);
931        if let VramHandle::Rect(rect) = handle {
932            self.set_rect(rect.x, rect.y, rect.w, rect.h, false);
933        }
934        self.room_pages[page_index] = None;
935    }
936
937    fn find_page_run(&self, count: u16, page_y: u16) -> Option<u16> {
938        let r0 = (page_y / ALLOC_ROW_H) as usize;
939        let r1 = r0 + ALLOC_PAGE_ROWS;
940        if r1 > VRAM_ALLOC_ROWS || count == 0 {
941            return None;
942        }
943        let n = count as usize;
944        let mut c = 0usize;
945        while c + n <= VRAM_ALLOC_COLS {
946            let cols_free = (c..c + n).all(|col| (r0..r1).all(|r| self.grid[r] & (1 << col) == 0));
947            if cols_free {
948                return Some((c as u16) * ALLOC_COL_W);
949            }
950            c += 1;
951        }
952        None
953    }
954
955    /// Allocate a room-material window (delegates to the composed
956    /// [`TextureWindowAtlas`]; the band must be reserved first).
957    pub fn alloc_window(
958        &mut self,
959        width_texels: u16,
960        height_texels: u16,
961    ) -> Option<(TexturePage, TextureWindowPlacement, VramHandle)> {
962        let placement = self.room.allocate(width_texels, height_texels)?;
963        let page_index = placement.page_index() as usize;
964        let Some(tpage) = self.ensure_room_page(page_index) else {
965            self.room.release(placement);
966            return None;
967        };
968        Some((tpage, placement, VramHandle::Window(placement)))
969    }
970
971    /// Reserve a whole room-material page (large UI / background textures use a
972    /// full page without a GP0(E2) window). The room band must be reserved
973    /// first via [`reserve_room_band`](Self::reserve_room_band).
974    pub fn alloc_room_page(&mut self) -> Option<(TexturePage, VramHandle)> {
975        let page_index = self.room.reserve_empty_page()?;
976        let Some(tpage) = self.ensure_room_page(page_index) else {
977            self.room.release_page(page_index);
978            return None;
979        };
980        Some((tpage, VramHandle::RoomPage(page_index as u16)))
981    }
982
983    /// Allocate an indexed model-atlas strip `halfwords_per_row` wide (rounded
984    /// up to whole 64-halfword tpage bases) at page row 256.
985    ///
986    /// Placement is measured in physical VRAM halfwords, so the same allocator
987    /// works for 4bpp and 8bpp atlases. `depth` controls how the GPU interprets
988    /// those halfwords when the returned tpage word is emitted.
989    pub fn alloc_model_slot(
990        &mut self,
991        halfwords_per_row: u16,
992        depth: TextureDepth,
993    ) -> Option<(TexturePage, VramHandle)> {
994        if !matches!(depth, TextureDepth::Bit4 | TextureDepth::Bit8) {
995            return None;
996        }
997        let pages = halfwords_per_row.div_ceil(ALLOC_COL_W).max(1);
998        let x = self.find_page_run(pages, 256)?;
999        self.set_rect(x, 256, pages * ALLOC_COL_W, TEXTURE_PAGE_TEXELS, true);
1000        Some((
1001            TexturePage::new(x, 256, depth),
1002            VramHandle::Rect(VramRect::new(
1003                x,
1004                256,
1005                pages * ALLOC_COL_W,
1006                TEXTURE_PAGE_TEXELS,
1007            )),
1008        ))
1009    }
1010
1011    /// Release a reservation.
1012    pub fn free(&mut self, handle: VramHandle) {
1013        match handle {
1014            VramHandle::Empty => {}
1015            VramHandle::Rect(rect) => self.set_rect(rect.x, rect.y, rect.w, rect.h, false),
1016            VramHandle::Window(placement) => {
1017                let page = placement.page_index() as usize;
1018                self.room.release(placement);
1019                self.release_room_page_if_empty(page);
1020            }
1021            VramHandle::RoomPage(page) => {
1022                let page = page as usize;
1023                self.room.release_page(page);
1024                self.release_room_page_if_empty(page);
1025            }
1026            VramHandle::Clut { x, y, entries } => self.clut.free(Clut::new(x, y), entries),
1027        }
1028    }
1029}
1030
1031impl<const ROOM_PAGES: usize, const CLUT_ROWS: usize> VramRegionSource
1032    for VramAllocator<ROOM_PAGES, CLUT_ROWS>
1033{
1034    fn alloc_page_run(
1035        &mut self,
1036        count: u16,
1037        depth: TextureDepth,
1038        page_y: u16,
1039    ) -> Option<(TexturePage, VramHandle)> {
1040        let x = self.find_page_run(count, page_y)?;
1041        self.set_rect(x, page_y, count * ALLOC_COL_W, TEXTURE_PAGE_TEXELS, true);
1042        Some((
1043            TexturePage::new(x, page_y, depth),
1044            VramHandle::Rect(VramRect::new(
1045                x,
1046                page_y,
1047                count * ALLOC_COL_W,
1048                TEXTURE_PAGE_TEXELS,
1049            )),
1050        ))
1051    }
1052
1053    fn alloc_clut(&mut self, entries: u16) -> Option<(Clut, VramHandle)> {
1054        let clut = self.clut.alloc(entries)?;
1055        Some((
1056            clut,
1057            VramHandle::Clut {
1058                x: clut.x(),
1059                y: clut.y(),
1060                entries,
1061            },
1062        ))
1063    }
1064}
1065
1066// ======================================================================
1067// Uploads
1068// ======================================================================
1069
1070/// Upload raw 16bpp halfwords into a VRAM rect via GP0 0xA0 + word
1071/// stream. Checks that `pixels.len() * 2 == rect.w * rect.h` -- one
1072/// halfword per pixel, and the FIFO ships 32-bit words containing
1073/// two halfwords each. Odd pixel counts (which round up on
1074/// hardware) panic because they're rarely what the caller wanted.
1075///
1076/// For a 15bpp direct-color upload, each halfword is a
1077/// [`Color555`]. For pre-packed 4bpp / 8bpp index data, each
1078/// halfword packs 4 or 2 indices respectively.
1079pub fn upload_16bpp(rect: VramRect, pixels: &[u16]) {
1080    let expected = rect.pixel_count();
1081    // Literal messages: formatting the counts links core::fmt (about 4 KB)
1082    // into any program where this function is not inlined.
1083    assert!(
1084        pixels.len() as u32 == expected,
1085        "upload_16bpp: pixels.len() != rect.w * rect.h"
1086    );
1087    assert!(
1088        expected.is_multiple_of(2),
1089        "upload_16bpp: odd pixel count not supported; round the rect up"
1090    );
1091
1092    // FIFO path by default: the CL2 silicon probes showed this console's
1093    // DMA controller can latch a channel busy-forever while moving
1094    // nothing, and an upload that wedges freezes boot with no diagnostic
1095    // (the unbounded busy-wait in the DMA path). The FIFO loop is a
1096    // boot-time cost nobody measures; callers that trust their DMA can
1097    // opt in via `dma_copy_to_vram`.
1098    // Two halfwords per 32-bit word, low half first.
1099    // During GP0(0xA0) image transfer the GPU is waiting for data,
1100    // not normal commands; DuckStation clears READY_CMD in this
1101    // state, so stream payload words without polling command-ready.
1102    // An image upload cannot be recorded, so it always reaches the port.
1103    let _pause = pause_recording();
1104    copy_to_vram_header(rect);
1105    let mut i = 0;
1106    while i + 1 < pixels.len() {
1107        let lo = pixels[i] as u32;
1108        let hi = pixels[i + 1] as u32;
1109        psx_io::gpu::write_command_unguarded(lo | (hi << 16));
1110        i += 2;
1111    }
1112}
1113
1114/// Upload raw byte-stream pixel data, interpreted as halfwords in
1115/// little-endian order. Same semantics as [`upload_16bpp`] but
1116/// accepts a `&[u8]` -- useful when the data comes from
1117/// `include_bytes!` of a cooked asset blob, where the returned
1118/// byte array has alignment 1 and a direct `&[u16]` reinterpret
1119/// would be undefined behaviour.
1120///
1121/// `rect.w` still measures *halfwords* (the VRAM native unit).
1122/// The byte length must be exactly `2 × rect.w × rect.h`.
1123pub fn upload_bytes(rect: VramRect, bytes: &[u8]) {
1124    let expected = (rect.pixel_count() as usize) * 2;
1125    assert_eq!(
1126        bytes.len(),
1127        expected,
1128        "upload_bytes: bytes.len() ({}) != 2 × rect.w × rect.h ({})",
1129        bytes.len(),
1130        expected,
1131    );
1132    assert!(
1133        bytes.len() >= 2 && bytes.len().is_multiple_of(2),
1134        "upload_bytes: byte count must be a positive multiple of 2"
1135    );
1136
1137    // FIFO path by default; see upload_16bpp for the silicon rationale.
1138    // Two halfwords per word, low half first -- matches upload_16bpp's
1139    // packing convention. GP0 image copies with an odd halfword count still
1140    // consume one complete final word; its unused high halfword is ignored by
1141    // the GPU, so zero-pad it. See upload_16bpp: image payload writes must not
1142    // wait on the normal command-ready bit.
1143    let _pause = pause_recording();
1144    copy_to_vram_header(rect);
1145    let mut i = 0;
1146    while i < bytes.len() {
1147        psx_io::gpu::write_command_unguarded(packed_upload_word(bytes, i));
1148        i += 4;
1149    }
1150}
1151
1152#[inline]
1153fn packed_upload_word(bytes: &[u8], offset: usize) -> u32 {
1154    u32::from_le_bytes([
1155        bytes[offset],
1156        bytes[offset + 1],
1157        bytes.get(offset + 2).copied().unwrap_or(0),
1158        bytes.get(offset + 3).copied().unwrap_or(0),
1159    ])
1160}
1161
1162/// Upload bytes using aligned word loads when possible. Unaligned and odd
1163/// halfword counts retain [`upload_bytes`]'s zero-padded final-word behavior.
1164pub fn upload_bytes_aligned(rect: VramRect, bytes: &[u8]) {
1165    if cfg!(target_endian = "little")
1166        && bytes.as_ptr().align_offset(4) == 0
1167        && bytes.len().is_multiple_of(4)
1168    {
1169        let words =
1170            // SAFETY: every u32 bit pattern is valid; alignment, extent and
1171            // lifetime are checked above and the input stays immutably borrowed.
1172            unsafe { core::slice::from_raw_parts(bytes.as_ptr().cast::<u32>(), bytes.len() / 4) };
1173        upload_words(rect, words);
1174    } else {
1175        upload_bytes(rect, bytes);
1176    }
1177}
1178
1179/// Upload an already packed little-endian stream of two 16-bit VRAM pixels
1180/// per word.
1181///
1182/// This is the aligned counterpart to [`upload_bytes`]. Runtime-generated
1183/// textures commonly live in a `u32` staging arena already, so accepting the
1184/// packed words directly avoids reconstructing every word from four byte
1185/// loads while retaining the silicon-safe FIFO transfer path.
1186#[doc(alias = "LoadImage")]
1187pub fn upload_words(rect: VramRect, words: &[u32]) {
1188    let expected_words = rect.pixel_count() as usize / 2;
1189    assert!(
1190        rect.pixel_count().is_multiple_of(2),
1191        "upload_words: odd pixel count is not supported"
1192    );
1193    assert_eq!(
1194        words.len(),
1195        expected_words,
1196        "upload_words: words.len() ({}) != rect.w*rect.h/2 ({})",
1197        words.len(),
1198        expected_words,
1199    );
1200    assert!(!words.is_empty(), "upload_words: empty upload");
1201
1202    let _pause = pause_recording();
1203    copy_to_vram_header(rect);
1204    for &word in words {
1205        psx_io::gpu::write_command_unguarded(word);
1206    }
1207}
1208
1209/// Emit the GP0(0xA0) "copy CPU→VRAM" header (destination, halfword extent). Its writes run
1210/// any armed present-queue guard and callers hold a recording pause, so the FIFO payload that
1211/// follows skips the flag test (`write_command_unguarded`); block DMA follows otherwise.
1212#[inline]
1213fn copy_to_vram_header(rect: VramRect) {
1214    // An asynchronously kicked ordering-table walk may still be feeding
1215    // GP0 over this same channel when streaming code uploads from a
1216    // fixed update. Interleaving header words (or reprogramming the
1217    // channel for the block-DMA path) mid-walk corrupts the command
1218    // stream, so drain the channel first. Bounded: a wedged walk must
1219    // not take the upload (or the boot that needs it) down with it.
1220    if !dma::wait_done(Channel::Gpu, dma::DEFAULT_SPINS) {
1221        dma::abort(Channel::Gpu);
1222    }
1223    wait_command_ready();
1224    write_command(gp0::COPY_CPU_TO_VRAM);
1225    write_command(pack_xy(rect.x, rect.y));
1226    write_command(pack_xy(rect.w, rect.h));
1227}
1228
1229/// Upload `words` to `rect` over block-mode DMA (channel 2) instead of
1230/// word-at-a-time FIFO writes, and wait for it. This mirrors PsyQ's
1231/// `LoadImage`: `BS = words-per-row`, `BA = rows`.
1232///
1233/// `words` holds the pixels as little-endian 32-bit words, two 16-bit
1234/// halfwords each, row after row: `(rect.w / 2) * rect.h` words, and only
1235/// that many are sent. Returns `false`, leaving the GPU untouched, when the
1236/// transfer cannot be expressed this way: an odd or zero width, a zero
1237/// height, a row wider than the GPU's 16-word FIFO, or a slice shorter than
1238/// the rectangle. Use [`upload_16bpp`] (the FIFO path) for those.
1239///
1240/// Opt-in: on real hardware the DMA controller can latch a channel
1241/// busy-forever (CL2 probe, 2026-07-31). The completion wait is bounded,
1242/// so a wedge costs a `false` and a partial upload rather than a hang.
1243#[doc(alias = "LoadImage")]
1244pub fn dma_copy_to_vram(_dma: &mut GpuDma, rect: VramRect, words: &[u32]) -> bool {
1245    if words.len() < copy_words(rect) {
1246        return false;
1247    }
1248    // SAFETY: the check above proved `words` holds the whole rectangle, and
1249    // it stays borrowed until this function returns; the wait below, or the
1250    // abort on a wedge, ends the transfer before then.
1251    if !unsafe { kick_copy_to_vram(rect, words.as_ptr()) } {
1252        return false;
1253    }
1254    // On a wedge the GP0(A0) header is already out and the payload did not
1255    // land, so VRAM holds a partial upload either way; report it.
1256    dma::wait_or_abort(Channel::Gpu, dma::DEFAULT_SPINS)
1257}
1258
1259/// [`dma_copy_to_vram`] without the completion wait: send the copy header,
1260/// kick the block DMA and return, so the CPU keeps working while channel 2
1261/// feeds the GPU. Poll [`dma_copy_to_vram_done`]; abort channel 2
1262/// (`psx_io::dma::abort`) if it never finishes. Returns `false`, having
1263/// touched nothing, for a transfer the DMA cannot express (see
1264/// [`dma_copy_to_vram`]).
1265///
1266/// `words` points at the `(rect.w / 2) * rect.h` pixel words, as the slice
1267/// of [`dma_copy_to_vram`] would.
1268///
1269/// # Safety
1270/// `words` must point at that many readable words, alive and unmodified
1271/// until [`dma_copy_to_vram_done`] returns `true` or channel 2 is aborted.
1272/// Until then nothing else may write GP0 or program channel 2, so no
1273/// drawing, other upload or frame submission; the `&mut GpuDma` borrow only
1274/// covers the call itself.
1275#[doc(alias = "LoadImage")]
1276pub unsafe fn dma_copy_to_vram_start(_dma: &mut GpuDma, rect: VramRect, words: *const u32) -> bool {
1277    // SAFETY: the caller upholds this function's contract.
1278    unsafe { kick_copy_to_vram(rect, words) }
1279}
1280
1281/// True once the transfer [`dma_copy_to_vram_start`] kicked has finished.
1282#[inline]
1283pub fn dma_copy_to_vram_done() -> bool {
1284    !dma::is_busy(Channel::Gpu)
1285}
1286
1287/// Words one block-DMA upload of `rect` sends.
1288fn copy_words(rect: VramRect) -> usize {
1289    (rect.w / 2) as usize * rect.h as usize
1290}
1291
1292/// The GP0(A0) header, then a block DMA of the rectangle's words from
1293/// `src`, with no wait. `false` (nothing touched) when the DMA cannot send
1294/// the rectangle whole: an odd or zero width, a zero height, or a row wider
1295/// than the GPU's 16-word FIFO.
1296///
1297/// # Safety
1298/// `src` must point at [`copy_words`] readable words that stay unmodified
1299/// until channel 2 finishes or is aborted.
1300unsafe fn kick_copy_to_vram(rect: VramRect, src: *const u32) -> bool {
1301    if !rect.w.is_multiple_of(2) || rect.w == 0 || rect.h == 0 {
1302        return false;
1303    }
1304    let words_per_row = rect.w / 2;
1305    // GPU block-mode DMA's BCR block size must fit the GPU's 16-word
1306    // FIFO. Wider rows, such as the combined UI font atlas, can appear to
1307    // work in emulators but wedge real hardware; use the GP0 FIFO path
1308    // instead of starting an invalid DMA.
1309    if words_per_row > 16 {
1310        return false;
1311    }
1312    let _pause = pause_recording();
1313    copy_to_vram_header(rect);
1314    // GP1(04h) = 2: route DMA words CPU→GP0. `psx-gpu::init` sets this,
1315    // but a VRAM readback could have flipped it to GPUREAD→CPU.
1316    write_display_control(gp1::dma_direction(2));
1317    dma::enable_channel(Channel::Gpu);
1318    // SAFETY: the channel was drained by `copy_to_vram_header`. The transfer
1319    // reads `words_per_row * rect.h` words from `src`, which the caller
1320    // keeps readable and unmodified until it completes or is aborted.
1321    unsafe {
1322        dma::start(
1323            Channel::Gpu,
1324            dma::Transfer {
1325                address: src as u32,
1326                size: dma::size_blocks(words_per_row, rect.h),
1327                control: psx_hw::dma::CHCR_TO_DEVICE
1328                    | psx_hw::dma::CHCR_SYNC_BLOCK
1329                    | psx_hw::dma::CHCR_START,
1330            },
1331        )
1332    };
1333    true
1334}
1335
1336/// Upload typed [`Color555`] pixels -- sugar over [`upload_16bpp`]
1337/// for direct-color textures.
1338pub fn upload_15bpp(rect: VramRect, pixels: &[Color555]) {
1339    let as_u16: &[u16] = unsafe {
1340        // SAFETY: Color555 is `#[repr(transparent)] u16`, so the
1341        // slice cast is layout-safe.
1342        core::slice::from_raw_parts(pixels.as_ptr() as *const u16, pixels.len())
1343    };
1344    upload_16bpp(rect, as_u16);
1345}
1346
1347/// Upload a CLUT -- a row of [`Color555`]s at `clut`. The caller
1348/// picks the width: 16 entries for 4bpp, 256 for 8bpp. Asserts
1349/// the CLUT fits in VRAM width and the slice length matches.
1350pub fn upload_clut(clut: Clut, entries: &[Color555]) {
1351    let rect = VramRect::new(clut.x(), clut.y(), entries.len() as u16, 1);
1352    upload_15bpp(rect, entries);
1353}
1354
1355// ======================================================================
1356// Tests
1357// ======================================================================
1358
1359/// Renamed to [`TexturePage`].
1360#[deprecated(note = "renamed to `TexturePage`")]
1361pub type Tpage = TexturePage;
1362
1363/// Renamed to [`TextureDepth`].
1364#[deprecated(note = "renamed to `TextureDepth`")]
1365pub type TexDepth = TextureDepth;
1366
1367impl TexturePage {
1368    /// Renamed to [`TexturePage::uv_word`].
1369    #[deprecated(note = "renamed to `uv_word`")]
1370    #[inline(always)]
1371    pub const fn uv_tpage_word(self, semi_trans: u8) -> u16 {
1372        self.uv_word(semi_trans)
1373    }
1374}
1375
1376impl Clut {
1377    /// Renamed to [`Clut::uv_word`].
1378    #[deprecated(note = "renamed to `uv_word`")]
1379    #[inline(always)]
1380    pub const fn uv_clut_word(self) -> u16 {
1381        self.uv_word()
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388
1389    #[test]
1390    fn dma_copy_to_vram_refuses_what_it_cannot_send_whole() {
1391        // Every case returns before touching the GPU, so this runs on the host.
1392        // SAFETY: the only token in this test.
1393        let mut dma = unsafe { GpuDma::steal() };
1394        let words = [0u32; 8];
1395        // Four words per row, two rows: the slice is one word short.
1396        assert!(!dma_copy_to_vram(
1397            &mut dma,
1398            VramRect::new(0, 0, 8, 2),
1399            &words[..7]
1400        ));
1401        // Odd, zero or FIFO-overflowing widths, and a zero height.
1402        assert!(!dma_copy_to_vram(
1403            &mut dma,
1404            VramRect::new(0, 0, 3, 1),
1405            &words
1406        ));
1407        assert!(!dma_copy_to_vram(
1408            &mut dma,
1409            VramRect {
1410                x: 0,
1411                y: 0,
1412                w: 0,
1413                h: 1
1414            },
1415            &words
1416        ));
1417        assert!(!dma_copy_to_vram(
1418            &mut dma,
1419            VramRect::new(0, 0, 34, 1),
1420            &[0; 17]
1421        ));
1422        assert!(!dma_copy_to_vram(
1423            &mut dma,
1424            VramRect {
1425                x: 0,
1426                y: 0,
1427                w: 2,
1428                h: 0
1429            },
1430            &words
1431        ));
1432    }
1433
1434    #[test]
1435    fn byte_upload_word_zero_pads_an_odd_final_halfword() {
1436        assert_eq!(
1437            packed_upload_word(&[0x11, 0x22, 0x33, 0x44], 0),
1438            0x4433_2211
1439        );
1440        assert_eq!(packed_upload_word(&[0x55, 0x66], 0), 0x0000_6655);
1441    }
1442
1443    #[test]
1444    fn stp_is_the_mask_bit_named_for_its_palette_meaning() {
1445        let opaque = Color555::rgb8(255, 255, 255);
1446        assert!(!opaque.is_semi_transparent());
1447        assert_eq!(
1448            opaque.with_semi_transparency().as_u16(),
1449            opaque.with_mask_bit().as_u16()
1450        );
1451        assert!(opaque.with_semi_transparency().is_semi_transparent());
1452        // The fully transparent slot must stay 0x0000: setting STP there would
1453        // make the GPU blend the texel instead of skipping it.
1454        assert_eq!(Color555::TRANSPARENT.as_u16(), 0);
1455        assert!(!Color555::TRANSPARENT.is_semi_transparent());
1456    }
1457
1458    #[test]
1459    fn color_rgb8_truncates_low_bits() {
1460        assert_eq!(Color555::rgb8(0, 0, 0).as_u16(), 0);
1461        assert_eq!(Color555::rgb8(255, 255, 255).as_u16(), 0x7FFF);
1462        // 0x18 = 24 → 24 >> 3 = 3.
1463        assert_eq!(Color555::rgb8(24, 0, 0).as_u16(), 3);
1464    }
1465
1466    #[test]
1467    fn color_rgb5_rejects_out_of_range_via_panic() {
1468        // Compile-time assert check happens in `const fn`; the panic
1469        // path at runtime is the same.
1470        assert_eq!(Color555::rgb5(31, 0, 0).as_u16(), 31);
1471        assert_eq!(Color555::rgb5(0, 31, 0).as_u16(), 31 << 5);
1472        assert_eq!(Color555::rgb5(0, 0, 31).as_u16(), 31 << 10);
1473    }
1474
1475    #[test]
1476    #[should_panic = "red must be < 32"]
1477    fn color_rgb5_panics_on_32() {
1478        let _ = Color555::rgb5(32, 0, 0);
1479    }
1480
1481    #[test]
1482    fn vram_rect_new_accepts_valid_positions() {
1483        let _ = VramRect::new(0, 0, 64, 64);
1484        let _ = VramRect::new(VRAM_WIDTH - 1, 0, 1, 1);
1485        let _ = VramRect::new(0, VRAM_HEIGHT - 1, 1, 1);
1486    }
1487
1488    #[test]
1489    #[should_panic = "overflows VRAM width"]
1490    fn vram_rect_new_rejects_horizontal_overflow() {
1491        let _ = VramRect::new(1000, 0, 100, 10);
1492    }
1493
1494    #[test]
1495    fn texture_window_atlas_packs_32x32_textures_inside_one_page() {
1496        let mut atlas = TextureWindowAtlas::<1>::new();
1497
1498        let first = atlas.allocate(32, 32).expect("first texture fits");
1499        let second = atlas.allocate(32, 32).expect("second texture fits");
1500        let eighth = {
1501            let mut placement = second;
1502            for _ in 2..8 {
1503                placement = atlas.allocate(32, 32).expect("top row texture fits");
1504            }
1505            placement
1506        };
1507        let ninth = atlas.allocate(32, 32).expect("second row texture fits");
1508
1509        assert_eq!(first.page_index(), 0);
1510        assert_eq!((first.origin_u(), first.origin_v()), (0, 0));
1511        assert_eq!((second.origin_u(), second.origin_v()), (32, 0));
1512        assert_eq!((eighth.origin_u(), eighth.origin_v()), (224, 0));
1513        assert_eq!((ninth.origin_u(), ninth.origin_v()), (0, 32));
1514    }
1515
1516    #[test]
1517    fn texture_window_atlas_moves_to_next_page_when_current_page_is_full() {
1518        let mut atlas = TextureWindowAtlas::<2>::new();
1519
1520        for _ in 0..16 {
1521            atlas
1522                .allocate(64, 64)
1523                .expect("64x64 slot fits in first page");
1524        }
1525        let next = atlas.allocate(64, 64).expect("second page has space");
1526
1527        assert_eq!(next.page_index(), 1);
1528        assert_eq!((next.origin_u(), next.origin_v()), (0, 0));
1529    }
1530
1531    #[test]
1532    fn texture_window_atlas_reserves_empty_page_for_unwindowed_texture() {
1533        let mut atlas = TextureWindowAtlas::<2>::new();
1534
1535        let page = atlas.reserve_empty_page().expect("page reserved");
1536        assert_eq!(page, 0);
1537        let next = atlas.allocate(64, 64).expect("second page still free");
1538
1539        assert_eq!(next.page_index(), 1);
1540        assert_eq!((next.origin_u(), next.origin_v()), (0, 0));
1541    }
1542
1543    #[test]
1544    fn texture_window_atlas_aligns_each_allocation_to_its_own_size() {
1545        let mut atlas = TextureWindowAtlas::<1>::new();
1546
1547        atlas.allocate(32, 32).expect("small texture fits");
1548        let large = atlas.allocate(64, 64).expect("large texture fits");
1549
1550        assert_eq!((large.origin_u(), large.origin_v()), (64, 0));
1551    }
1552
1553    #[test]
1554    fn texture_window_atlas_rejects_non_texture_window_dimensions() {
1555        let mut atlas = TextureWindowAtlas::<1>::new();
1556
1557        assert!(atlas.allocate(48, 32).is_none());
1558        assert!(atlas.allocate(4, 32).is_none());
1559        assert!(atlas.allocate(256, 32).is_none());
1560    }
1561
1562    #[test]
1563    fn tpage_encodes_uv_word_correctly() {
1564        let tp = TexturePage::new(640, 0, TextureDepth::Bit15);
1565        // x=640 → tpx=10, y=0 → tpy=0, depth=15bpp → 2.
1566        let word = tp.uv_word(0);
1567        assert_eq!(word & 0xF, 10, "tpx");
1568        assert_eq!((word >> 4) & 1, 0, "tpy");
1569        assert_eq!((word >> 5) & 3, 0, "semi_trans");
1570        assert_eq!((word >> 7) & 3, 2, "depth");
1571    }
1572
1573    #[test]
1574    #[should_panic = "x must be a multiple of 64"]
1575    fn tpage_rejects_misaligned_x() {
1576        let _ = TexturePage::new(32, 0, TextureDepth::Bit8);
1577    }
1578
1579    #[test]
1580    #[should_panic = "y must be 0 or 256"]
1581    fn tpage_rejects_bad_y() {
1582        let _ = TexturePage::new(0, 128, TextureDepth::Bit4);
1583    }
1584
1585    #[test]
1586    fn clut_encodes_uv_word_correctly() {
1587        let cl = Clut::new(640, 240);
1588        // x=640 → cx=40, y=240 → cy=240.
1589        let word = cl.uv_word();
1590        assert_eq!(word & 0x3F, 40, "cx");
1591        assert_eq!((word >> 6) & 0x1FF, 240, "cy");
1592    }
1593
1594    #[test]
1595    #[should_panic = "x must be a multiple of 16"]
1596    fn clut_rejects_misaligned_x() {
1597        let _ = Clut::new(8, 0);
1598    }
1599
1600    #[test]
1601    fn tex_depth_texels_per_halfword() {
1602        assert_eq!(TextureDepth::Bit4.texels_per_halfword(), 4);
1603        assert_eq!(TextureDepth::Bit8.texels_per_halfword(), 2);
1604        assert_eq!(TextureDepth::Bit15.texels_per_halfword(), 1);
1605    }
1606
1607    #[test]
1608    fn vram_alloc_reserves_framebuffer_then_packs_pages_past_it() {
1609        let mut a = VramAllocator::<6, 16>::new(480);
1610        a.reserve_rect(VramRect::new(0, 0, 320, 480));
1611        let (tp, _h) = a
1612            .alloc_page_run(3, TextureDepth::Bit4, 0)
1613            .expect("3 contiguous pages");
1614        assert_eq!(tp.x() % 64, 0, "page X is 64-aligned");
1615        assert!(tp.x() >= 320, "page must not overlap the framebuffer");
1616        assert_eq!(tp.y(), 0);
1617    }
1618
1619    #[test]
1620    fn vram_alloc_free_round_trips_a_page() {
1621        let mut a = VramAllocator::<6, 16>::new(480);
1622        let (tp1, h1) = a.alloc_page_run(1, TextureDepth::Bit4, 0).unwrap();
1623        let (tp2, _h2) = a.alloc_page_run(1, TextureDepth::Bit4, 0).unwrap();
1624        assert_ne!(tp1.x(), tp2.x());
1625        a.free(h1);
1626        let (tp3, _h3) = a.alloc_page_run(1, TextureDepth::Bit4, 0).unwrap();
1627        assert_eq!(tp3.x(), tp1.x(), "freed page is reused");
1628    }
1629
1630    #[test]
1631    fn vram_alloc_window_lands_in_reserved_room_band() {
1632        let mut a = VramAllocator::<2, 16>::new(480);
1633        a.reserve_room_band(640, 0);
1634        let (tp, pl, _h) = a.alloc_window(32, 32).expect("window fits");
1635        assert_eq!(tp.x(), 640, "first window is at room band base");
1636        assert_eq!((pl.origin_u(), pl.origin_v()), (0, 0));
1637    }
1638
1639    #[test]
1640    fn lazy_room_page_returns_physical_backing_after_last_window() {
1641        let mut a = VramAllocator::<6, 16>::new(480);
1642        a.reserve_rect(VramRect::new(0, 0, 320, 480));
1643        a.reserve_room_band(640, 0);
1644        let (_, _, window) = a.alloc_window(64, 64).expect("room window");
1645        assert!(
1646            a.alloc_page_run(6, TextureDepth::Bit4, 0).is_none(),
1647            "live preferred room page interrupts the six-page top run"
1648        );
1649        a.free(window);
1650        assert!(
1651            a.alloc_page_run(6, TextureDepth::Bit4, 0).is_some(),
1652            "last-window release must return the physical room page"
1653        );
1654    }
1655
1656    #[test]
1657    fn model_slots_preserve_indexed_texture_depth() {
1658        let mut a = VramAllocator::<2, 16>::new(480);
1659        let (bit4, _h4) = a.alloc_model_slot(32, TextureDepth::Bit4).unwrap();
1660        let (bit8, _h8) = a.alloc_model_slot(64, TextureDepth::Bit8).unwrap();
1661
1662        assert_eq!(bit4.depth(), TextureDepth::Bit4);
1663        assert_eq!(bit8.depth(), TextureDepth::Bit8);
1664        assert_ne!(bit4.x(), bit8.x());
1665        assert!(a.alloc_model_slot(64, TextureDepth::Bit15).is_none());
1666    }
1667
1668    #[test]
1669    fn clut_row_allocator_packs_and_reuses_slots() {
1670        let mut c = ClutRowAllocator::<4>::new(480);
1671        let a = c.alloc(16).unwrap();
1672        let b = c.alloc(16).unwrap();
1673        assert_eq!((a.x(), a.y()), (0, 480));
1674        assert_eq!((b.x(), b.y()), (16, 480));
1675        let big = c.alloc(256).unwrap(); // 16 contiguous slots
1676        assert_eq!((big.x(), big.y()), (32, 480));
1677        c.free(a, 16);
1678        let reused = c.alloc(16).unwrap();
1679        assert_eq!((reused.x(), reused.y()), (0, 480), "freed slot reused");
1680    }
1681
1682    #[test]
1683    fn texture_window_atlas_release_frees_for_reuse() {
1684        let mut atlas = TextureWindowAtlas::<1>::new();
1685        let first = atlas.allocate(32, 32).unwrap();
1686        let _second = atlas.allocate(32, 32).unwrap();
1687        atlas.release(first);
1688        let reused = atlas.allocate(32, 32).unwrap();
1689        assert_eq!(
1690            (reused.origin_u(), reused.origin_v()),
1691            (first.origin_u(), first.origin_v()),
1692            "released slot is reused"
1693        );
1694    }
1695
1696    #[test]
1697    fn overlapping_layout_rectangles_are_found_in_listed_order() {
1698        // Disjoint: the two framebuffers and the block under them.
1699        let clean = [
1700            VramRect::new(0, 0, 320, 240),
1701            VramRect::new(0, 240, 320, 240),
1702            VramRect::new(0, 480, 320, 32),
1703        ];
1704        assert_eq!(first_vram_overlap(&clean), None);
1705        assert!(vram_layout_is_disjoint(&clean));
1706
1707        // The hl-psx bug in miniature: CLUT rows at Y=480..503 that run past
1708        // X=320 sit inside a texture page banded at Y=256.
1709        let clashing = [
1710            VramRect::new(0, 0, 320, 240),
1711            VramRect::new(320, 256, 64, 256),
1712            VramRect::new(0, 480, 640, 24),
1713        ];
1714        assert_eq!(first_vram_overlap(&clashing), Some((1, 2)));
1715        assert!(!vram_layout_is_disjoint(&clashing));
1716
1717        // Touching edges are not an overlap.
1718        let touching = [
1719            VramRect::new(0, 0, 320, 240),
1720            VramRect::new(320, 0, 64, 240),
1721        ];
1722        assert_eq!(first_vram_overlap(&touching), None);
1723        let stacked = [VramRect::new(0, 0, 320, 240), VramRect::new(0, 240, 320, 8)];
1724        assert_eq!(first_vram_overlap(&stacked), None);
1725
1726        // A single rect, and none at all, are trivially disjoint.
1727        assert!(vram_layout_is_disjoint(&[VramRect::new(0, 0, 1, 1)]));
1728        assert!(vram_layout_is_disjoint(&[]));
1729    }
1730}