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}