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

psx_font/
lib.rs

1//! Bitmap-font atlases for the PS1.
2//!
3//! One crate, two types, three steps:
4//!
5//! 1. Declare (or pick) a [`BitmapFont`] -- a `static` descriptor
6//!    that carries glyph dimensions, a 1-bit-per-pixel bitmap, and
7//!    layout metadata (advance, line height, bit order). The
8//!    built-in fonts in [`fonts`] cover Public-Domain IBM-VGA-style
9//!    8×8 for ASCII / Latin-1 / box-drawing.
10//!
11//! 2. Upload it into VRAM once with [`FontAtlas::upload`] -- the
12//!    crate picks a sensible atlas layout, expands the 1bpp source
13//!    into a 4bpp CLUT texture, uploads that + a two-entry CLUT
14//!    (transparent + white), and returns a handle.
15//!
16//! 3. Call one of the [`FontAtlas`] draw methods every frame. The
17//!    same atlas supports both the fast rectangle path and the
18//!    flexible quad path -- choose based on what the call needs.
19//!
20//! ## Draw-path cheat sheet
21//!
22//! | Method | Hardware | Cost / glyph | Does |
23//! |---|---|---|---|
24//! | [`FontAtlas::draw_text`] | GP0 0x64 textured rect | 4 words | 1:1 axis-aligned, single tint |
25//! | [`FontAtlas::draw_text_scaled`] | GP0 0x2C textured quad | 9 words | Integer scale (2×, 3×, …), single tint |
26//! | [`FontAtlas::draw_text_scaled_q8`] | GP0 0x2C textured quad | 9 words | Q8 fractional scale (1.5× = 384), single tint |
27//! | [`FontAtlas::draw_text_rotated`] | GP0 0x2C textured quad | 9 words | Arbitrary angle rotation, single tint |
28//! | [`FontAtlas::draw_text_affine`] | GP0 0x2C textured quad | 9 words | Arbitrary 2×2 matrix, single tint |
29//! | [`FontAtlas::draw_text_gradient`] | GP0 0x3C gouraud-textured quad | 12 words | 1:1, top/bottom gradient |
30//! | [`FontAtlas::draw_text_scaled_gradient`] | GP0 0x3C gouraud-textured quad | 12 words | Scaled + top/bottom gradient |
31//!
32//! `draw_text` is the fast default. Everything below it is a quad
33//! primitive that pays ~2–3× the GP0 bandwidth for transforms or
34//! per-corner colour. At PS1 scale, this matters for text-heavy UIs
35//! (credit crawls, RPG dialogue walls) and doesn't for a HUD of a
36//! few dozen glyphs. Callers pick consciously.
37//!
38//! All methods share the same atlas and CLUT -- no duplicate VRAM.
39//! `draw_text_*` variants can freely mix in one frame, and tints
40//! compose with the PSX per-texel multiplier (`output = texel *
41//! tint / 128`).
42//!
43//! ## Generic over glyph dimensions
44//!
45//! Nothing in [`BitmapFont`] or [`FontAtlas`] hard-codes 8×8.
46//! Declare a second font at 6×12 or 8×16, drop it into VRAM at a
47//! different tpage, and you can mix sizes on the same screen.
48//! What's size-dependent:
49//!
50//! - **Atlas layout**: the uploader picks `glyphs_per_row` so the
51//!   atlas fits within its tpage.
52//! - **Upload buffer**: the stack-only scratch buffer inside
53//!   [`FontAtlas::upload`] is 16 KiB, enough for 128 glyphs at 16×16
54//!   or 64 glyphs at 32×16. Larger atlases panic today; use
55//!   [`upload_fonts`] with caller-owned scratch for them.
56//!
57//! ## Why 4bpp and not 15bpp direct
58//!
59//! 4bpp uses 1/4 the VRAM of 15bpp and opens the standard PSX
60//! "colour a monochrome glyph via tint" trick. A 96-glyph ASCII
61//! 8×8 atlas fits in 6×8 halfwords = 96 halfwords = 192 bytes of
62//! VRAM. Direct-15bpp would be 768 bytes, and you'd lose the free
63//! recolouring.
64//!
65//! ## Coordinate conventions
66//!
67//! - `draw_text` / `draw_text_scaled` / `draw_text_gradient`:
68//!   `(x, y)` is the **top-left** of the string's first glyph.
69//! - `draw_text_rotated`: `(cx, cy)` is the rotation **pivot** --
70//!   the centre of the baseline. Positive angles rotate
71//!   counter-clockwise in screen coords.
72//! - `draw_text_affine`: `origin` is the point the 2×2 transform
73//!   maps to the top-left of the first glyph. The matrix is applied
74//!   in glyph-local space before translating to `origin`.
75//!
76//! ## Fixed-point conventions (rotation + affine)
77//!
78//! Rotation uses a Q0.12 angle: `u16` in `[0, 4096)` mapping to
79//! `[0°, 360°)`. Sin/cos come from the shared SDK sin LUT in
80//! [`psx_math::sincos`] -- see that crate for the precision /
81//! Q-format specifics.
82//!
83//! Affine matrices are Q3.12 -- `i16` with 12 fractional bits, so
84//! `4096` = 1.0, `-4096` = -1.0, `8192` = 2.0, and the usable
85//! range is `±7.999…`. That's enough headroom for any visually
86//! reasonable 2×2 transform a bitmap font would want.
87
88#![no_std]
89#![deny(unsafe_op_in_unsafe_fn)]
90#![warn(missing_docs)]
91
92use psx_hw::gpu::packet;
93use psx_hw::gpu::{gp0, pack_color, pack_texcoord, pack_vertex, pack_xy};
94use psx_io::gpu::{wait_command_ready, write_command};
95use psx_math::sincos;
96use psx_vram::{
97    upload_16bpp, upload_clut, Clut, Color555, TextureDepth, TexturePage, VramHandle, VramRect,
98    VramRegionSource,
99};
100
101pub mod fonts;
102pub mod hex;
103
104pub use hex::{format_u16, HexU16};
105
106// ======================================================================
107// BitmapFont -- the static descriptor
108// ======================================================================
109
110/// Bit-packing convention within each bitmap byte.
111///
112/// - [`BitOrder::Lsb`]: bit 0 of each byte is the **leftmost** pixel.
113///   Matches the dhepper/font8x8 convention our built-in fonts use.
114/// - [`BitOrder::Msb`]: bit 7 is the leftmost pixel. Matches the IBM
115///   VGA BIOS ROM / Linux `fbcon` `font_8x8.c` / GRUB conventions.
116///
117/// Fonts imported from different sources carry different bit orders;
118/// the uploader handles both transparently.
119#[derive(Copy, Clone, Debug, PartialEq, Eq)]
120pub enum BitOrder {
121    /// Bit 0 is the leftmost pixel.
122    Lsb,
123    /// Bit 7 is the leftmost pixel.
124    Msb,
125}
126
127/// A bitmap font as a static data descriptor.
128///
129/// All fields are compile-time constants so fonts can live in
130/// `.rodata` and a `BitmapFont` value can be declared as a `const`
131/// right next to its bitmap. The type stays size-agnostic -- 6×8,
132/// 8×8, 8×16, 12×16 all work; the uploader reads `glyph_w` /
133/// `glyph_h` and lays out the atlas from there.
134///
135/// Glyph `i` in the `bitmap` slice occupies
136/// `bitmap[i * row_bytes * glyph_h .. i * row_bytes * glyph_h +
137/// row_bytes * glyph_h]`, where `row_bytes = ceil(glyph_w / 8)`.
138/// For 8-wide glyphs that's one byte per row. Wider glyphs (12,
139/// 16 px) use multiple bytes per row, MSB-first within the row
140/// (i.e., byte 0 covers columns 0..=7, byte 1 covers columns
141/// 8..=15, regardless of [`BitOrder`] within each byte).
142///
143/// The `first_char` / `glyph_count` window is a codepoint range --
144/// code `c` is looked up at offset `c - first_char` in the bitmap
145/// as long as `first_char <= c < first_char + glyph_count`.
146/// Anything outside falls back to the missing-glyph box.
147#[derive(Copy, Clone, Debug)]
148pub struct BitmapFont {
149    /// Glyph cell width in pixels.
150    pub glyph_w: u8,
151    /// Glyph cell height in pixels.
152    pub glyph_h: u8,
153    /// First codepoint covered. For a basic-Latin font this is
154    /// usually `0x00` (null) or `0x20` (space).
155    pub first_char: u16,
156    /// Number of glyphs in this font. The range
157    /// `first_char..first_char + glyph_count` is the supported set.
158    pub glyph_count: u16,
159    /// Row-major bitmap bytes. `glyph_count * glyph_h *
160    /// ceil(glyph_w / 8)` bytes total.
161    pub bitmap: &'static [u8],
162    /// Optional per-glyph pixel advances. Proportional TTF imports fill this;
163    /// fixed-cell bitmap fonts leave it `None` and use `advance_x`.
164    pub glyph_advances: Option<&'static [u8]>,
165    /// Pixel step between adjacent characters on a text line.
166    /// Usually `== glyph_w` for fixed-width fonts. Proportional fonts use this
167    /// as the fallback for missing/out-of-range glyphs.
168    pub advance_x: u8,
169    /// Pixel step between text lines. Usually `== glyph_h`.
170    pub line_height: u8,
171    /// Bit packing within each bitmap byte.
172    pub bit_order: BitOrder,
173}
174
175impl BitmapFont {
176    /// Bytes per row of a single glyph in the source bitmap.
177    /// Derived from [`BitmapFont::glyph_w`] -- 1 byte for ≤ 8-wide
178    /// fonts, 2 bytes for 9..16-wide, etc.
179    pub const fn row_bytes(&self) -> usize {
180        (self.glyph_w as usize).div_ceil(8)
181    }
182
183    /// Total bitmap bytes per glyph.
184    pub const fn glyph_stride(&self) -> usize {
185        self.row_bytes() * self.glyph_h as usize
186    }
187
188    /// Glyph index for `ch` if the font covers its codepoint.
189    pub fn glyph_index(&self, ch: char) -> Option<u16> {
190        let cp = ch as u32;
191        let first = self.first_char as u32;
192        let end = first.saturating_add(self.glyph_count as u32);
193        if cp >= first && cp < end {
194            Some((cp - first) as u16)
195        } else {
196            None
197        }
198    }
199
200    /// Pixel advance for `ch`, falling back to [`BitmapFont::advance_x`] when
201    /// the font has no per-glyph table or `ch` is outside the covered range.
202    pub fn glyph_advance(&self, ch: char) -> u8 {
203        self.glyph_index(ch)
204            .and_then(|index| {
205                self.glyph_advances
206                    .and_then(|advances| advances.get(index as usize).copied())
207            })
208            .unwrap_or(self.advance_x)
209    }
210
211    /// Pixel width of `text` with this font's advance metrics.
212    pub fn text_width(&self, text: &str) -> u16 {
213        text.chars()
214            .map(|ch| self.glyph_advance(ch) as u16)
215            .fold(0u16, u16::saturating_add)
216    }
217
218    /// Pixel width of `text` with an extra signed spacing value inserted
219    /// between adjacent characters. The spacing is in final screen pixels,
220    /// so callers that scale glyphs can decide whether or not to scale the
221    /// spacing separately.
222    pub fn text_width_with_spacing(&self, text: &str, letter_spacing: i8) -> u16 {
223        let mut chars = text.chars();
224        let Some(first) = chars.next() else {
225            return 0;
226        };
227        let mut width = i32::from(self.glyph_advance(first));
228        for ch in chars {
229            width = width
230                .saturating_add(i32::from(letter_spacing))
231                .saturating_add(i32::from(self.glyph_advance(ch)));
232        }
233        width.clamp(0, i32::from(u16::MAX)) as u16
234    }
235
236    /// Fetch row `r` of glyph `i` as 4 bytes LSB-packed little-
237    /// endian -- the row comes out as a `u32` with pixel 0 in bit
238    /// 0, pixel 1 in bit 1, … up to pixel 31 (enough for any
239    /// realistic cell width). Handles [`BitOrder`] normalisation
240    /// in one place so callers don't have to branch.
241    pub fn glyph_row_packed(&self, i: u16, r: u8) -> u32 {
242        let base = i as usize * self.glyph_stride() + r as usize * self.row_bytes();
243        let mut out: u32 = 0;
244        for byte_idx in 0..self.row_bytes() {
245            let raw = self.bitmap[base + byte_idx] as u32;
246            let normalised = match self.bit_order {
247                BitOrder::Lsb => raw,
248                BitOrder::Msb => raw.reverse_bits() >> 24,
249            };
250            out |= normalised << (byte_idx * 8);
251        }
252        out
253    }
254}
255
256// ======================================================================
257// FontAtlas -- the VRAM handle
258// ======================================================================
259
260/// A [`BitmapFont`] installed in VRAM and ready to draw from.
261///
262/// Hold on to the returned handle for the lifetime you want glyphs
263/// to stay drawable. Uploading costs ~1 GP0 0xA0 transfer of
264/// `glyph_count × glyph_w × glyph_h / 4` bytes (plus 2 halfwords
265/// for the CLUT); draw calls cost 4 GP0 words per glyph.
266#[derive(Copy, Clone, Debug)]
267pub struct FontAtlas {
268    font: &'static BitmapFont,
269    tpage: TexturePage,
270    /// Pre-encoded CLUT word. Storing the two-byte packet value instead of
271    /// the four-byte coordinate handle pays for `uv_origin` without growing
272    /// `FontAtlas` in PS1 RAM, and removes repeated encoding from draw calls.
273    clut_word: u16,
274    /// How many glyphs per row of the atlas texture. Picked at
275    /// upload time so the texture fits within a single 4bpp tpage.
276    glyphs_per_row: u16,
277    /// Page-local texel origin of this atlas inside a packed tpage.
278    uv_origin: (u8, u8),
279}
280
281// The packed origin replaces the space recovered by caching the CLUT packet
282// word, so adding shared-page placement must not grow every resident atlas.
283#[cfg(target_arch = "mips")]
284const _: () = assert!(core::mem::size_of::<FontAtlas>() == 16);
285
286fn scale_q8_i16(value: i16, scale_q8: u16) -> i16 {
287    let scaled = i32::from(value)
288        .saturating_mul(i32::from(scale_q8))
289        .saturating_add(128)
290        >> 8;
291    scaled.clamp(1, i32::from(i16::MAX)) as i16
292}
293
294fn round_q8_to_i16(value_q8: i32) -> i16 {
295    let rounded = if value_q8 >= 0 {
296        value_q8.saturating_add(128) >> 8
297    } else {
298        -(value_q8.saturating_neg().saturating_add(128) >> 8)
299    };
300    rounded.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
301}
302
303#[inline]
304fn reset_texture_window() {
305    wait_command_ready();
306    write_command(gp0::tex_window(0, 0, 0, 0));
307}
308
309/// GPU semi-transparency equation for [`FontAtlas::draw_text_blended`].
310///
311/// The PSX blends a semi-transparent primitive against the framebuffer
312/// with one of four fixed equations. The choice is *not* per-primitive:
313/// the primitive only carries a "blend me" bit, and the equation comes
314/// from the two-bit ABR field of the current GP0(E1h) draw mode. These
315/// are the same four the rest of the SDK exposes as `psx_gpu::BlendMode`,
316/// restated here so the font crate does not take a dependency on the
317/// primitive layer to name two bits.
318#[derive(Copy, Clone, Debug, PartialEq, Eq)]
319pub enum TextBlend {
320    /// `(background + foreground) / 2`. A fixed 50% wash; the tint still
321    /// recolours, but it cannot fade a glyph away -- at tint zero the
322    /// glyph still halves whatever is behind it.
323    Average,
324    /// `background + foreground`, clamped per channel. The mode to reach
325    /// for when text has to fade: scaling the tint toward black scales
326    /// the contribution toward nothing, so the glyph dissolves instead
327    /// of darkening. Reads as emissive, which suits HUD and combat text.
328    Add,
329    /// `background - foreground`, clamped per channel. Darkens whatever
330    /// is behind the glyph, so it works as a drop shadow over a light
331    /// background as well as a dark one.
332    Subtract,
333    /// `background + foreground / 4`, clamped per channel. A quarter-
334    /// strength [`Self::Add`] for a subtler glow.
335    AddQuarter,
336}
337
338impl TextBlend {
339    /// The two-bit draw-mode semi-transparency ("ABR") field selecting
340    /// this equation.
341    #[doc(alias = "ABR")]
342    pub const fn semi_transparency_bits(self) -> u8 {
343        match self {
344            Self::Average => 0,
345            Self::Add => 1,
346            Self::Subtract => 2,
347            Self::AddQuarter => 3,
348        }
349    }
350}
351
352/// GP0 command byte for a variable-size textured rectangle with the
353/// semi-transparency bit (25) set. The opaque path uses `0x6400_0000`.
354const SEMI_TRANSPARENT_RECT_CMD: u32 = packet::TEXTURED_RECT | packet::SEMI_TRANSPARENT;
355
356/// GP0 command byte for a variable-size MONOCHROME rectangle with the
357/// semi-transparency bit (25) set. The opaque form is `0x6000_0000`.
358///
359/// Untextured, so it costs three data words however large it is --
360/// which is what makes a backing plate cheaper than a second pass over
361/// the glyphs it sits behind.
362const SEMI_TRANSPARENT_FLAT_RECT_CMD: u32 = packet::FLAT_RECT | packet::SEMI_TRANSPARENT;
363
364/// The GP0(E1h) word that installs `tpage` with `blend`'s equation in
365/// the ABR field. Split out from the draw call so the bit layout is
366/// checkable on the host, where no GPU exists to observe.
367const fn blended_draw_mode_word(tpage: TexturePage, blend: TextBlend) -> u32 {
368    gp0::draw_mode(
369        (tpage.x() / 64) as u32,
370        if tpage.y() == 256 { 1 } else { 0 },
371        blend.semi_transparency_bits() as u32,
372        tpage.depth() as u32,
373        false,
374        true,
375    )
376}
377
378#[inline]
379fn pack_uv_word(u: u8, v: u8, high_word: u16) -> u32 {
380    u as u32 | ((v as u32) << 8) | ((high_word as u32) << 16)
381}
382
383#[inline]
384fn write_textured_quad_packet(
385    verts: [(i16, i16); 4],
386    uvs: [(u8, u8); 4],
387    color_cmd: u32,
388    clut_word: u16,
389    tpage_word: u16,
390) {
391    wait_command_ready();
392    write_command(color_cmd);
393    write_command(pack_vertex(verts[0].0, verts[0].1));
394    write_command(pack_uv_word(uvs[0].0, uvs[0].1, clut_word));
395    write_command(pack_vertex(verts[1].0, verts[1].1));
396    write_command(pack_uv_word(uvs[1].0, uvs[1].1, tpage_word));
397    write_command(pack_vertex(verts[2].0, verts[2].1));
398    write_command(pack_uv_word(uvs[2].0, uvs[2].1, 0));
399    write_command(pack_vertex(verts[3].0, verts[3].1));
400    write_command(pack_uv_word(uvs[3].0, uvs[3].1, 0));
401}
402
403#[inline]
404fn write_textured_gouraud_quad_packet(
405    verts: [(i16, i16); 4],
406    uvs: [(u8, u8); 4],
407    colors: [(u8, u8, u8); 4],
408    color0_cmd: u32,
409    clut_word: u16,
410    tpage_word: u16,
411) {
412    wait_command_ready();
413    write_command(color0_cmd);
414    write_command(pack_vertex(verts[0].0, verts[0].1));
415    write_command(pack_uv_word(uvs[0].0, uvs[0].1, clut_word));
416    write_command(pack_color(colors[1].0, colors[1].1, colors[1].2));
417    write_command(pack_vertex(verts[1].0, verts[1].1));
418    write_command(pack_uv_word(uvs[1].0, uvs[1].1, tpage_word));
419    write_command(pack_color(colors[2].0, colors[2].1, colors[2].2));
420    write_command(pack_vertex(verts[2].0, verts[2].1));
421    write_command(pack_uv_word(uvs[2].0, uvs[2].1, 0));
422    write_command(pack_color(colors[3].0, colors[3].1, colors[3].2));
423    write_command(pack_vertex(verts[3].0, verts[3].1));
424    write_command(pack_uv_word(uvs[3].0, uvs[3].1, 0));
425}
426
427/// VRAM reservations backing a set of fonts uploaded together by
428/// [`upload_fonts`]. Free both handles through the allocator to reclaim the
429/// VRAM when the owning scene is torn down.
430#[derive(Copy, Clone, Debug)]
431pub struct FontSetVram {
432    /// The contiguous page-run reservation that holds every atlas.
433    pub pages: VramHandle,
434    /// The shared 2-entry CLUT reservation.
435    pub clut: VramHandle,
436}
437
438/// Atlas cell width: the glyph width rounded up to a whole halfword (4
439/// texels at 4bpp), so every glyph starts on a halfword boundary. See the
440/// note in [`FontAtlas::upload`] for why that matters on real hardware.
441pub(crate) fn cell_width(font: &BitmapFont) -> u16 {
442    (font.glyph_w as u16).max(1).div_ceil(4) * 4
443}
444
445/// Atlas layout for `font`: `(glyphs_per_row, atlas_w_texels, atlas_h_texels,
446/// halfwords_per_row)`. Mirrors the layout [`FontAtlas::upload`] computes.
447fn font_atlas_dims(font: &BitmapFont) -> (u16, u16, u16, u16) {
448    let cell_w = cell_width(font);
449    let glyph_h = font.glyph_h as u16;
450    let max_cols = (FontAtlas::MAX_ATLAS_W_TEXELS / cell_w).max(1);
451    let glyphs_per_row = font.glyph_count.min(max_cols).max(1);
452    let glyph_rows = font.glyph_count.div_ceil(glyphs_per_row);
453    let atlas_w = glyphs_per_row * cell_w;
454    let atlas_h = glyph_rows * glyph_h;
455    // Even stride: `upload_16bpp` moves whole words and rejects an odd
456    // pixel count, and cell padding can leave an odd halfword row (KENNEY
457    //_PIXEL lands on 63). The spare column stays zero.
458    let halfwords_per_row = atlas_w.div_ceil(4).next_multiple_of(2);
459    (glyphs_per_row, atlas_w, atlas_h, halfwords_per_row)
460}
461
462/// The four corner UVs (texel coords) of a glyph cell whose top-left is
463/// `(u, v)` and size is `gw × gh`, with the far edge **saturated at 255**
464/// instead of wrapping. Corner order matches the quad-path vertices: top-left,
465/// top-right, bottom-left, bottom-right.
466///
467/// A full-width 256-texel atlas (e.g. 16 columns of 16px glyphs) puts the
468/// rightmost column at `u = 240`, so the far U is `240 + 16 = 256`, which
469/// wraps to `0` in the `u8` UV field and makes the GPU interpolate U from 240
470/// down to 0 across the quad -- smearing that glyph (the classic 256-wide
471/// texture right-edge bug). Saturating pins the far edge to the last texel.
472fn glyph_quad_uvs(u: u8, v: u8, gw: u8, gh: u8) -> [(u8, u8); 4] {
473    let u1 = u.saturating_add(gw);
474    let v1 = v.saturating_add(gh);
475    [(u, v), (u1, v), (u, v1), (u1, v1)]
476}
477
478/// Halfwords spanned by one 4bpp texture page (64 halfwords = 256 texels).
479const FONT_PAGE_HALFWORDS: usize = 64;
480
481/// Maximum number of font atlases accepted by one packed upload. The game
482/// manifest currently caps a scene at eight; keeping this bound at sixteen
483/// also matches the number of 4bpp page columns in 1024-wide VRAM.
484const MAX_PACKED_FONT_ATLASES: usize = 16;
485
486#[derive(Copy, Clone, Debug, PartialEq, Eq)]
487struct AtlasPlacement {
488    page: u16,
489    x_halfwords: u16,
490    y: u16,
491}
492
493/// VRAM and scratch requirements for one packed font-set upload.
494#[derive(Copy, Clone, Debug, PartialEq, Eq)]
495pub struct FontPackMetrics {
496    /// Minimum contiguous page run requested by the bounded packer.
497    pub pages: u16,
498    /// Rows transferred by the single combined `GP0(A0h)` upload.
499    pub upload_rows: u16,
500    /// Caller-owned scratch halfwords required for that upload.
501    pub scratch_halfwords: usize,
502}
503
504#[inline]
505fn placements_overlap(
506    a: AtlasPlacement,
507    a_w: u16,
508    a_h: u16,
509    b: AtlasPlacement,
510    b_w: u16,
511    b_h: u16,
512) -> bool {
513    a.page == b.page
514        && a.x_halfwords < b.x_halfwords + b_w
515        && b.x_halfwords < a.x_halfwords + a_w
516        && a.y < b.y + b_h
517        && b.y < a.y + a_h
518}
519
520/// Plan page-local atlas rectangles with a deterministic, bounded
521/// bottom-left bin layout. Tall atlases are placed first; candidate corners
522/// come only from page edges and already-placed rectangle edges, avoiding a
523/// texel-by-texel search on the PS1 boot path.
524fn plan_font_pack(
525    fonts: &[&'static BitmapFont],
526    placements: &mut [Option<AtlasPlacement>; MAX_PACKED_FONT_ATLASES],
527) -> Option<FontPackMetrics> {
528    if fonts.is_empty() || fonts.len() > MAX_PACKED_FONT_ATLASES {
529        return None;
530    }
531    placements.fill(None);
532
533    let mut page_count = 0u16;
534    for _ in 0..fonts.len() {
535        // Height-first, then width-first is a shelf/bin heuristic that keeps
536        // the page count low while preserving the caller's output ordering.
537        let mut selected = None;
538        let mut selected_key = (0u16, 0u16);
539        for (index, &font) in fonts.iter().enumerate() {
540            if placements[index].is_some() {
541                continue;
542            }
543            let (_, _, height, width) = font_atlas_dims(font);
544            if width == 0 || width as usize > FONT_PAGE_HALFWORDS || height == 0 || height > 256 {
545                return None;
546            }
547            let key = (height, width);
548            if selected.is_none() || key > selected_key {
549                selected = Some(index);
550                selected_key = key;
551            }
552        }
553        let index = selected?;
554        let (_, _, height, width) = font_atlas_dims(fonts[index]);
555
556        let mut chosen = None;
557        // Include one new page; allocations below may grow page_count.
558        let available_pages = page_count;
559        for page in 0..=available_pages {
560            if page == page_count && page_count >= 16 {
561                break;
562            }
563            let mut page_best = None;
564
565            // In a bottom-left-stable layout, a rectangle's left/bottom edge
566            // touches either the page edge or another rectangle's right/bottom
567            // edge. Enumerating those corners is exhaustive for this policy.
568            for y_source in 0..=fonts.len() {
569                let y = if y_source == 0 {
570                    0
571                } else {
572                    let prior_index = y_source - 1;
573                    let Some(prior) = placements[prior_index] else {
574                        continue;
575                    };
576                    if prior.page != page {
577                        continue;
578                    }
579                    let (_, _, prior_h, _) = font_atlas_dims(fonts[prior_index]);
580                    prior.y + prior_h
581                };
582                if y + height > 256 {
583                    continue;
584                }
585                for x_source in 0..=fonts.len() {
586                    let x_halfwords = if x_source == 0 {
587                        0
588                    } else {
589                        let prior_index = x_source - 1;
590                        let Some(prior) = placements[prior_index] else {
591                            continue;
592                        };
593                        if prior.page != page {
594                            continue;
595                        }
596                        let (_, _, _, prior_w) = font_atlas_dims(fonts[prior_index]);
597                        prior.x_halfwords + prior_w
598                    };
599                    if x_halfwords + width > FONT_PAGE_HALFWORDS as u16 {
600                        continue;
601                    }
602                    let candidate = AtlasPlacement {
603                        page,
604                        x_halfwords,
605                        y,
606                    };
607                    let overlaps = placements.iter().enumerate().any(|(prior_index, prior)| {
608                        let Some(prior) = prior else {
609                            return false;
610                        };
611                        let (_, _, prior_h, prior_w) = font_atlas_dims(fonts[prior_index]);
612                        placements_overlap(candidate, width, height, *prior, prior_w, prior_h)
613                    });
614                    if overlaps {
615                        continue;
616                    }
617                    if page_best.is_none_or(|best: AtlasPlacement| {
618                        (candidate.y, candidate.x_halfwords) < (best.y, best.x_halfwords)
619                    }) {
620                        page_best = Some(candidate);
621                    }
622                }
623            }
624            if let Some(best) = page_best {
625                chosen = Some(best);
626                if page == page_count {
627                    page_count += 1;
628                }
629                break;
630            }
631        }
632        placements[index] = Some(chosen?);
633    }
634
635    let upload_rows = placements
636        .iter()
637        .enumerate()
638        .filter_map(|(index, placement)| {
639            let placement = placement.as_ref()?;
640            let (_, _, height, _) = font_atlas_dims(fonts[index]);
641            Some(placement.y + height)
642        })
643        .max()?;
644    let scratch_halfwords = usize::from(page_count)
645        .checked_mul(FONT_PAGE_HALFWORDS)?
646        .checked_mul(usize::from(upload_rows))?;
647    Some(FontPackMetrics {
648        pages: page_count,
649        upload_rows,
650        scratch_halfwords,
651    })
652}
653
654/// Return the page-run and scratch requirements for [`upload_fonts`] without
655/// reserving VRAM or touching the GPU.
656pub fn font_pack_metrics(fonts: &[&'static BitmapFont]) -> Option<FontPackMetrics> {
657    let mut placements = [None; MAX_PACKED_FONT_ATLASES];
658    plan_font_pack(fonts, &mut placements)
659}
660
661fn pack_font_bits(
662    fonts: &[&'static BitmapFont],
663    placements: &[Option<AtlasPlacement>; MAX_PACKED_FONT_ATLASES],
664    metrics: FontPackMetrics,
665    scratch: &mut [u16],
666) -> Option<()> {
667    if metrics.scratch_halfwords > scratch.len() {
668        return None;
669    }
670    let stride = usize::from(metrics.pages) * FONT_PAGE_HALFWORDS;
671    scratch[..metrics.scratch_halfwords].fill(0);
672    for (slot, &font) in fonts.iter().enumerate() {
673        let placement = placements[slot]?;
674        let (glyphs_per_row, _, _, _) = font_atlas_dims(font);
675        let col0 =
676            usize::from(placement.page) * FONT_PAGE_HALFWORDS + usize::from(placement.x_halfwords);
677        for gi in 0..font.glyph_count {
678            let atlas_col = gi % glyphs_per_row;
679            let atlas_row = gi / glyphs_per_row;
680            let base_x = atlas_col * cell_width(font);
681            let base_y = placement.y + atlas_row * font.glyph_h as u16;
682            for row in 0..font.glyph_h {
683                let row_bits = font.glyph_row_packed(gi, row);
684                for col in 0..font.glyph_w as u16 {
685                    if (row_bits >> col) & 1 == 0 {
686                        continue;
687                    }
688                    let x = base_x + col;
689                    let y = base_y + row as u16;
690                    let hw_idx = y as usize * stride + col0 + (x as usize / 4);
691                    let nibble_shift = (x & 3) * 4;
692                    scratch[hw_idx] |= 1u16 << nibble_shift; // CLUT index 1 = white
693                }
694            }
695        }
696    }
697    Some(())
698}
699
700/// Upload several fonts in a **single** `GP0(A0h)` image transfer.
701///
702/// Repeated per-font VRAM uploads desync the GPU command stream on some
703/// targets and corrupt subsequent (world) rendering; packing every glyph
704/// atlas side-by-side into one reserved page run and emitting one transfer
705/// avoids that entirely. Multiple atlas rectangles share each 256x256 4bpp
706/// tpage through page-local UV origins; all fonts share one 2-entry CLUT
707/// (transparent, white) which `tint` recolours per draw.
708///
709/// `scratch` is a caller-owned buffer (put it in `static mut` BSS, **not** on
710/// the stack); query [`font_pack_metrics`] for the exact required length.
711/// `out` receives one `Some(FontAtlas)` per font; trailing slots
712/// are set to `None`. Returns the handles to free the set later, or `None`
713/// (uploading nothing) if a font is too wide, `scratch`/`out` is too small,
714/// or the allocator is out of space. Every failure clears all of `out`, so a
715/// caller can never mistake stale or partially prepared handles for the set.
716pub fn upload_fonts<R: VramRegionSource>(
717    fonts: &[&'static BitmapFont],
718    alloc: &mut R,
719    scratch: &mut [u16],
720    out: &mut [Option<FontAtlas>],
721) -> Option<FontSetVram> {
722    let n = fonts.len();
723    out.fill(None);
724    if n == 0 || n > out.len() {
725        return None;
726    }
727    let mut placements = [None; MAX_PACKED_FONT_ATLASES];
728    let metrics = plan_font_pack(fonts, &mut placements)?;
729    if metrics.scratch_halfwords > scratch.len() {
730        return None;
731    }
732
733    let (base, pages) = alloc.alloc_page_run(metrics.pages, TextureDepth::Bit4, 0)?;
734    // CLUT allocation can't fail at boot (the band is empty); if it ever does
735    // the page run is left reserved, which is harmless at boot.
736    let (clut, clut_handle) = alloc.alloc_clut(2)?;
737
738    pack_font_bits(fonts, &placements, metrics, scratch)?;
739    for (slot, &font) in fonts.iter().enumerate() {
740        let placement = placements[slot]?;
741        let (glyphs_per_row, _, _, _) = font_atlas_dims(font);
742        out[slot] = Some(FontAtlas {
743            font,
744            tpage: TexturePage::new(
745                base.x() + placement.page * FONT_PAGE_HALFWORDS as u16,
746                base.y(),
747                TextureDepth::Bit4,
748            ),
749            clut_word: clut.uv_word(),
750            glyphs_per_row,
751            uv_origin: ((placement.x_halfwords * 4) as u8, placement.y as u8),
752        });
753    }
754    for slot in out.iter_mut().skip(n) {
755        *slot = None;
756    }
757
758    // One image transfer for the whole combined atlas, then the shared CLUT.
759    let stride = usize::from(metrics.pages) * FONT_PAGE_HALFWORDS;
760    upload_16bpp(
761        VramRect::new(base.x(), base.y(), stride as u16, metrics.upload_rows),
762        &scratch[..metrics.scratch_halfwords],
763    );
764    upload_clut(clut, &[Color555::TRANSPARENT, Color555::rgb5(31, 31, 31)]);
765
766    Some(FontSetVram {
767        pages,
768        clut: clut_handle,
769    })
770}
771
772impl FontAtlas {
773    /// Maximum texels a 4bpp tpage covers horizontally. 64 pixels
774    /// is the PSX-SPX value (tpage is 256×256 in texel units, but
775    /// in 4bpp mode the effective horizontal texel range per page
776    /// maps onto 64 VRAM halfwords = 64 × 4 = 256 texels).
777    const MAX_ATLAS_W_TEXELS: u16 = 256;
778    /// Stack buffer for the packed 4bpp atlas. 8192 halfwords =
779    /// 16 KiB leaves real call-frame headroom inside the SDK's 32 KiB
780    /// minimum stack reserve while covering the common cases:
781    /// - 128 glyphs at 8×8   (2048 hw)
782    /// - 128 glyphs at 8×16  (4096 hw)
783    /// - 64 glyphs  at 16×16 (4096 hw)
784    /// - 128 glyphs at 12×16 (5040 hw)
785    /// - 128 glyphs at 16×16 (8192 hw)
786    /// - 64 glyphs  at 32×16 (8192 hw) -- the largest supported
787    ///
788    /// A full 32 KiB local is not safe: the linker guarantees 32 KiB for the
789    /// *whole* call stack, so the uploader's caller/interrupt frames would
790    /// cross into static RAM. Larger imports should use [`upload_fonts`] and
791    /// provide scratch storage with an explicitly audited lifetime.
792    const MAX_PACK_HALFWORDS: usize = 8192;
793
794    /// Upload `font` as a 4bpp CLUT texture at `tpage`, with a
795    /// 2-entry CLUT (transparent, white) at `clut`.
796    ///
797    /// The caller picks the tpage / clut locations -- typically
798    /// `TexturePage::new(768, 0, TextureDepth::Bit4)` for the standard
799    /// off-display region, and a `Clut::new(768, 480)` for the
800    /// CLUT row. Both must live inside VRAM and not overlap the
801    /// active framebuffer.
802    ///
803    /// Atlas layout picks `glyphs_per_row` so the texture's pixel
804    /// width stays within `MAX_ATLAS_W_TEXELS`. For 8-wide fonts
805    /// that's 32 glyphs per row; 16-wide fonts get 16 per row.
806    pub fn upload(font: &'static BitmapFont, tpage: TexturePage, clut: Clut) -> Self {
807        assert!(
808            matches!(tpage.depth(), TextureDepth::Bit4),
809            "FontAtlas::upload requires a 4bpp tpage",
810        );
811
812        let glyph_w = font.glyph_w as u16;
813        let glyph_h = font.glyph_h as u16;
814        let glyph_count = font.glyph_count;
815
816        // Cells are padded to a whole halfword (4 texels at 4bpp) so every
817        // glyph STARTS on a halfword boundary. A 5-wide font otherwise puts
818        // each glyph at an arbitrary nibble, and on real hardware the demo
819        // disc's SPLEEN 5x8 description text rendered lowercase 'f' as a
820        // bare crossbar while every emulator drew it whole (hwtest 0xB3-
821        // 0xB5, 2026-08-07 console capture). The padding costs VRAM and
822        // changes nothing about the drawn pixels: `draw_text` still emits
823        // glyph_w-wide rects, only the atlas coordinates move.
824        let cell_w = glyph_w.div_ceil(4) * 4;
825
826        // Atlas width = glyphs_per_row × cell_w, capped at the
827        // MAX to stay within a single 4bpp tpage.
828        let max_cols = (Self::MAX_ATLAS_W_TEXELS / cell_w).max(1);
829        let glyphs_per_row = glyph_count.min(max_cols);
830        let glyph_rows = glyph_count.div_ceil(glyphs_per_row);
831        let atlas_w = glyphs_per_row * cell_w;
832        let atlas_h = glyph_rows * glyph_h;
833
834        // Pack 1bpp source → 4bpp VRAM texture into a stack buffer.
835        // Each 16-bit halfword holds 4 texels (nibble 0 = leftmost).
836        // The stride is rounded to an even number of halfwords because
837        // `upload_16bpp` moves whole words and rejects an odd pixel count;
838        // cell padding can otherwise leave an odd row (KENNEY_PIXEL lands
839        // on 63). The spare column stays zero.
840        let halfwords_per_row = atlas_w.div_ceil(4).next_multiple_of(2);
841        let total_halfwords = halfwords_per_row as usize * atlas_h as usize;
842        assert!(
843            total_halfwords <= Self::MAX_PACK_HALFWORDS,
844            "font atlas too large for stack buffer",
845        );
846        let mut packed = [0u16; Self::MAX_PACK_HALFWORDS];
847
848        for gi in 0..glyph_count {
849            let atlas_col = gi % glyphs_per_row;
850            let atlas_row = gi / glyphs_per_row;
851            let base_x = atlas_col * cell_w;
852            let base_y = atlas_row * glyph_h;
853            for row in 0..glyph_h as u8 {
854                let row_bits = font.glyph_row_packed(gi, row);
855                for col in 0..glyph_w {
856                    let bit = (row_bits >> col) & 1;
857                    let x = base_x + col;
858                    let y = base_y + row as u16;
859                    // 4 texels per halfword; pick nibble by x & 3.
860                    let hw_idx = y as usize * halfwords_per_row as usize + (x as usize / 4);
861                    let nibble_shift = (x & 3) * 4;
862                    packed[hw_idx] |= (bit as u16) << nibble_shift;
863                }
864            }
865        }
866
867        // upload_16bpp takes pixel count = w*h, which for a 4bpp
868        // texture uploaded as a 16bpp rect works out to
869        // halfwords_per_row × atlas_h halfwords = the 4bpp pixel
870        // count. Upload is by halfwords regardless of bit-depth;
871        // the GPU doesn't inspect bits inside words.
872        //
873        // VRAM rect semantics expose the *halfword* footprint when
874        // depth is 4bpp -- so width is `atlas_w / 4`, not `atlas_w`.
875        let vram_rect = VramRect::new(tpage.x(), tpage.y(), halfwords_per_row, atlas_h);
876        upload_16bpp(vram_rect, &packed[..total_halfwords]);
877
878        // Upload 2-entry CLUT: idx 0 = transparent, idx 1 = white.
879        // The white texel will be tinted per-draw_call via the
880        // sprite's per-vertex colour (GP0 0x64 tint byte).
881        let clut_entries = [Color555::TRANSPARENT, Color555::rgb5(31, 31, 31)];
882        upload_clut(clut, &clut_entries);
883
884        Self {
885            font,
886            tpage,
887            clut_word: clut.uv_word(),
888            glyphs_per_row,
889            uv_origin: (0, 0),
890        }
891    }
892
893    /// Pixel width of `text` when rendered with this atlas.
894    pub fn text_width(&self, text: &str) -> u16 {
895        self.font.text_width(text)
896    }
897
898    /// Pixel advance of one character, the per-glyph term
899    /// [`FontAtlas::text_width`] sums.
900    ///
901    /// Exposed so a caller scanning a string can carry a running width
902    /// instead of re-measuring every prefix: word wrap is quadratic
903    /// otherwise, which on R3000 is the most expensive thing a text layout
904    /// does. `text_width(s)` stays exactly
905    /// `s.chars().map(glyph_advance).fold(0, u16::saturating_add)`.
906    pub fn glyph_advance(&self, ch: char) -> u8 {
907        self.font.glyph_advance(ch)
908    }
909
910    /// Pixel width of `text` using this atlas's font metrics plus signed
911    /// spacing inserted between adjacent characters.
912    pub fn text_width_with_spacing(&self, text: &str, letter_spacing: i8) -> u16 {
913        self.font.text_width_with_spacing(text, letter_spacing)
914    }
915
916    /// Pixel height of a single text line. Usually `== glyph_h`.
917    pub fn line_height(&self) -> u16 {
918        self.font.line_height as u16
919    }
920
921    /// Look up the atlas UV for a character, returning the
922    /// top-left `(u, v)` of the glyph in texel coords, or `None`
923    /// if the char is outside the font's range.
924    ///
925    /// Shared by every draw method so the codepoint-window logic
926    /// lives in one place.
927    #[inline]
928    fn glyph_uv(&self, ch: char) -> Option<(u8, u8)> {
929        let cp = ch as u32;
930        let first = self.font.first_char as u32;
931        if cp < first || cp >= first + self.font.glyph_count as u32 {
932            return None;
933        }
934        let idx = (cp - first) as u16;
935        let col = idx % self.glyphs_per_row;
936        let row = idx / self.glyphs_per_row;
937        let u = u16::from(self.uv_origin.0) + col * cell_width(self.font);
938        let v = u16::from(self.uv_origin.1) + row * self.font.glyph_h as u16;
939        Some((u as u8, v as u8))
940    }
941
942    /// Draw `text` at screen-space `(x, y)` with the given tint.
943    ///
944    /// `(0x80, 0x80, 0x80)` = unmodulated white; any other value
945    /// recolours every glyph via the PSX per-texel multiplier
946    /// (`output = texel * tint / 128`).
947    ///
948    /// **Fast path** -- uses textured rectangles (GP0 0x64, 4 words
949    /// per glyph). For any transform or per-vertex colour needs,
950    /// reach for [`Self::draw_text_scaled`], [`Self::draw_text_rotated`],
951    /// [`Self::draw_text_affine`], or [`Self::draw_text_gradient`].
952    ///
953    /// Characters outside the font's codepoint range are skipped --
954    /// they still advance the cursor so the rest of the string
955    /// lines up as the caller intended. Iteration is per-`char`,
956    /// so any `&str` is valid input; a Latin-1 atlas with
957    /// `first_char = 0xA0` picks up `é`, `ü`, etc. automatically.
958    ///
959    /// Sets the GP0(0xE1) draw-mode tpage to our atlas once at the
960    /// start of the call -- if the caller was rendering with a
961    /// different tpage, they'll need to re-apply theirs after
962    /// draw_text returns.
963    ///
964    /// # Example
965    ///
966    /// ```ignore
967    /// atlas.draw_text(8, 8, "SCORE 0042", (0x80, 0x80, 0x80));
968    /// ```
969    pub fn draw_text(&self, x: i16, y: i16, text: &str, tint: (u8, u8, u8)) {
970        self.draw_text_with_spacing(x, y, text, 0, tint);
971    }
972
973    /// Emit ordered GP0 packets for deferred, axis-aligned text drawing.
974    ///
975    /// The sink receives one two-word draw-mode/texture-window packet followed
976    /// by one four-word textured rectangle per supported glyph. Atlas padding,
977    /// packed-page origin, CLUT and character advances are owned by this atlas,
978    /// just as they are for immediate drawing. No GPU access occurs here.
979    ///
980    /// The sink must copy each borrowed packet before returning. Returning
981    /// `false` stops emission immediately, allowing a fixed packet arena to
982    /// reject a full run without writing beyond its capacity. Earlier packets
983    /// remain accepted; the return value says whether the entire run fit.
984    pub fn emit_text_packets(
985        &self,
986        x: i16,
987        y: i16,
988        text: &str,
989        tint: (u8, u8, u8),
990        mut emit: impl FnMut(&[u32]) -> bool,
991    ) -> bool {
992        let material =
993            psx_gpu::material::TextureMaterial::opaque(self.clut_word, self.tpage.uv_word(0), tint);
994        if !emit(&[material.draw_mode_word(), material.texture_window_word()]) {
995            return false;
996        }
997        let mut cursor_x = x;
998        for ch in text.chars() {
999            if let Some((u, v)) = self.glyph_uv(ch) {
1000                if !emit(&[
1001                    material.textured_rect_header(),
1002                    pack_vertex(cursor_x, y),
1003                    pack_texcoord(u, v, self.clut_word),
1004                    pack_xy(self.font.glyph_w as u16, self.font.glyph_h as u16),
1005                ]) {
1006                    return false;
1007                }
1008            }
1009            cursor_x = cursor_x.wrapping_add(self.font.glyph_advance(ch) as i16);
1010        }
1011        true
1012    }
1013
1014    /// Draw `text` with signed spacing inserted between adjacent characters.
1015    /// `letter_spacing` is measured in final screen pixels and is not applied
1016    /// after the last character.
1017    pub fn draw_text_with_spacing(
1018        &self,
1019        x: i16,
1020        y: i16,
1021        text: &str,
1022        letter_spacing: i8,
1023        tint: (u8, u8, u8),
1024    ) {
1025        // Our atlas tpage takes over the current draw-mode slot; the
1026        // per-glyph GP0 0x64 rectangles all sample from it.
1027        self.tpage.apply_as_draw_mode();
1028
1029        let font = self.font;
1030        let clut_word = self.clut_word;
1031        let color_cmd = packet::TEXTURED_RECT | pack_color(tint.0, tint.1, tint.2);
1032        let glyph_size = pack_xy(font.glyph_w as u16, font.glyph_h as u16);
1033        let gap = i16::from(letter_spacing);
1034        let mut cursor_x = x;
1035        for ch in text.chars() {
1036            let Some((u, v)) = self.glyph_uv(ch) else {
1037                cursor_x = cursor_x
1038                    .wrapping_add(font.glyph_advance(ch) as i16)
1039                    .wrapping_add(gap);
1040                continue;
1041            };
1042
1043            wait_command_ready();
1044            // GP0 0x64 = variable-size textured rectangle, no blend,
1045            // opaque. First word: 0x64_BB_GG_RR (color is the tint
1046            // multiplier -- NOT a fill colour: CLUT index 1's white
1047            // texel gets modulated by this).
1048            write_command(color_cmd);
1049            write_command(pack_vertex(cursor_x, y));
1050            // Second word packs (U, V, CLUT) -- our `pack_texcoord`
1051            // takes (u, v, extra) where `extra` is the CLUT field
1052            // (high halfword). The tpage is implied by the current
1053            // draw mode.
1054            write_command(pack_texcoord(u, v, clut_word));
1055            // Third word: rectangle size.
1056            write_command(glyph_size);
1057
1058            cursor_x = cursor_x
1059                .wrapping_add(font.glyph_advance(ch) as i16)
1060                .wrapping_add(gap);
1061        }
1062    }
1063
1064    /// Draw `text` with the GPU's semi-transparency enabled, blending
1065    /// each glyph against the framebuffer with `blend`'s equation.
1066    ///
1067    /// **Fast path** -- same GP0 0x64 textured rectangle and same 4
1068    /// words per glyph as [`Self::draw_text`], with the primitive's
1069    /// semi-transparency bit set and the draw mode's ABR field carrying
1070    /// the equation. The only extra cost is the one draw-mode word at
1071    /// the start and the one that restores the opaque mode at the end.
1072    ///
1073    /// Why this exists: the opaque path can only recolour a glyph, and
1074    /// a tint ramped toward black draws a *black* glyph, not an absent
1075    /// one. [`TextBlend::Add`] plus a ramped tint is the PS1's real
1076    /// fade -- at tint zero the glyph contributes nothing and vanishes.
1077    ///
1078    /// Restores the atlas's opaque draw mode before returning, so a
1079    /// blended run cannot leave a stray blend equation on the GPU for
1080    /// whatever draws next. As with [`Self::draw_text`], the tpage slot
1081    /// is left pointing at this atlas.
1082    pub fn draw_text_blended(
1083        &self,
1084        x: i16,
1085        y: i16,
1086        text: &str,
1087        tint: (u8, u8, u8),
1088        blend: TextBlend,
1089    ) {
1090        self.draw_text_blended_with_spacing(x, y, text, 0, tint, blend);
1091    }
1092
1093    /// [`Self::draw_text_blended`] with signed inter-character spacing,
1094    /// measured in screen pixels and not applied after the last glyph.
1095    pub fn draw_text_blended_with_spacing(
1096        &self,
1097        x: i16,
1098        y: i16,
1099        text: &str,
1100        letter_spacing: i8,
1101        tint: (u8, u8, u8),
1102        blend: TextBlend,
1103    ) {
1104        wait_command_ready();
1105        write_command(blended_draw_mode_word(self.tpage, blend));
1106        reset_texture_window();
1107
1108        let font = self.font;
1109        let clut_word = self.clut_word;
1110        // 0x66 rather than 0x64: same variable-size textured rectangle,
1111        // with bit 25 (semi-transparency) set so the ABR field above
1112        // actually applies.
1113        let color_cmd = SEMI_TRANSPARENT_RECT_CMD | pack_color(tint.0, tint.1, tint.2);
1114        let glyph_size = pack_xy(font.glyph_w as u16, font.glyph_h as u16);
1115        let gap = i16::from(letter_spacing);
1116        let mut cursor_x = x;
1117        for ch in text.chars() {
1118            let Some((u, v)) = self.glyph_uv(ch) else {
1119                cursor_x = cursor_x
1120                    .wrapping_add(font.glyph_advance(ch) as i16)
1121                    .wrapping_add(gap);
1122                continue;
1123            };
1124
1125            wait_command_ready();
1126            write_command(color_cmd);
1127            write_command(pack_vertex(cursor_x, y));
1128            write_command(pack_texcoord(u, v, clut_word));
1129            write_command(glyph_size);
1130
1131            cursor_x = cursor_x
1132                .wrapping_add(font.glyph_advance(ch) as i16)
1133                .wrapping_add(gap);
1134        }
1135
1136        // Leave the GPU in the opaque mode every other draw path assumes.
1137        self.tpage.apply_as_draw_mode();
1138    }
1139
1140    /// Draw a flat semi-transparent rectangle in `blend`'s equation --
1141    /// a backing plate for a run of text drawn on top of it.
1142    ///
1143    /// This is a method on the atlas rather than a free function
1144    /// because the blend equation is carried by the GP0(E1h) draw-mode
1145    /// word, which also names a texture page. Setting one means setting
1146    /// the other, so whatever changes the equation has to know which
1147    /// page the glyphs that follow will want back -- and the atlas is
1148    /// what knows that. It restores the atlas's opaque draw mode on the
1149    /// way out, exactly as [`Self::draw_text_blended`] does.
1150    ///
1151    /// [`TextBlend::Subtract`] is the equation to reach for behind
1152    /// text: it darkens whatever is under it and, because it scales
1153    /// with the tint, it fades to nothing along with the text it seats.
1154    /// [`TextBlend::Average`] cannot fade -- at tint zero it still
1155    /// halves the background, so a plate drawn with it outlives the
1156    /// glyphs and is left behind as a grey box.
1157    pub fn draw_backdrop_blended(
1158        &self,
1159        x: i16,
1160        y: i16,
1161        w: u16,
1162        h: u16,
1163        tint: (u8, u8, u8),
1164        blend: TextBlend,
1165    ) {
1166        if w == 0 || h == 0 {
1167            return;
1168        }
1169        wait_command_ready();
1170        write_command(blended_draw_mode_word(self.tpage, blend));
1171        write_command(SEMI_TRANSPARENT_FLAT_RECT_CMD | pack_color(tint.0, tint.1, tint.2));
1172        write_command(pack_vertex(x, y));
1173        write_command(pack_xy(w, h));
1174        self.tpage.apply_as_draw_mode();
1175    }
1176
1177    /// Draw `text` at screen-space `(x, y)` scaled by `(scale_x,
1178    /// scale_y)`. `scale=(1, 1)` matches [`Self::draw_text`]'s
1179    /// output, but via the quad path instead of the rect path --
1180    /// so prefer `draw_text` for native-size.
1181    ///
1182    /// **Quad path** -- uses textured quads (GP0 0x2C, 9 words per
1183    /// glyph). PSX samples textures with nearest-neighbour, so
1184    /// integer scales (2×, 3×, 4×) produce crisp pixel-doubled
1185    /// output. Use [`Self::draw_text_scaled_q8`] for fractional
1186    /// screen-space sizes.
1187    ///
1188    /// # Example
1189    ///
1190    /// ```ignore
1191    /// atlas.draw_text_scaled(80, 100, "GAME OVER", 3, 3, (220, 40, 40));
1192    /// ```
1193    pub fn draw_text_scaled(
1194        &self,
1195        x: i16,
1196        y: i16,
1197        text: &str,
1198        scale_x: u8,
1199        scale_y: u8,
1200        tint: (u8, u8, u8),
1201    ) {
1202        self.draw_text_scaled_with_spacing(x, y, text, scale_x, scale_y, 0, tint);
1203    }
1204
1205    /// Draw `text` with Q8 fixed-point scale factors (`256` = 1.0x).
1206    /// This keeps the font atlas monochrome while allowing fractional
1207    /// screen-space glyph sizes such as 1.5x (`384`).
1208    pub fn draw_text_scaled_q8(
1209        &self,
1210        x: i16,
1211        y: i16,
1212        text: &str,
1213        scale_x_q8: u16,
1214        scale_y_q8: u16,
1215        tint: (u8, u8, u8),
1216    ) {
1217        self.draw_text_scaled_with_spacing_q8(x, y, text, scale_x_q8, scale_y_q8, 0, tint);
1218    }
1219
1220    /// Draw scaled text with signed spacing inserted between adjacent
1221    /// characters. `letter_spacing` is measured in final screen pixels, not
1222    /// source-glyph pixels.
1223    pub fn draw_text_scaled_with_spacing(
1224        &self,
1225        x: i16,
1226        y: i16,
1227        text: &str,
1228        scale_x: u8,
1229        scale_y: u8,
1230        letter_spacing: i8,
1231        tint: (u8, u8, u8),
1232    ) {
1233        assert!(scale_x > 0 && scale_y > 0, "scale must be > 0 in both axes");
1234        self.draw_text_scaled_with_spacing_q8(
1235            x,
1236            y,
1237            text,
1238            u16::from(scale_x) * 256,
1239            u16::from(scale_y) * 256,
1240            letter_spacing,
1241            tint,
1242        );
1243    }
1244
1245    /// Draw scaled text with Q8 fixed-point scale and signed spacing inserted
1246    /// between adjacent characters. `letter_spacing` is measured in final
1247    /// screen pixels, not source-glyph pixels.
1248    pub fn draw_text_scaled_with_spacing_q8(
1249        &self,
1250        x: i16,
1251        y: i16,
1252        text: &str,
1253        scale_x_q8: u16,
1254        scale_y_q8: u16,
1255        letter_spacing: i8,
1256        tint: (u8, u8, u8),
1257    ) {
1258        assert!(
1259            scale_x_q8 > 0 && scale_y_q8 > 0,
1260            "scale must be > 0 in both axes"
1261        );
1262        if text.is_empty() {
1263            return;
1264        }
1265        let font = self.font;
1266        let gw = font.glyph_w as i16;
1267        let gh = font.glyph_h as i16;
1268        let sw = scale_q8_i16(gw, scale_x_q8);
1269        let sh = scale_q8_i16(gh, scale_y_q8);
1270        let clut = self.clut_word;
1271        let tpage = self.tpage.uv_word(0);
1272        let color_cmd = gp0::polygon_opcode(false, true, true, false, false)
1273            | pack_color(tint.0, tint.1, tint.2);
1274        let gap_q8 = i32::from(letter_spacing) << 8;
1275        let mut cursor_x_q8 = i32::from(x) << 8;
1276        reset_texture_window();
1277        for ch in text.chars() {
1278            let cursor_x = round_q8_to_i16(cursor_x_q8);
1279            let Some((u, v)) = self.glyph_uv(ch) else {
1280                cursor_x_q8 = cursor_x_q8
1281                    .saturating_add(i32::from(font.glyph_advance(ch)) * i32::from(scale_x_q8))
1282                    .saturating_add(gap_q8);
1283                continue;
1284            };
1285            let verts = [
1286                (cursor_x, y),
1287                (cursor_x + sw, y),
1288                (cursor_x, y + sh),
1289                (cursor_x + sw, y + sh),
1290            ];
1291            let uvs = glyph_quad_uvs(u, v, font.glyph_w, font.glyph_h);
1292            write_textured_quad_packet(verts, uvs, color_cmd, clut, tpage);
1293            cursor_x_q8 = cursor_x_q8
1294                .saturating_add(i32::from(font.glyph_advance(ch)) * i32::from(scale_x_q8))
1295                .saturating_add(gap_q8);
1296        }
1297    }
1298
1299    /// Draw `text` rotated around the pivot `(cx, cy)` by
1300    /// `angle_q12` (Q0.12, one revolution = 4096). The string is
1301    /// centred on the pivot at angle 0 -- its natural extent is
1302    /// `text_width × glyph_h`, anchored so `(cx, cy)` sits at the
1303    /// centre of the baseline.
1304    ///
1305    /// **Quad path** -- 9 GP0 words per glyph. Sin/cos come from a
1306    /// compact 256-entry Q1.12 table ([`sincos`]), good to ~1.4° --
1307    /// imperceptible at 8px glyph scale.
1308    ///
1309    /// See crate-level docs for the Q0.12 angle convention.
1310    ///
1311    /// # Example
1312    ///
1313    /// ```ignore
1314    /// // Spin a title once per second (frame_idx updates each vsync).
1315    /// let angle = ((frame_idx * 68) & 0xFFF) as u16; // ~4096/60 ≈ 68
1316    /// atlas.draw_text_rotated(160, 120, "SPIN", angle, (255, 255, 255));
1317    /// ```
1318    pub fn draw_text_rotated(
1319        &self,
1320        cx: i16,
1321        cy: i16,
1322        text: &str,
1323        angle_q12: u16,
1324        tint: (u8, u8, u8),
1325    ) {
1326        if text.is_empty() {
1327            return;
1328        }
1329        let font = self.font;
1330        let gw = font.glyph_w as i32;
1331        let gh = font.glyph_h as i32;
1332        let total_w = font.text_width(text) as i32;
1333        // Centre the string on the pivot -- baseline (top edge of
1334        // first glyph) is `gh/2` above the pivot so that the glyph
1335        // midline runs through `(cx, cy)`.
1336        let origin_x = -total_w / 2;
1337        let origin_y = -gh / 2;
1338        let s = sincos::sin_q12(angle_q12);
1339        let c = sincos::cos_q12(angle_q12);
1340        let clut = self.clut_word;
1341        let tpage = self.tpage.uv_word(0);
1342        let color_cmd = gp0::polygon_opcode(false, true, true, false, false)
1343            | pack_color(tint.0, tint.1, tint.2);
1344
1345        // Transform helper: local (lx, ly) → screen (sx, sy), with
1346        // Q1.12 rotation matrix and integer translate to (cx, cy).
1347        let rot = |lx: i32, ly: i32| -> (i16, i16) {
1348            let rx = (lx * c - ly * s) >> 12;
1349            let ry = (lx * s + ly * c) >> 12;
1350            ((cx as i32 + rx) as i16, (cy as i32 + ry) as i16)
1351        };
1352
1353        let mut cursor_x = 0i32;
1354        reset_texture_window();
1355        for ch in text.chars() {
1356            let Some((u, v)) = self.glyph_uv(ch) else {
1357                cursor_x += font.glyph_advance(ch) as i32;
1358                continue;
1359            };
1360            let lx0 = origin_x + cursor_x;
1361            let lx1 = lx0 + gw;
1362            let ly0 = origin_y;
1363            let ly1 = origin_y + gh;
1364            let verts = [rot(lx0, ly0), rot(lx1, ly0), rot(lx0, ly1), rot(lx1, ly1)];
1365            let uvs = glyph_quad_uvs(u, v, font.glyph_w, font.glyph_h);
1366            write_textured_quad_packet(verts, uvs, color_cmd, clut, tpage);
1367            cursor_x += font.glyph_advance(ch) as i32;
1368        }
1369    }
1370
1371    /// Draw `text` through an arbitrary 2×2 affine transform.
1372    ///
1373    /// The matrix `m` is Q3.12 fixed-point -- `m = [[4096, 0], [0,
1374    /// 4096]]` is the identity (native size, axis-aligned). Each
1375    /// glyph's local corner `(lx, ly)` maps onto screen space as
1376    /// `(origin.0 + (m[0][0]*lx + m[0][1]*ly) >> 12,
1377    ///   origin.1 + (m[1][0]*lx + m[1][1]*ly) >> 12)`.
1378    ///
1379    /// Covers rotation, non-uniform scale, shear, reflection, and
1380    /// any combination -- the other quad-path methods are all
1381    /// specializations of this one.
1382    ///
1383    /// **Quad path** -- 9 GP0 words per glyph.
1384    ///
1385    /// # Example: horizontal shear
1386    ///
1387    /// ```ignore
1388    /// // Skew 30° right: x' = x + 0.577·y. 0.577 × 4096 ≈ 2365.
1389    /// let m = [[4096, 2365], [0, 4096]];
1390    /// atlas.draw_text_affine((40, 40), "SKEW", m, (200, 200, 200));
1391    /// ```
1392    pub fn draw_text_affine(
1393        &self,
1394        origin: (i16, i16),
1395        text: &str,
1396        m: [[i16; 2]; 2],
1397        tint: (u8, u8, u8),
1398    ) {
1399        if text.is_empty() {
1400            return;
1401        }
1402        let font = self.font;
1403        let gw = font.glyph_w as i32;
1404        let gh = font.glyph_h as i32;
1405        let (m00, m01) = (m[0][0] as i32, m[0][1] as i32);
1406        let (m10, m11) = (m[1][0] as i32, m[1][1] as i32);
1407        let clut = self.clut_word;
1408        let tpage = self.tpage.uv_word(0);
1409        let color_cmd = gp0::polygon_opcode(false, true, true, false, false)
1410            | pack_color(tint.0, tint.1, tint.2);
1411
1412        let tx = |lx: i32, ly: i32| -> (i16, i16) {
1413            let sx = origin.0 as i32 + ((m00 * lx + m01 * ly) >> 12);
1414            let sy = origin.1 as i32 + ((m10 * lx + m11 * ly) >> 12);
1415            (sx as i16, sy as i16)
1416        };
1417
1418        let mut cursor_x = 0i32;
1419        reset_texture_window();
1420        for ch in text.chars() {
1421            let Some((u, v)) = self.glyph_uv(ch) else {
1422                cursor_x += font.glyph_advance(ch) as i32;
1423                continue;
1424            };
1425            let lx0 = cursor_x;
1426            let lx1 = lx0 + gw;
1427            let ly0 = 0;
1428            let ly1 = gh;
1429            let verts = [tx(lx0, ly0), tx(lx1, ly0), tx(lx0, ly1), tx(lx1, ly1)];
1430            let uvs = glyph_quad_uvs(u, v, font.glyph_w, font.glyph_h);
1431            write_textured_quad_packet(verts, uvs, color_cmd, clut, tpage);
1432            cursor_x += font.glyph_advance(ch) as i32;
1433        }
1434    }
1435
1436    /// Draw `text` with a top-to-bottom colour gradient.
1437    ///
1438    /// Top of each glyph is tinted `top`; bottom is tinted
1439    /// `bottom`. The GPU gouraud-interpolates down each glyph,
1440    /// producing a smooth vertical gradient across the whole line.
1441    ///
1442    /// **Gouraud quad path** -- 12 GP0 words per glyph (GP0 0x3C).
1443    /// Use when you want a rainbow title, a torch-lit dialogue
1444    /// box, or any per-vertex colour effect; prefer the single-
1445    /// tint [`Self::draw_text`] otherwise.
1446    ///
1447    /// # Example
1448    ///
1449    /// ```ignore
1450    /// // Classic "red hot" gradient.
1451    /// atlas.draw_text_gradient(
1452    ///     40, 10, "INFERNO",
1453    ///     (255, 240, 80),  // bright yellow at the top
1454    ///     (180, 30, 20),   // deep red at the bottom
1455    /// );
1456    /// ```
1457    pub fn draw_text_gradient(
1458        &self,
1459        x: i16,
1460        y: i16,
1461        text: &str,
1462        top: (u8, u8, u8),
1463        bottom: (u8, u8, u8),
1464    ) {
1465        self.draw_text_scaled_gradient(x, y, text, 1, 1, top, bottom);
1466    }
1467
1468    /// Draw `text` with a top-to-bottom gradient, scaled by
1469    /// `(scale_x, scale_y)`. Combines [`Self::draw_text_scaled`]
1470    /// and [`Self::draw_text_gradient`] in one draw -- a 3× title
1471    /// with a fire-colour sweep costs the same 12 words per glyph
1472    /// as a 1× gradient.
1473    ///
1474    /// Nearest-neighbour sampling still applies, so integer
1475    /// scales produce crisp pixel-doubled output.
1476    ///
1477    /// # Example
1478    ///
1479    /// ```ignore
1480    /// // 3× "TITLE" with yellow→red gradient.
1481    /// atlas.draw_text_scaled_gradient(
1482    ///     64, 10, "TITLE", 3, 3,
1483    ///     (255, 220, 80), (200, 40, 20),
1484    /// );
1485    /// ```
1486    pub fn draw_text_scaled_gradient(
1487        &self,
1488        x: i16,
1489        y: i16,
1490        text: &str,
1491        scale_x: u8,
1492        scale_y: u8,
1493        top: (u8, u8, u8),
1494        bottom: (u8, u8, u8),
1495    ) {
1496        assert!(scale_x > 0 && scale_y > 0, "scale must be > 0 in both axes");
1497        if text.is_empty() {
1498            return;
1499        }
1500        let font = self.font;
1501        let gw = font.glyph_w as i16;
1502        let gh = font.glyph_h as i16;
1503        let sw = gw * scale_x as i16;
1504        let sh = gh * scale_y as i16;
1505        let clut = self.clut_word;
1506        let tpage = self.tpage.uv_word(0);
1507        let color0_cmd =
1508            gp0::polygon_opcode(true, true, true, false, false) | pack_color(top.0, top.1, top.2);
1509        let mut cursor_x = x;
1510        reset_texture_window();
1511        for ch in text.chars() {
1512            let Some((u, v)) = self.glyph_uv(ch) else {
1513                cursor_x = cursor_x.wrapping_add(font.glyph_advance(ch) as i16 * scale_x as i16);
1514                continue;
1515            };
1516            let verts = [
1517                (cursor_x, y),
1518                (cursor_x + sw, y),
1519                (cursor_x, y + sh),
1520                (cursor_x + sw, y + sh),
1521            ];
1522            let uvs = glyph_quad_uvs(u, v, font.glyph_w, font.glyph_h);
1523            let colors = [top, top, bottom, bottom];
1524            write_textured_gouraud_quad_packet(verts, uvs, colors, color0_cmd, clut, tpage);
1525            cursor_x = cursor_x.wrapping_add(font.glyph_advance(ch) as i16 * scale_x as i16);
1526        }
1527    }
1528
1529    /// Draw `text` with per-vertex glyph colours, Q8 scaling, and
1530    /// authored letter spacing.
1531    ///
1532    /// `colors` are ordered top-left, top-right, bottom-left,
1533    /// bottom-right, matching `psx_gpu::draw_quad_textured_gouraud`.
1534    /// This is the general-purpose path behind gradient UI text:
1535    /// vertical gradients use `[top, top, bottom, bottom]`, horizontal
1536    /// gradients use `[left, right, left, right]`.
1537    pub fn draw_text_scaled_gouraud_with_spacing_q8(
1538        &self,
1539        x: i16,
1540        y: i16,
1541        text: &str,
1542        scale_x_q8: u16,
1543        scale_y_q8: u16,
1544        letter_spacing: i8,
1545        colors: [(u8, u8, u8); 4],
1546    ) {
1547        assert!(
1548            scale_x_q8 > 0 && scale_y_q8 > 0,
1549            "scale must be > 0 in both axes"
1550        );
1551        if text.is_empty() {
1552            return;
1553        }
1554        let font = self.font;
1555        let gw = font.glyph_w as i16;
1556        let gh = font.glyph_h as i16;
1557        let sw = scale_q8_i16(gw, scale_x_q8);
1558        let sh = scale_q8_i16(gh, scale_y_q8);
1559        let clut = self.clut_word;
1560        let tpage = self.tpage.uv_word(0);
1561        let color0_cmd = gp0::polygon_opcode(true, true, true, false, false)
1562            | pack_color(colors[0].0, colors[0].1, colors[0].2);
1563        let gap_q8 = i32::from(letter_spacing) << 8;
1564        let mut cursor_x_q8 = i32::from(x) << 8;
1565        reset_texture_window();
1566        for ch in text.chars() {
1567            let cursor_x = round_q8_to_i16(cursor_x_q8);
1568            let Some((u, v)) = self.glyph_uv(ch) else {
1569                cursor_x_q8 = cursor_x_q8
1570                    .saturating_add(i32::from(font.glyph_advance(ch)) * i32::from(scale_x_q8))
1571                    .saturating_add(gap_q8);
1572                continue;
1573            };
1574            let verts = [
1575                (cursor_x, y),
1576                (cursor_x + sw, y),
1577                (cursor_x, y + sh),
1578                (cursor_x + sw, y + sh),
1579            ];
1580            let uvs = glyph_quad_uvs(u, v, font.glyph_w, font.glyph_h);
1581            write_textured_gouraud_quad_packet(verts, uvs, colors, color0_cmd, clut, tpage);
1582            cursor_x_q8 = cursor_x_q8
1583                .saturating_add(i32::from(font.glyph_advance(ch)) * i32::from(scale_x_q8))
1584                .saturating_add(gap_q8);
1585        }
1586    }
1587
1588    /// Access the underlying [`BitmapFont`]. Useful for UI code
1589    /// that needs glyph dimensions for its own layout math.
1590    pub fn font(&self) -> &'static BitmapFont {
1591        self.font
1592    }
1593
1594    /// Texture page the atlas is installed at -- useful if the caller
1595    /// wants to restore it after drawing with a different tpage. Always
1596    /// 4bpp, always inside a valid VRAM page-aligned slot.
1597    #[doc(alias = "tpage")]
1598    pub fn texture_page(&self) -> TexturePage {
1599        self.tpage
1600    }
1601}
1602
1603// ======================================================================
1604// Tests (host-side -- pure data-transform checks)
1605// ======================================================================
1606
1607// ======================================================================
1608// TextSink -- one text interface for UI code
1609// ======================================================================
1610
1611/// The minimum a widget needs from a text renderer: draw a run at a position
1612/// in a colour, and measure it.
1613///
1614/// UI code written against this works with any renderer -- [`FontAtlas`], a
1615/// scaled bitmap font, or a game's own atlas -- instead of being written twice
1616/// because two renderers happen to have different inherent methods. That is the
1617/// situation this exists to prevent: a caller with a list, a menu or a dialog to
1618/// draw should not care which one is behind it.
1619///
1620/// Coordinates are screen pixels with the origin top-left, matching the rest of
1621/// the SDK's immediate-mode drawing.
1622pub trait TextSink {
1623    /// Draw `text` with its top-left corner at `(x, y)`.
1624    fn draw(&self, x: i16, y: i16, text: &str, tint: (u8, u8, u8));
1625
1626    /// Width of `text` in screen pixels, using the same metrics `draw` will.
1627    fn width(&self, text: &str) -> i16;
1628
1629    /// Baseline-to-baseline distance for stacked rows.
1630    fn line_height(&self) -> i16;
1631
1632    /// Draw `text` centred on `x`. Provided so every caller does not repeat the
1633    /// half-width arithmetic, and so a renderer with non-integer metrics can
1634    /// override the rounding.
1635    fn draw_centered(&self, x: i16, y: i16, text: &str, tint: (u8, u8, u8)) {
1636        self.draw(x - self.width(text) / 2, y, text, tint);
1637    }
1638}
1639
1640impl TextSink for FontAtlas {
1641    fn draw(&self, x: i16, y: i16, text: &str, tint: (u8, u8, u8)) {
1642        FontAtlas::draw_text(self, x, y, text, tint);
1643    }
1644
1645    fn width(&self, text: &str) -> i16 {
1646        FontAtlas::text_width(self, text) as i16
1647    }
1648
1649    fn line_height(&self) -> i16 {
1650        self.font().line_height as i16
1651    }
1652}
1653
1654#[cfg(test)]
1655mod tests {
1656    extern crate std;
1657
1658    use super::{
1659        blended_draw_mode_word, TextBlend, SEMI_TRANSPARENT_FLAT_RECT_CMD,
1660        SEMI_TRANSPARENT_RECT_CMD,
1661    };
1662    use psx_vram::{TextureDepth, TexturePage};
1663
1664    /// The blend equation is selected by the draw mode, not the
1665    /// primitive, so these two bits are the whole mechanism. Pin them
1666    /// against the hardware's documented ABR encoding.
1667    #[test]
1668    fn text_blend_maps_to_the_hardware_abr_field() {
1669        assert_eq!(TextBlend::Average.semi_transparency_bits(), 0);
1670        assert_eq!(TextBlend::Add.semi_transparency_bits(), 1);
1671        assert_eq!(TextBlend::Subtract.semi_transparency_bits(), 2);
1672        assert_eq!(TextBlend::AddQuarter.semi_transparency_bits(), 3);
1673    }
1674
1675    /// The blended draw mode must differ from the opaque one in the ABR
1676    /// field and NOWHERE else: same page, same depth, same flags. A
1677    /// stray bit here would silently repoint the atlas or change its
1678    /// colour depth mid-string.
1679    #[test]
1680    fn blended_draw_mode_only_moves_the_abr_bits() {
1681        for tpage in [
1682            TexturePage::new(0, 0, TextureDepth::Bit4),
1683            TexturePage::new(640, 256, TextureDepth::Bit4),
1684            TexturePage::new(896, 0, TextureDepth::Bit8),
1685        ] {
1686            let opaque = blended_draw_mode_word(tpage, TextBlend::Average);
1687            for blend in [
1688                TextBlend::Average,
1689                TextBlend::Add,
1690                TextBlend::Subtract,
1691                TextBlend::AddQuarter,
1692            ] {
1693                let word = blended_draw_mode_word(tpage, blend);
1694                assert_eq!(
1695                    (word >> 5) & 3,
1696                    u32::from(blend.semi_transparency_bits()),
1697                    "{blend:?} ABR field"
1698                );
1699                assert_eq!(
1700                    word & !(3 << 5),
1701                    opaque & !(3 << 5),
1702                    "{blend:?} changed something other than ABR"
1703                );
1704            }
1705            // The low nine bits are the same page/blend/depth encoding
1706            // the SDK embeds in a textured primitive's UV word, so the
1707            // rect path and the quad path address one atlas identically.
1708            for blend in [TextBlend::Average, TextBlend::Add, TextBlend::Subtract] {
1709                assert_eq!(
1710                    blended_draw_mode_word(tpage, blend) & 0x1FF,
1711                    u32::from(tpage.uv_word(blend.semi_transparency_bits())),
1712                    "{blend:?} page/blend/depth encoding"
1713                );
1714            }
1715        }
1716    }
1717
1718    /// The blended rectangle differs from the opaque one only by the
1719    /// semi-transparency bit. If these ever diverge further, the blended
1720    /// path has stopped being the same 4-word primitive.
1721    #[test]
1722    fn blended_rect_command_is_the_opaque_one_plus_bit_25() {
1723        const OPAQUE_RECT_CMD: u32 = packet::TEXTURED_RECT;
1724        assert_eq!(SEMI_TRANSPARENT_RECT_CMD, OPAQUE_RECT_CMD | (1 << 25));
1725    }
1726
1727    /// The backdrop is the MONOCHROME rectangle, not the textured one:
1728    /// same bit-25 rule, one opcode lower, and three data words instead
1729    /// of four however large the plate is. That word count is the whole
1730    /// reason a plate is cheaper than a second pass over the glyphs.
1731    #[test]
1732    fn blended_backdrop_command_is_the_flat_rect_plus_bit_25() {
1733        const OPAQUE_FLAT_RECT_CMD: u32 = packet::FLAT_RECT;
1734        assert_eq!(
1735            SEMI_TRANSPARENT_FLAT_RECT_CMD,
1736            OPAQUE_FLAT_RECT_CMD | (1 << 25)
1737        );
1738        // Untextured, so it must NOT carry the textured rect's opcode.
1739        assert_ne!(SEMI_TRANSPARENT_FLAT_RECT_CMD, SEMI_TRANSPARENT_RECT_CMD);
1740    }
1741
1742    /// The wrap scan in `psx-engine`'s UI carries a running sum of
1743    /// `glyph_advance` instead of re-measuring each prefix with
1744    /// `text_width`. That is only bit-exact while `text_width` stays the
1745    /// saturating fold of the per-character advances, so pin it here --
1746    /// including the proportional table, the out-of-range fallback and the
1747    /// u16 saturation.
1748    #[test]
1749    fn text_width_is_the_saturating_fold_of_glyph_advance() {
1750        const BITMAP: [u8; 8] = [0; 8];
1751        const ADVANCES: [u8; 4] = [3, 9, 0, 255];
1752        let proportional = super::BitmapFont {
1753            glyph_w: 8,
1754            glyph_h: 8,
1755            first_char: b'a' as u16,
1756            glyph_count: 4,
1757            bitmap: &BITMAP,
1758            glyph_advances: Some(&ADVANCES),
1759            advance_x: 7,
1760            line_height: 8,
1761            bit_order: super::BitOrder::Lsb,
1762        };
1763        let fixed = super::BitmapFont {
1764            glyph_advances: None,
1765            ..proportional
1766        };
1767
1768        assert_eq!(proportional.glyph_advance('a'), 3);
1769        assert_eq!(proportional.glyph_advance('d'), 255);
1770        // Outside the covered range: falls back to `advance_x`.
1771        assert_eq!(proportional.glyph_advance('z'), 7);
1772        assert_eq!(fixed.glyph_advance('a'), 7);
1773
1774        for font in [&proportional, &fixed] {
1775            for text in ["", "a", "ad", "abcd", "abcdz", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"] {
1776                let folded = text
1777                    .chars()
1778                    .map(|ch| u16::from(font.glyph_advance(ch)))
1779                    .fold(0u16, u16::saturating_add);
1780                assert_eq!(font.text_width(text), folded, "text_width({text:?})");
1781            }
1782        }
1783        // The long run really does saturate, so the case above is load-bearing.
1784        assert_eq!(
1785            proportional.text_width(&std::string::String::from("d").repeat(258)),
1786            u16::MAX
1787        );
1788    }
1789
1790    #[test]
1791    fn centered_text_is_symmetric_about_the_anchor() {
1792        // A stub renderer keeps the contract test independent of any atlas
1793        // upload, which needs hardware.
1794        struct Fixed(i16);
1795        impl super::TextSink for Fixed {
1796            fn draw(&self, _x: i16, _y: i16, _t: &str, _c: (u8, u8, u8)) {}
1797            fn width(&self, text: &str) -> i16 {
1798                self.0 * text.len() as i16
1799            }
1800            fn line_height(&self) -> i16 {
1801                8
1802            }
1803        }
1804        struct Spy(core::cell::Cell<i16>);
1805        impl super::TextSink for Spy {
1806            fn draw(&self, x: i16, _y: i16, _t: &str, _c: (u8, u8, u8)) {
1807                self.0.set(x);
1808            }
1809            fn width(&self, text: &str) -> i16 {
1810                4 * text.len() as i16
1811            }
1812            fn line_height(&self) -> i16 {
1813                8
1814            }
1815        }
1816        let f = Fixed(4);
1817        assert_eq!(super::TextSink::width(&f, "abcd"), 16);
1818        let spy = Spy(core::cell::Cell::new(-1));
1819        spy.draw_centered(160, 0, "abcd", (0, 0, 0));
1820        // 4 glyphs * 4 px = 16 wide, so centred on 160 starts at 152.
1821        assert_eq!(spy.0.get(), 152);
1822    }
1823
1824    use super::*;
1825
1826    /// Synthetic font: two 8×2 glyphs, LSB-first, for bit-unpacking
1827    /// verification.
1828    const TEST_FONT: BitmapFont = BitmapFont {
1829        glyph_w: 8,
1830        glyph_h: 2,
1831        first_char: b'A' as u16,
1832        glyph_count: 2,
1833        bitmap: &[
1834            // 'A': row 0 = all pixels, row 1 = leftmost only
1835            0xFF, 0x01, // 'B': row 0 = alternating, row 1 = rightmost
1836            0x55, 0x80,
1837        ],
1838        glyph_advances: None,
1839        advance_x: 8,
1840        line_height: 2,
1841        bit_order: BitOrder::Lsb,
1842    };
1843
1844    const TEST_PROPORTIONAL_ADVANCES: [u8; 2] = [3, 7];
1845    const TEST_PROPORTIONAL_FONT: BitmapFont = BitmapFont {
1846        glyph_advances: Some(&TEST_PROPORTIONAL_ADVANCES),
1847        ..TEST_FONT
1848    };
1849
1850    const TOO_TALL_FONT: BitmapFont = BitmapFont {
1851        glyph_w: 255,
1852        glyph_h: 255,
1853        glyph_count: 2,
1854        bitmap: &[],
1855        ..TEST_FONT
1856    };
1857
1858    #[test]
1859    fn row_bytes_handles_widths_under_and_over_8() {
1860        let mut f = TEST_FONT;
1861        assert_eq!(f.row_bytes(), 1);
1862        f.glyph_w = 12;
1863        assert_eq!(f.row_bytes(), 2);
1864        f.glyph_w = 16;
1865        assert_eq!(f.row_bytes(), 2);
1866        f.glyph_w = 24;
1867        assert_eq!(f.row_bytes(), 3);
1868    }
1869
1870    #[test]
1871    fn lsb_row_unpacks_as_expected() {
1872        let row_a0 = TEST_FONT.glyph_row_packed(0, 0);
1873        assert_eq!(row_a0, 0xFF);
1874        let row_a1 = TEST_FONT.glyph_row_packed(0, 1);
1875        assert_eq!(row_a1, 0x01);
1876        let row_b0 = TEST_FONT.glyph_row_packed(1, 0);
1877        assert_eq!(row_b0, 0x55);
1878        let row_b1 = TEST_FONT.glyph_row_packed(1, 1);
1879        assert_eq!(row_b1, 0x80);
1880    }
1881
1882    #[test]
1883    fn msb_order_mirrors_the_byte() {
1884        let msb_font = BitmapFont {
1885            bit_order: BitOrder::Msb,
1886            ..TEST_FONT
1887        };
1888        // 0xFF mirrors to 0xFF.
1889        assert_eq!(msb_font.glyph_row_packed(0, 0), 0xFF);
1890        // 0x01 in MSB-first becomes 0x80 after reversing.
1891        assert_eq!(msb_font.glyph_row_packed(0, 1), 0x80);
1892        // 0x55 (0b01010101) mirrors to 0xAA (0b10101010).
1893        assert_eq!(msb_font.glyph_row_packed(1, 0), 0xAA);
1894    }
1895
1896    #[test]
1897    fn glyph_advances_override_fixed_advance_when_present() {
1898        assert_eq!(TEST_PROPORTIONAL_FONT.glyph_advance('A'), 3);
1899        assert_eq!(TEST_PROPORTIONAL_FONT.glyph_advance('B'), 7);
1900        assert_eq!(TEST_PROPORTIONAL_FONT.glyph_advance('Z'), 8);
1901        assert_eq!(TEST_PROPORTIONAL_FONT.text_width("ABA"), 13);
1902    }
1903
1904    #[test]
1905    fn text_width_with_spacing_inserts_gaps_between_glyphs_only() {
1906        assert_eq!(TEST_PROPORTIONAL_FONT.text_width_with_spacing("", 5), 0);
1907        assert_eq!(TEST_PROPORTIONAL_FONT.text_width_with_spacing("A", 5), 3);
1908        assert_eq!(TEST_PROPORTIONAL_FONT.text_width_with_spacing("ABA", 2), 17);
1909        assert_eq!(TEST_PROPORTIONAL_FONT.text_width_with_spacing("ABA", -2), 9);
1910    }
1911
1912    #[test]
1913    fn q8_scale_helpers_round_to_screen_pixels() {
1914        assert_eq!(scale_q8_i16(8, 256), 8);
1915        assert_eq!(scale_q8_i16(8, 384), 12);
1916        assert_eq!(scale_q8_i16(17, 384), 26);
1917        assert_eq!(round_q8_to_i16(384), 2);
1918        assert_eq!(round_q8_to_i16(-384), -2);
1919    }
1920
1921    #[test]
1922    fn glyph_quad_uvs_saturate_the_rightmost_atlas_column() {
1923        // A 256-texel-wide atlas (16 columns of 16px glyphs) places the last
1924        // column's glyph at u=240, so its far U is 240+16=256, which wraps to 0
1925        // in the u8 UV field and smears the glyph across the quad. It must
1926        // saturate to 255 instead. (This was the "garbled O in MUSIC VOLUME"
1927        // bug: 'O' is the only rightmost-column glyph in uppercase menu text.)
1928        let edge = glyph_quad_uvs(240, 0, 16, 14);
1929        assert_eq!(edge[1].0, 255, "far U must saturate, not wrap to 0");
1930        assert_eq!(edge[3].0, 255);
1931        // Interior columns stay exact (no clamping kicks in).
1932        assert_eq!(
1933            glyph_quad_uvs(224, 0, 16, 14),
1934            [(224, 0), (240, 0), (224, 14), (240, 14)]
1935        );
1936        // V saturates the same way near the bottom edge of a tall atlas.
1937        let tall = glyph_quad_uvs(0, 248, 16, 14);
1938        assert_eq!(tall[2].1, 255);
1939        assert_eq!(tall[3].1, 255);
1940    }
1941
1942    #[test]
1943    fn zen_dots_display_is_native_size_and_fits_the_ps1_upload_budget() {
1944        let font = &crate::fonts::ZEN_DOTS_DISPLAY;
1945        assert_eq!(font.first_char, b'A' as u16);
1946        assert_eq!(font.glyph_count, 26);
1947        assert_eq!(font.glyph_h, 27);
1948        assert_eq!(font.text_width("CORTEX"), 162);
1949
1950        let (_, atlas_w, atlas_h, halfwords_per_row) = font_atlas_dims(font);
1951        assert!(atlas_w <= FontAtlas::MAX_ATLAS_W_TEXELS);
1952        assert!(atlas_h <= u8::MAX as u16);
1953        assert!(
1954            usize::from(halfwords_per_row) * usize::from(atlas_h) <= FontAtlas::MAX_PACK_HALFWORDS
1955        );
1956    }
1957
1958    #[test]
1959    fn drippy_space_is_display_only_and_fits_the_ps1_upload_budget() {
1960        for font in [
1961            &crate::fonts::DRIPPY_SPACE,
1962            &crate::fonts::DRIPPY_SPACE_DISPLAY,
1963        ] {
1964            let expected_first = if font.glyph_count == 26 { b'A' } else { b' ' };
1965            assert_eq!(font.first_char, expected_first as u16);
1966            let (_, atlas_w, atlas_h, halfwords_per_row) = font_atlas_dims(font);
1967            assert!(atlas_w <= FontAtlas::MAX_ATLAS_W_TEXELS);
1968            assert!(atlas_h <= u8::MAX as u16);
1969            assert!(
1970                usize::from(halfwords_per_row) * usize::from(atlas_h)
1971                    <= FontAtlas::MAX_PACK_HALFWORDS
1972            );
1973        }
1974        assert_eq!(crate::fonts::DRIPPY_SPACE.glyph_count, 0x5B - 0x20);
1975        assert_eq!(crate::fonts::DRIPPY_SPACE_DISPLAY.glyph_count, 26);
1976    }
1977
1978    const ARENA_FONTS: &[&BitmapFont] = &[
1979        &crate::fonts::ZEN_DOTS_DISPLAY,
1980        &crate::fonts::ZEN_DOTS,
1981        &crate::fonts::KENNEY_FUTURE_NARROW,
1982        &crate::fonts::KENNEY_PIXEL,
1983        &crate::fonts::KENNEY_FUTURE,
1984        &crate::fonts::BASIC,
1985    ];
1986
1987    #[test]
1988    fn arena_six_font_set_has_a_provably_minimal_two_page_pack() {
1989        let mut placements = [None; MAX_PACKED_FONT_ATLASES];
1990        let metrics = plan_font_pack(ARENA_FONTS, &mut placements).unwrap();
1991        assert_eq!(metrics.pages, 2, "six fonts must share two tpages");
1992        assert_eq!(metrics.upload_rows, 241);
1993        assert_eq!(metrics.scratch_halfwords, 2 * 64 * 241);
1994        assert_eq!(font_pack_metrics(ARENA_FONTS), Some(metrics));
1995
1996        // More than one complete page of rectangle area is occupied, so the
1997        // two-page result is not merely heuristic for this shipping set: it is
1998        // the mathematical minimum without rotating or modifying an atlas.
1999        let occupied_halfwords: usize = ARENA_FONTS
2000            .iter()
2001            .map(|font| {
2002                let (_, _, h, w) = font_atlas_dims(font);
2003                usize::from(h) * usize::from(w)
2004            })
2005            .sum();
2006        assert!(occupied_halfwords > FONT_PAGE_HALFWORDS * 256);
2007        assert!(occupied_halfwords <= 2 * FONT_PAGE_HALFWORDS * 256);
2008
2009        for (a_index, a) in placements[..ARENA_FONTS.len()].iter().enumerate() {
2010            let a = a.unwrap();
2011            let (_, _, a_h, a_w) = font_atlas_dims(ARENA_FONTS[a_index]);
2012            assert!(a.x_halfwords + a_w <= FONT_PAGE_HALFWORDS as u16);
2013            assert!(a.y + a_h <= 256);
2014            for (b_index, b) in placements[..a_index].iter().enumerate() {
2015                let b = b.unwrap();
2016                let (_, _, b_h, b_w) = font_atlas_dims(ARENA_FONTS[b_index]);
2017                assert!(!placements_overlap(a, a_w, a_h, b, b_w, b_h));
2018            }
2019        }
2020    }
2021
2022    #[test]
2023    fn packed_arena_atlases_preserve_every_glyph_and_padding_texel() {
2024        let mut placements = [None; MAX_PACKED_FONT_ATLASES];
2025        let metrics = plan_font_pack(ARENA_FONTS, &mut placements).unwrap();
2026        let mut scratch = std::vec![0xFFFF; metrics.scratch_halfwords];
2027        pack_font_bits(ARENA_FONTS, &placements, metrics, &mut scratch).unwrap();
2028        let stride = usize::from(metrics.pages) * FONT_PAGE_HALFWORDS;
2029
2030        for (font_index, &font) in ARENA_FONTS.iter().enumerate() {
2031            let placement = placements[font_index].unwrap();
2032            let (glyphs_per_row, atlas_w, atlas_h, _) = font_atlas_dims(font);
2033            let cell_w = cell_width(font);
2034            for local_y in 0..atlas_h {
2035                let glyph_row = local_y / u16::from(font.glyph_h);
2036                let glyph_y = (local_y % u16::from(font.glyph_h)) as u8;
2037                for local_x in 0..atlas_w {
2038                    let glyph_col = local_x / cell_w;
2039                    let glyph_x = local_x % cell_w;
2040                    let glyph_index = glyph_row * glyphs_per_row + glyph_col;
2041                    let expected =
2042                        if glyph_index < font.glyph_count && glyph_x < u16::from(font.glyph_w) {
2043                            ((font.glyph_row_packed(glyph_index, glyph_y) >> glyph_x) & 1) as u16
2044                        } else {
2045                            0
2046                        };
2047                    let global_halfword = usize::from(placement.page) * FONT_PAGE_HALFWORDS
2048                        + usize::from(placement.x_halfwords)
2049                        + usize::from(local_x / 4);
2050                    let word =
2051                        scratch[usize::from(placement.y + local_y) * stride + global_halfword];
2052                    let actual = (word >> ((local_x & 3) * 4)) & 0xF;
2053                    assert_eq!(
2054                        actual, expected,
2055                        "font {font_index} texel ({local_x},{local_y})"
2056                    );
2057                }
2058            }
2059        }
2060    }
2061
2062    #[test]
2063    fn packed_origins_feed_exact_rect_and_quad_packet_uvs() {
2064        let fonts = [&TEST_FONT, &TEST_FONT];
2065        let mut placements = [None; MAX_PACKED_FONT_ATLASES];
2066        let metrics = plan_font_pack(&fonts, &mut placements).unwrap();
2067        assert_eq!(metrics.pages, 1);
2068        assert_eq!(placements[0].unwrap().x_halfwords, 0);
2069        assert_eq!(placements[1].unwrap().x_halfwords, 4);
2070
2071        let clut = Clut::new(960, 500);
2072        for (index, &font) in fonts.iter().enumerate() {
2073            let placement = placements[index].unwrap();
2074            let (glyphs_per_row, _, _, _) = font_atlas_dims(font);
2075            let atlas = FontAtlas {
2076                font,
2077                tpage: TexturePage::new(placement.page * 64, 0, TextureDepth::Bit4),
2078                clut_word: clut.uv_word(),
2079                glyphs_per_row,
2080                uv_origin: ((placement.x_halfwords * 4) as u8, placement.y as u8),
2081            };
2082            for glyph_index in 0..font.glyph_count {
2083                let ch = char::from_u32(u32::from(font.first_char + glyph_index)).unwrap();
2084                let (u, v) = atlas.glyph_uv(ch).unwrap();
2085                let expected_u = u16::from(atlas.uv_origin.0)
2086                    + (glyph_index % glyphs_per_row) * cell_width(font);
2087                let expected_v = u16::from(atlas.uv_origin.1)
2088                    + (glyph_index / glyphs_per_row) * u16::from(font.glyph_h);
2089                assert_eq!((u16::from(u), u16::from(v)), (expected_u, expected_v));
2090
2091                // GP0(64h) takes this packed UV/CLUT word. Every quad path
2092                // shares `glyph_uv` and only extends the same top-left corner.
2093                let rect_uv_word = pack_texcoord(u, v, atlas.clut_word);
2094                assert_eq!(rect_uv_word & 0xFFFF, u32::from(u) | (u32::from(v) << 8));
2095                assert_eq!((rect_uv_word >> 16) as u16, atlas.clut_word);
2096                let quad = glyph_quad_uvs(u, v, font.glyph_w, font.glyph_h);
2097                assert_eq!(quad[0], (u, v));
2098                assert_eq!(quad[3].0, u.saturating_add(font.glyph_w));
2099                assert_eq!(quad[3].1, v.saturating_add(font.glyph_h));
2100            }
2101        }
2102    }
2103
2104    #[test]
2105    fn font_pack_planner_fails_closed_outside_its_bound() {
2106        assert_eq!(font_pack_metrics(&[]), None);
2107        let too_many = [&TEST_FONT; MAX_PACKED_FONT_ATLASES + 1];
2108        assert_eq!(font_pack_metrics(&too_many), None);
2109
2110        assert_eq!(font_pack_metrics(&[&TOO_TALL_FONT]), None);
2111    }
2112
2113    #[test]
2114    fn every_pre_upload_failure_clears_stale_atlas_outputs() {
2115        struct NoAllocationExpected;
2116        impl VramRegionSource for NoAllocationExpected {
2117            fn alloc_page_run(
2118                &mut self,
2119                _count: u16,
2120                _depth: TextureDepth,
2121                _page_y: u16,
2122            ) -> Option<(TexturePage, VramHandle)> {
2123                panic!("preflight failure must not reserve pages")
2124            }
2125
2126            fn alloc_clut(&mut self, _entries: u16) -> Option<(Clut, VramHandle)> {
2127                panic!("preflight failure must not reserve a CLUT")
2128            }
2129        }
2130
2131        let stale = FontAtlas {
2132            font: &TEST_FONT,
2133            tpage: TexturePage::new(0, 0, TextureDepth::Bit4),
2134            clut_word: Clut::new(0, 480).uv_word(),
2135            glyphs_per_row: 2,
2136            uv_origin: (0, 0),
2137        };
2138        let mut alloc = NoAllocationExpected;
2139
2140        let mut out = [Some(stale); 2];
2141        assert!(upload_fonts(&[], &mut alloc, &mut [], &mut out).is_none());
2142        assert!(out.iter().all(Option::is_none));
2143
2144        out.fill(Some(stale));
2145        assert!(upload_fonts(
2146            &[&TEST_FONT, &TEST_FONT, &TEST_FONT],
2147            &mut alloc,
2148            &mut [],
2149            &mut out,
2150        )
2151        .is_none());
2152        assert!(out.iter().all(Option::is_none));
2153
2154        out.fill(Some(stale));
2155        assert!(upload_fonts(&[&TEST_FONT], &mut alloc, &mut [], &mut out).is_none());
2156        assert!(out.iter().all(Option::is_none));
2157    }
2158}
2159
2160#[cfg(test)]
2161mod deferred_packet_tests {
2162    extern crate std;
2163    use super::*;
2164    use std::vec::Vec;
2165
2166    fn atlas() -> FontAtlas {
2167        FontAtlas {
2168            font: &fonts::SPLEEN_5X8,
2169            tpage: TexturePage::new(640, 0, TextureDepth::Bit4),
2170            clut_word: Clut::new(960, 500).uv_word(),
2171            glyphs_per_row: 32,
2172            uv_origin: (0, 16),
2173        }
2174    }
2175
2176    #[test]
2177    fn deferred_text_uses_padded_cells_and_packed_origin() {
2178        let atlas = atlas();
2179        let mut packets = Vec::new();
2180        assert!(atlas.emit_text_packets(-4, 9, "fg", (128, 90, 20), |p| {
2181            packets.push(p.to_vec());
2182            true
2183        }));
2184        assert_eq!(packets.len(), 3);
2185        assert_eq!(packets[0].len(), 2);
2186        assert_eq!(packets[0][0] >> 24, 0xe1);
2187        assert_eq!(packets[0][1], 0xe2000000);
2188        for (i, ch) in ['f', 'g'].into_iter().enumerate() {
2189            let index = ch as u16 - atlas.font.first_char;
2190            let u = (index % 32) * 8; // five texels padded to eight
2191            let v = 16 + (index / 32) * 8;
2192            assert_eq!(
2193                packets[i + 1],
2194                [
2195                    0x64000000 | pack_color(128, 90, 20),
2196                    pack_vertex(-4 + i as i16 * 5, 9),
2197                    pack_texcoord(u as u8, v as u8, atlas.clut_word),
2198                    pack_xy(5, 8),
2199                ]
2200            );
2201        }
2202    }
2203
2204    #[test]
2205    fn deferred_sink_failure_stops_without_emitting_more_packets() {
2206        for capacity in 0..3 {
2207            let mut calls = 0;
2208            assert!(
2209                !atlas().emit_text_packets(0, 0, "abcdef", (128, 128, 128), |_| {
2210                    calls += 1;
2211                    calls <= capacity
2212                })
2213            );
2214            assert_eq!(calls, capacity + 1);
2215        }
2216    }
2217
2218    #[test]
2219    fn missing_glyph_keeps_advance_and_empty_run_only_sets_material() {
2220        let atlas = atlas();
2221        let mut packets = Vec::new();
2222        assert!(
2223            atlas.emit_text_packets(10, 20, "\u{10ffff}f", (128, 128, 128), |p| {
2224                packets.push(p.to_vec());
2225                true
2226            })
2227        );
2228        assert_eq!(packets.len(), 2);
2229        assert_eq!(
2230            packets[1][1],
2231            pack_vertex(10 + atlas.glyph_advance('\u{10ffff}') as i16, 20)
2232        );
2233        let mut calls = 0;
2234        assert!(atlas.emit_text_packets(0, 0, "", (0, 0, 0), |p| {
2235            calls += 1;
2236            assert_eq!(p.len(), 2);
2237            true
2238        }));
2239        assert_eq!(calls, 1);
2240    }
2241}