rustyn64_rdp/lib.rs
1//! `rustyn64-rdp` — RDP (Reality Display Processor), the RCP rasterizer.
2//!
3//! The RDP consumes a command stream (from the RSP or the CPU via the DP FIFO)
4//! and rasterizes triangles/rectangles into a framebuffer in RDRAM, running the
5//! color-combiner + blender + Z/coverage pipeline. The Video Interface (VI)
6//! then scans that framebuffer out. The accuracy bar is **LLE** — a faithful
7//! per-pixel pipeline (the ParaLLEl-RDP / angrylion reference), not a
8//! triangle-list HLE.
9//!
10//! [`Rdp::tick`] decodes the DP FIFO — recognizing every command `0x00`–`0x3F`
11//! and consuming each one's full length (via [`command`]) so the stream stays
12//! aligned — and dispatches the sync commands and the **FILL pipeline** (Set
13//! Color Image, Set Fill Color, Set Scissor, Fill Rectangle), which writes solid
14//! rectangles into the framebuffer. The rest of the rasterizer (edge-walked
15//! triangles, the texture engine with TMEM, the combiner/blender, dithering,
16//! coverage AA) is the remainder of this roadmap phase.
17//!
18//! Part of the one-directional chip-crate graph (see `docs/architecture.md`):
19//! this crate depends on **exactly one** chip crate, `rustyn64-cart`, purely for
20//! its [`RdramBus`] memory-bus trait — the RDP reads texture and framebuffer
21//! reaches its tile storage through `rustynes-mappers`. `#![no_std]` + `alloc`.
22
23#![no_std]
24#![forbid(unsafe_code)]
25#![warn(missing_docs)]
26#![allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
27// Skeleton `tick` is deliberately non-`const` (it will drain the DP FIFO).
28#![allow(clippy::missing_const_for_fn)]
29
30extern crate alloc;
31
32pub mod command;
33
34pub use rustyn64_cart::RdramBus;
35
36use serde::{Deserialize, Serialize};
37
38/// The narrow bus the RDP sees.
39///
40/// RDRAM access (for the framebuffer + texture fetches) plus the
41/// DP-interrupt-raise hook. Extends [`RdramBus`] (`RustyNES`'s `PpuBus` analog)
42/// with the IRQ notify the rasterizer needs on `SYNC_FULL` / DP-done.
43pub trait VideoBus: RdramBus {
44 /// Raise the DP (RDP-done) interrupt on the MI. Default no-op for ad-hoc
45 /// test buses; `rustyn64-core` sets the live `MI_INTR.dp` line.
46 fn raise_dp_interrupt(&mut self) {}
47}
48
49/// One RGBA8888 output pixel (post-VI-filter); the framebuffer the frontend
50/// presents is a slice of these.
51pub type Pixel = u32;
52
53/// `DPC_STATUS.XBUS` — the DP reads commands from DMEM rather than RDRAM.
54pub const DP_STATUS_XBUS: u32 = 0x1;
55/// `DPC_STATUS.FREEZE` — the DP is halted; registers can be read/written freely
56/// without the command FIFO advancing.
57pub const DP_STATUS_FREEZE: u32 = 0x2;
58/// `DPC_STATUS.END_VALID` (the wiki's `END_PENDING`, read bit 9) — an end
59/// address is latched behind an in-flight transfer.
60///
61/// Defined for the read-back layout but **not yet driven**: setting it requires
62/// tracking a transfer *in progress*, which only exists once the rasterizer
63/// runs (`tick` is a stub). It therefore always reads 0 today, which is exactly
64/// what n64-systemtest's frozen `start-valid` case expects; the set/clear
65/// transition lands with the FIFO drain.
66pub const DP_STATUS_END_VALID: u32 = 0x200;
67/// `DPC_STATUS.START_VALID` — a start address is latched and pending; further
68/// writes to `DPC_START` are ignored until it is consumed by a `DPC_END` write.
69pub const DP_STATUS_START_VALID: u32 = 0x400;
70
71/// The `DPC_START`/`DPC_END` register mask: a 24-bit, 8-byte-aligned RDRAM
72/// address (n64-systemtest's `RDP START & END REG (masking)`).
73pub const DPC_ADDR_MASK: u32 = 0x00FF_FFF8;
74
75/// `Sync Load` (0x26) pipeline stall, in GCLK cycles.
76///
77/// Fixed and unconditional — the RDP always stalls this long, whether or not a
78/// load is in flight (N64brew *Reality Display Processor/Commands* §0x26). One
79/// `tick` is one GCLK.
80pub const SYNC_LOAD_GCLK: u32 = 25;
81
82// `Set Other Modes.cycle_type` (command bits 53:52 = word-0 bits 21:20).
83// Provenance: N64brew *Reality Display Processor/Commands* §0x2F – Set Other
84// Modes, the `bit 53:52` row — *"cycle_type[1:0]: Determines pipeline mode.
85// Either 1-Cycle (0), 2-Cycle (1), COPY (2), FILL (3)"*. Which of these read the
86// fill register rather than the combiner is ledger **R-21**.
87
88/// `Set Other Modes.cycle_type` value for **1-cycle** mode — the default
89/// pipeline: one combiner pass per pixel.
90pub const CYCLE_TYPE_1CYCLE: u8 = 0;
91
92/// `Set Other Modes.cycle_type` value for **2-cycle** mode — two combiner passes
93/// per pixel, so a second texel and the LOD fraction become available.
94pub const CYCLE_TYPE_2CYCLE: u8 = 1;
95
96/// `Set Other Modes.cycle_type` value for **copy** mode — a raw texel blit that
97/// bypasses the combiner and blender entirely.
98pub const CYCLE_TYPE_COPY: u8 = 2;
99
100/// `Set Other Modes.cycle_type` value for **fill** mode — the `Set Fill Color`
101/// register is repeated verbatim to memory, bypassing the combiner and blender.
102pub const CYCLE_TYPE_FILL: u8 = 3;
103
104/// `Sync Pipe` (0x27) pipeline stall, in GCLK cycles.
105///
106/// Fixed and unconditional (N64brew *…/Commands* §0x27).
107pub const SYNC_PIPE_GCLK: u32 = 50;
108/// `Sync Tile` (0x28) pipeline stall, in GCLK cycles.
109///
110/// Fixed and unconditional (N64brew *…/Commands* §0x28).
111pub const SYNC_TILE_GCLK: u32 = 33;
112
113// RDP command opcodes handled by the dispatcher (bits 61:56 of a command word).
114const OP_SYNC_LOAD: u8 = 0x26;
115const OP_SYNC_PIPE: u8 = 0x27;
116const OP_SYNC_TILE: u8 = 0x28;
117const OP_SYNC_FULL: u8 = 0x29;
118const OP_TEXTURE_RECTANGLE: u8 = 0x24;
119const OP_TEXTURE_RECTANGLE_FLIP: u8 = 0x25;
120const OP_SET_SCISSOR: u8 = 0x2D;
121const OP_LOAD_TLUT: u8 = 0x30;
122const OP_SET_TILE_SIZE: u8 = 0x32;
123const OP_LOAD_BLOCK: u8 = 0x33;
124const OP_LOAD_TILE: u8 = 0x34;
125const OP_SET_TILE: u8 = 0x35;
126
127/// TMEM byte offset of the high (palette / split-high) half.
128const TMEM_HIGH: u32 = 0x800;
129
130/// Sign-extend the low 16 bits of `v` to `i32` (for the `s10.5` `S`/`T` and the
131/// `s5.10` `DsDx`/`DtDy`). `v as i16` already keeps only the low 16 bits.
132const fn sext16(v: u32) -> i32 {
133 v as i16 as i32
134}
135
136/// Sign-extend the low `bits` bits of `v` to `i32` (for the triangle edge fields:
137/// `yh/ym/yl` are 14-bit `s11.2`, `xh/xm/xl` 28-bit `s11.16`, the slopes 30-bit).
138#[allow(clippy::cast_possible_wrap)] // the reinterpret-as-signed IS the sign extension
139const fn sext(v: u32, bits: u32) -> i32 {
140 debug_assert!(bits >= 1 && bits <= 32, "sext width out of range");
141 let shift = 32 - bits;
142 ((v << shift) as i32) >> shift
143}
144
145/// Sign-extend the low 9 bits of `x` (the combiner's `bitfieldExtract(x, 0, 9)`).
146///
147/// Only bits 0–8 are used: the `<< 23` shifts bit 8 to the sign position and the
148/// arithmetic `>> 23` fills from it, so any higher bits of `x` are discarded.
149const fn sext9(x: i32) -> i32 {
150 (x << 23) >> 23
151}
152
153/// Position of the most significant set bit of `x`, or `-1` for `x <= 0` — GLSL
154/// `findMSB` semantics for the non-negative inputs this codec uses (`z_encode.h`).
155/// All callers here pass non-negative magnitudes; the `-1` result flows through
156/// `max(findMSB(dz), 0)` in [`dz_compress`], and [`combine_dz`] guards on it so a
157/// non-positive input can never reach the negative shift (GLSL's signed `findMSB`
158/// differs for genuinely-negative inputs, which do not occur in this domain).
159#[allow(
160 clippy::cast_possible_wrap,
161 reason = "leading_zeros() is 0..=31, always in range for i32"
162)]
163const fn find_msb(x: i32) -> i32 {
164 if x <= 0 {
165 -1
166 } else {
167 31 - (x.leading_zeros() as i32)
168 }
169}
170
171/// Decompress a 14-bit stored depth to an 18-bit UNORM (`0..=0x3_FFFF`). The N64
172/// Z buffer uses an inverted floating-point encoding with more precision near 1
173/// (ParaLLEl-RDP `z_encode.h`): `exponent` in bits 13:11, `mantissa` in 10:0.
174fn z_decompress(z: u16) -> i32 {
175 let z = i32::from(z);
176 let exponent = z >> 11;
177 let mantissa = z & 0x7ff;
178 let shift = (6 - exponent).max(0);
179 let base = 0x4_0000 - (0x4_0000 >> exponent);
180 (mantissa << shift) + base
181}
182
183/// Compress an 18-bit UNORM depth back to the 14-bit stored form (`z_encode.h`).
184/// Inverse of [`z_decompress`]; `exponent` is derived from the leading zeros of
185/// the inverted depth so precision concentrates near the far plane.
186///
187/// Verified by the [`z_decompress`] round-trip test and exercised by
188/// [`Rdp::zbuffer_write`], which compresses the computed 18-bit depth before it is
189/// packed into the Z buffer.
190#[allow(
191 clippy::cast_sign_loss,
192 clippy::cast_possible_truncation,
193 reason = "the packed result is 0..=0x3FFF, always a valid non-negative u16"
194)]
195fn z_compress(z: i32) -> u16 {
196 // Clamp to the 18-bit UNORM domain so the `0x3FFFF - z` subtraction and the
197 // `z >> shift` mantissa are well-defined for any computed depth (parallel-rdp
198 // clamps `z` this way before writeback, `clamping.h`).
199 let z = z.clamp(0, 0x3_FFFF);
200 let inv_z = (0x3_FFFF - z).max(1);
201 let exponent = (17 - find_msb(inv_z)).clamp(0, 7);
202 let shift = (6 - exponent).max(0);
203 let mantissa = (z >> shift) & 0x7ff;
204 ((exponent << 11) + mantissa) as u16
205}
206
207/// Decompress a 4-bit stored `dz` to its linear delta `1 << dz` (`z_encode.h`).
208const fn dz_decompress(dz: i32) -> i32 {
209 1 << dz
210}
211
212/// Compress a linear `dz` delta to its 4-bit `log2` form (`z_encode.h`). The RDP
213/// uses this cheap integer `log2`, correct only for powers of two (hence the
214/// "dz should be a power of 2" hazard); `dz == 0` yields 0 via `find_msb`'s `-1`.
215const fn dz_compress(dz: i32) -> i32 {
216 let m = find_msb(dz);
217 if m < 0 { 0 } else { m }
218}
219
220/// The largest power of two `<= dz` (ParaLLEl-RDP `depth_test.h` `combine_dz`).
221/// Guards on `find_msb(dz) >= 0` rather than `dz != 0` so a non-positive `dz`
222/// returns 0 instead of shifting `1 << -1` (the codec only feeds it non-negative
223/// magnitudes; the guard makes it panic-free for any `i32` regardless).
224const fn combine_dz(dz: i32) -> i32 {
225 let m = find_msb(dz);
226 if m >= 0 { 1 << m } else { 0 }
227}
228
229/// Interpolate the per-pixel depth (18-bit UNORM, `0..=0x3_FFFF`) for pixel
230/// `(x, y)` from the triangle's z-coefficients — a faithful port of ParaLLEl-RDP's
231/// `interpolate_z` (`interpolation.h`) for the full-coverage, `do_offset == false`
232/// case (sub-pixel coverage snapping is the deferred R-9 residual).
233///
234/// `z_base`/`dzdx`/`dzde` are the `s15.16` z-coefficient and its per-x / per-major-
235/// edge deltas; `major_x` is the `s15.16` major-edge x at this scanline (its
236/// integer part is the interpolation origin, its `xfrac` aligns to the edge);
237/// `y_base` is the top scanline. 32-bit wrapping matches the reference's `int`
238/// arithmetic; the final `clamp_z` bounds the result to the depth range.
239#[allow(
240 clippy::cast_possible_truncation,
241 reason = "major_x fields and the clamped result are within range for the casts"
242)]
243#[allow(
244 clippy::similar_names,
245 reason = "dzdx / dzde are the N64 RDP's own z-coefficient names"
246)]
247fn interpolate_z(
248 z_base: i32,
249 dzdx: i32,
250 dzde: i32,
251 major_x: i64,
252 y: i32,
253 y_base: i32,
254 x: i32,
255) -> i32 {
256 let base_x = (major_x >> 16) as i32;
257 let xfrac = ((major_x >> 8) & 0xff) as i32;
258 let mut z = z_base.wrapping_add(dzde.wrapping_mul(y.wrapping_sub(y_base)));
259 z = ((z & !0x1ff).wrapping_sub(xfrac.wrapping_mul((dzdx >> 8) & !1))) & !0x3ff;
260 z = z.wrapping_add(dzdx.wrapping_mul(x.wrapping_sub(base_x)));
261 // Snap (full coverage: the first-subpixel xoff/yoff terms are 0).
262 let snapped = ((z >> 10) << 2) >> 5;
263 snapped.clamp(0, 0x3_FFFF)
264}
265
266/// Interpolate the per-pixel shade color for pixel `(x, y)` from a triangle's
267/// shade coefficients — a port of ParaLLEl-RDP's `interpolate_rgba`
268/// (`interpolation.h`) for the full-coverage case. `base`/`dx`/`de` are the `s15.16`
269/// RGBA base and its per-x / per-major-edge deltas; `base_x` is the major-edge x
270/// integer at this scanline. Each channel: walk the scanline base by `de`, add the
271/// per-x `dx` term (masked `& ~0x1f`), snap `>> 14 << 2 >> 4`, and clamp to a byte.
272#[allow(
273 clippy::cast_possible_truncation,
274 reason = "base_x derives from the s15.16 major-edge x, in range for the cast"
275)]
276fn interpolate_shade(
277 base: &[i32; 4],
278 dx: &[i32; 4],
279 de: &[i32; 4],
280 major_x: i64,
281 y: i32,
282 y_base: i32,
283 x: i32,
284) -> [u8; 4] {
285 let base_x = (major_x >> 16) as i32;
286 let mut out = [0u8; 4];
287 for (c, o) in out.iter_mut().enumerate() {
288 let scan = base[c].wrapping_add(de[c].wrapping_mul(y.wrapping_sub(y_base)));
289 let v = scan.wrapping_add((dx[c] & !0x1f).wrapping_mul(x.wrapping_sub(base_x)));
290 // The snap is done in i16, matching the oracle's `i16x4(rgba >> 14)` cast
291 // (`interpolation.h`) — the truncation to 16 bits is part of the hardware
292 // result, and it keeps the `<< 2` off the i32 sign bit.
293 let snapped = (((v >> 14) as i16).wrapping_shl(2) >> 4).into();
294 *o = clamp_9bit(snapped);
295 }
296 out
297}
298
299/// The raw `S`/`T`/`W` accumulators for pixel `(x, y)`, **before** the `>> 16` and
300/// the (non-)perspective divide: walk the `s16.16` `S`/`T`/`W` by `de` down the
301/// scanline and `dx` across (`interpolation.h`).
302///
303/// Kept separate from [`divide_stw`] so the LOD (R-13) can tap this same
304/// accumulator and step it by `dx`/`dy` without re-deriving the scanline walk;
305/// `divide_stw(interpolate_stw_raw(..), persp)` is the plain per-pixel coordinate.
306#[allow(
307 clippy::cast_possible_truncation,
308 reason = "base_x is in range for the cast"
309)]
310fn interpolate_stw_raw(tex: &TexSetup, major_x: i64, y: i32, y_base: i32, x: i32) -> [i32; 3] {
311 let base_x = (major_x >> 16) as i32;
312 let mut stw = [0i32; 3];
313 for (c, o) in stw.iter_mut().enumerate() {
314 let scan = tex.base[c].wrapping_add(tex.de[c].wrapping_mul(y.wrapping_sub(y_base)));
315 *o = scan.wrapping_add((tex.dx[c] & !0x1f).wrapping_mul(x.wrapping_sub(base_x)));
316 }
317 stw
318}
319
320/// Take the integer part of the raw accumulators and run the (non-)perspective
321/// divide: `no_perspective_divide` is just `(s, t)`; the perspective path divides
322/// by `W`.
323///
324/// Returns the raw **`s10.5`** coordinate (the 5 low bits are the sub-texel
325/// fraction), *before* the tile transform: the caller runs [`sample_coord`]
326/// (shift / tile-origin subtraction / clamp / mask) to turn it into the
327/// tile-relative integer texel. Keeping the fraction here is what lets the shift
328/// and the tile-size clamp operate on the true coordinate (ledger R-13).
329#[allow(
330 clippy::tuple_array_conversions,
331 reason = "the perspective divide's (s, t) is packed into the coord array"
332)]
333fn divide_stw(stw: [i32; 3], persp: bool) -> [i32; 2] {
334 let (s, t, w) = (stw[0] >> 16, stw[1] >> 16, stw[2] >> 16);
335 if persp {
336 let (s, t) = perspective_divide(s, t, w);
337 [s, t]
338 } else {
339 [s, t]
340 }
341}
342
343/// Map a raw `s10.5` texture coordinate to a tile-relative **integer texel** for
344/// the point-sampled 1-cycle path: shift, tile-origin subtraction, **clamp**, then
345/// mask/mirror — the sampler order (ParaLLEl-RDP `tcshift_cycle` → `TRELATIVE` →
346/// `tcclamp_cycle_light` → `tcmask`; ledger R-13). Differs from the COPY-mode
347/// [`wrap_coord`] only by the clamp, which sits *between* the subtraction and the
348/// mask — masking first would corrupt the clamp's over-max / negative detection.
349///
350/// `lo`/`hi` are the tile's `SL`/`SH` (or `TL`/`TH`) `Set Tile Size` fields
351/// (`u10.2`). The over-max test compares against the **raw absolute** `hi`; the
352/// substituted clamp value is the **relative** tile width `(hi>>2) − (lo>>2)`.
353#[allow(
354 clippy::cast_sign_loss,
355 reason = "the texel index is non-negative after the clamp-to-0 and the mask"
356)]
357fn sample_coord(
358 coord: i32,
359 shift: u8,
360 mask: u8,
361 mirror: bool,
362 clamp_en: bool,
363 lo: u16,
364 hi: u16,
365) -> u32 {
366 // Point sampling discards the sub-texel fraction and the neighbor diff, so the
367 // `is_t` argument (which only affects the T-axis neighbor diff) is irrelevant
368 // here — `false` is safe for both axes. The base texel is exactly the bilinear
369 // base (`sample_axis`), so the point and bilinear paths never disagree.
370 sample_axis(coord, shift, mask, mirror, clamp_en, lo, hi, false).0 as u32
371}
372
373/// Shift step of the tile transform (`tcshift_cycle`): codes 0–10 shift right,
374/// 11–15 shift left by `16 − code`, both on the sign-extended `i16`.
375///
376/// The left shift wraps in 16-bit space — a result that overflows `i16` stays
377/// truncated + sign-extended (`SIGN16`), matching `coord <<= (16-shifter);
378/// coord = SIGN16(coord)` (equivalent because the low 16 bits of `coord << n`
379/// depend only on `coord`'s low 16 bits). This is the hardware behavior, NOT a
380/// bug: widening to `i32` before the shift would give a different result.
381fn tile_shift(coord: i32, shift: u8) -> i32 {
382 let shift = shift.min(15); // the hardware shift field is 4 bits
383 if shift < 11 {
384 i32::from(coord as i16) >> shift
385 } else {
386 i32::from((coord as i16).wrapping_shl(u32::from(16 - shift)))
387 }
388}
389
390/// Mask/mirror + neighbor-diff step (`tcmask_coupled`): returns the masked base
391/// texel and the increment (`sdiff`/`tdiff`) to reach the bilinear neighbor. The
392/// neighbor is `base + diff` and is **NOT** re-masked — `diff` is chosen so a plain
393/// add lands on the correct wrapped/mirrored texel: `+1` normally, `0` at a wrap
394/// **seam** (the "duplicate the last texel" quirk), `-base` at a mirror-off period
395/// end (wrap to 0), `-1` on a mirrored half. `mask == 0` ⇒ identity base, `diff = 1`.
396/// `is_t` uses the T max-wrap `-(base & 0xff)` (the fetch masks the T base `& 0xff`).
397fn mask_coupled(mut s: i32, mask: u8, mirror: bool, is_t: bool) -> (i32, i32) {
398 let mask = mask.min(10); // hardware caps the mask width at 10
399 if mask == 0 {
400 return (s, 1);
401 }
402 let maskbits = (1i32 << mask) - 1;
403 if mirror {
404 let wrap = (s >> mask) & 1; // wrapthreshold = mask (mask <= 10)
405 s = (s ^ -wrap) & maskbits; // -wrap = all-ones in the mirrored half → invert
406 let diff = if (s - wrap) & maskbits == maskbits {
407 0 // seam: the neighbor duplicates the last texel
408 } else {
409 1 - (wrap << 1) // +1 in the forward half, -1 in the mirrored half
410 };
411 (s, diff)
412 } else {
413 s &= maskbits;
414 let diff = if s == maskbits {
415 if is_t { -(s & 0xFF) } else { -s } // period end: wrap the neighbor to 0
416 } else {
417 1
418 };
419 (s, diff)
420 }
421}
422
423/// The per-axis tile sampler: map a raw `s10.5` coordinate to `(base_texel, frac,
424/// diff)` via shift → tile-origin subtraction → **clamp** → mask/mirror (the
425/// ParaLLEl-RDP sampler order; ledger R-13). `frac` is the 5-bit sub-texel weight
426/// the bilinear filter uses; per `tcclamp_cycle` it is **zeroed when the coordinate
427/// clamps** (high, or negative), so the filter degenerates to the edge texel there.
428/// `diff` (from [`mask_coupled`]) is the increment to the bilinear neighbor texel.
429/// The point sampler ([`sample_coord`]) drops both `frac` and `diff`.
430///
431/// `lo`/`hi` are the tile's `SL`/`SH` (or `TL`/`TH`) `Set Tile Size` fields
432/// (`u10.2`). The over-max test compares against the **raw absolute** `hi`; the
433/// substituted clamp value is the **relative** tile width `(hi>>2) − (lo>>2)`.
434#[allow(
435 clippy::cast_sign_loss,
436 reason = "the fraction is masked to 5 bits (0..=0x1f), always non-negative"
437)]
438#[allow(
439 clippy::too_many_arguments,
440 reason = "the per-axis tile fields (shift/mask/mirror/clamp/lo/hi) plus coord and is_t"
441)]
442fn sample_axis(
443 coord: i32,
444 shift: u8,
445 mask: u8,
446 mirror: bool,
447 clamp_en: bool,
448 lo: u16,
449 hi: u16,
450 is_t: bool,
451) -> (i32, u32, i32) {
452 let shifted = tile_shift(coord, shift);
453 // Over-max flag against the RAW absolute `hi`, BEFORE the tile-origin subtraction
454 // (`tcshift_cycle` computes it here) — also removes every large positive, so the
455 // `0x10000` sign test below only ever sees the reachable small range.
456 let over_max = (shifted >> 3) >= i32::from(hi);
457 // Tile-origin subtraction (`TRELATIVE`): `SL` (`u10.2`) `<< 3` = the `s.5` scale.
458 let rel = shifted - (i32::from(lo) << 3);
459 let mut frac = (rel & 0x1F) as u32; // 5-bit sub-texel fraction, captured pre-clamp
460 // Clamp — active when the tile clamp bit is set OR `mask == 0`; zeroes `frac`.
461 let base = if clamp_en || mask == 0 {
462 if over_max {
463 frac = 0;
464 // `clampdiffs`: the tile WIDTH in texels, `(SH>>2) − (SL>>2)`.
465 ((i32::from(hi) >> 2) - (i32::from(lo) >> 2)) & 0x3FF
466 } else if rel & 0x1_0000 == 0 {
467 // Non-negative (bit 16 = the RDP's sign marker, `!(locs & 0x10000)`).
468 rel >> 5
469 } else {
470 frac = 0;
471 0 // went below the tile origin: clamp low
472 }
473 } else {
474 rel >> 5
475 };
476 let (masked, diff) = mask_coupled(base, mask, mirror, is_t);
477 (masked, frac, diff)
478}
479
480/// The N64's **3-point (triangular) bilinear** filter: blend the four texels
481/// `t0=(s,t)`, `t1=(s+1,t)`, `t2=(s,t+1)`, `t3=(s+1,t+1)` by the 5-bit `sfrac`,
482/// `tfrac` — a faithful port of ParaLLEl-RDP `texture_pipeline_cycle` (`tex.c`).
483/// `upper = (sfrac + tfrac) & 0x20` selects the triangle: the lower-left uses
484/// `t0,t1,t2`, the upper-right uses `t3,t2,t1` with inverted fractions. Each
485/// channel is a `+0x10 >> 5` round of a convex combination, so it stays in `0..=255`.
486///
487/// When `mid_texel` (Set Other Modes bit 44) is set **and** the sample lands
488/// exactly on the texel center (`sfrac == tfrac == 0x10`), the RDP replaces the
489/// triangle pick with a **four-texel average** (Angrylion `tex.c` `center` case:
490/// `t3 + ((((t1+t2)<<6) − (t3<<7) + ((!t3+t0)<<6) + 0xc0) >> 8)`).
491#[allow(
492 clippy::cast_possible_wrap,
493 clippy::cast_sign_loss,
494 reason = "sfrac/tfrac are 5-bit (0..=0x1f); the convex-combination output is 0..=255"
495)]
496fn bilinear_3point(
497 t0: [u8; 4],
498 t1: [u8; 4],
499 t2: [u8; 4],
500 t3: [u8; 4],
501 sfrac: u32,
502 tfrac: u32,
503 mid_texel: bool,
504) -> [u8; 4] {
505 let (sf, tf) = (sfrac as i32, tfrac as i32);
506 let upper = (sfrac + tfrac) & 0x20 != 0;
507 // The exact-center four-texel average, active only under `mid_texel`.
508 let center = mid_texel && sfrac == 0x10 && tfrac == 0x10;
509 let mut out = [0u8; 4];
510 for x in 0..4 {
511 let (c0, c1, c2, c3) = (
512 i32::from(t0[x]),
513 i32::from(t1[x]),
514 i32::from(t2[x]),
515 i32::from(t3[x]),
516 );
517 let v = if center {
518 // Four-neighbor average (`!c3` = C `~t3`, i.e. `-c3 - 1`).
519 c3 + ((((c1 + c2) << 6) - (c3 << 7) + ((!c3 + c0) << 6) + 0xc0) >> 8)
520 } else if upper {
521 c3 + (((0x20 - sf) * (c2 - c3) + (0x20 - tf) * (c1 - c3) + 0x10) >> 5)
522 } else {
523 c0 + ((sf * (c1 - c0) + tf * (c2 - c0) + 0x10) >> 5)
524 };
525 out[x] = v as u8; // convex combination + round: always 0..=255
526 }
527 out
528}
529
530/// Accumulate one LOD delta pair — a port of Angrylion `tclod_4x17_to_15`
531/// (`tcoord.c`). Each axis' delta is a 17-bit signed difference folded to its
532/// magnitude (`~d & 0x1ffff` when negative); the LOD is the running maximum of
533/// the S delta, the T delta, and `previous`. Bit 14 is the "too large" marker
534/// set when any of bits 16:14 survive.
535fn lod_delta(scurr: i32, snext: i32, tcurr: i32, tnext: i32, previous: i32) -> i32 {
536 let fold = |next: i32, curr: i32| {
537 // Mask to 17 bits first: the masked value is provably non-negative, so
538 // widening it to `sext`'s `u32` cannot lose a sign.
539 let d = sext((next & 0x1_FFFF).cast_unsigned(), 17)
540 - sext((curr & 0x1_FFFF).cast_unsigned(), 17);
541 if d & 0x2_0000 != 0 { !d & 0x1_FFFF } else { d }
542 };
543 let d = fold(snext, scurr).max(fold(tnext, tcurr)).max(previous);
544 let mut lod = d & 0x7FFF;
545 if d & 0x1_C000 != 0 {
546 lod |= 0x4000;
547 }
548 lod
549}
550
551/// `log2table` (Angrylion `tcoord.c`): the index of the highest set bit, with
552/// `0` and `1` both mapping to `0` — i.e. `i.ilog2()` for `i >= 1`.
553const fn lod_log2(i: u32) -> u32 {
554 if i < 2 { 0 } else { i.ilog2() }
555}
556
557/// The four outputs of Angrylion `lodfrac_lodtile_signals` (`tcoord.c`).
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559struct LodSignals {
560 /// The LOD fraction, **raw** (`0..=0x1ff`, the `0x100` bit set by
561 /// `sharpen_tex_en`) exactly as the hardware holds it; the combiner
562 /// sign-extends it downstream via `sext9`, matching how `K4`/`K5` are stored
563 /// (ledger R-10).
564 frac: i16,
565 /// The mip level the LOD lands on (`log2` of the LOD's high bits).
566 l_tile: u32,
567 /// The LOD is below the finest level — magnifying rather than minifying.
568 magnify: bool,
569 /// The LOD is past the coarsest available level (or there is no mip chain).
570 distant: bool,
571}
572
573/// Decode an LOD magnitude into its fraction and mip signals — a port of
574/// Angrylion `lodfrac_lodtile_signals` (`tcoord.c`).
575fn lod_signals(
576 lodclamp: bool,
577 lod: i32,
578 min_level: u8,
579 max_level: u8,
580 sharpen: bool,
581 detail: bool,
582) -> LodSignals {
583 // `lod` is a 15-bit magnitude, never negative: its only producer, `lod_delta`,
584 // returns `d & 0x7fff` (optionally `| 0x4000`). That is what makes the bit-14
585 // test below safe on a signed type — the same `int32_t` the oracle uses.
586 debug_assert!((0..=0x7FFF).contains(&lod), "lod is a 15-bit magnitude");
587 let min_level = i32::from(min_level);
588 let plain = !sharpen && !detail; // neither sharpen nor detail texturing
589 if lod & 0x4000 != 0 || lodclamp {
590 // Clamped / too far: pinned to the coarsest level, fully on it.
591 return LodSignals {
592 frac: 0xFF,
593 l_tile: 0,
594 magnify: false,
595 distant: true,
596 };
597 }
598 if lod < min_level || lod < 32 {
599 // Magnifying: `lod` is below the finest level, so there is nothing to
600 // interpolate toward unless sharpen/detail texturing asks for it.
601 let distant = max_level == 0;
602 let frac = if plain {
603 if distant { 0xFF } else { 0 }
604 } else {
605 let base = if lod < min_level { min_level } else { lod };
606 let lf = base << 3;
607 (if sharpen { lf | 0x100 } else { lf }) as i16
608 };
609 return LodSignals {
610 frac,
611 l_tile: 0,
612 magnify: true,
613 distant,
614 };
615 }
616 let l_tile = lod_log2((lod >> 5).cast_unsigned() & 0xFF);
617 let distant = if max_level == 0 {
618 true
619 } else {
620 lod & 0x6000 != 0 || l_tile >= u32::from(max_level)
621 };
622 let frac = if plain && distant {
623 0xFF
624 } else {
625 (((lod << 3) >> l_tile) & 0xFF) as i16
626 };
627 LodSignals {
628 frac,
629 l_tile,
630 magnify: false,
631 distant,
632 }
633}
634
635/// The mip tile pair the LOD selects, when `tex_lod_en` is set — a port of the
636/// tile-selection tail of Angrylion `tclod_2cycle` (`tcoord.c`).
637///
638/// A "distant" LOD pins the level to `max_level`; otherwise it is `l_tile`. The
639/// pair is `(base + level, base + level + 1)` so the two cycles straddle the mip
640/// boundary, collapsing to the same tile where there is nothing to blend toward
641/// (distant, or magnifying without sharpen). Detail texturing shifts both by one.
642/// Every index wraps into the 8 tile descriptors.
643fn lod_mip_tiles(
644 base_tile: usize,
645 sig: LodSignals,
646 max_level: u8,
647 sharpen: bool,
648 detail: bool,
649) -> (usize, usize) {
650 let level = if sig.distant {
651 usize::from(max_level)
652 } else {
653 sig.l_tile as usize
654 };
655 if detail {
656 // Detail texturing samples one level finer than the plain path.
657 // One level finer unless magnifying (`usize::from(bool)` is 1 for true, 0
658 // for false; clippy's `bool_to_int_with_if` requires this form over an `if`).
659 let finer = usize::from(!sig.magnify);
660 let t1 = (base_tile + level + finer) & 7;
661 let t2 = if !sig.distant && !sig.magnify {
662 (base_tile + level + 2) & 7
663 } else {
664 (base_tile + level + 1) & 7
665 };
666 return (t1, t2);
667 }
668 let t1 = (base_tile + level) & 7;
669 // The second tile advances only when there is a coarser level to blend
670 // toward: not when distant, and not when plainly magnifying.
671 let t2 = if sig.distant || (!sharpen && sig.magnify) {
672 t1
673 } else {
674 (t1 + 1) & 7
675 };
676 (t1, t2)
677}
678
679/// The RDP's perspective-divide reciprocal LUT (ParaLLEl-RDP `perspective.h`,
680/// transcribed in its `(base, slope · 4)` source form). Indexed by the top 6 bits
681/// of the normalized `W`; `(base, slope)` give `rcp = ((slope · wnorm) >> 10) + base`.
682#[rustfmt::skip]
683const PERSPECTIVE_TABLE: [(i16, i16); 64] = [
684 (0x4000, -252 * 4), (0x3f04, -244 * 4), (0x3e10, -238 * 4), (0x3d22, -230 * 4),
685 (0x3c3c, -223 * 4), (0x3b5d, -218 * 4), (0x3a83, -210 * 4), (0x39b1, -205 * 4),
686 (0x38e4, -200 * 4), (0x381c, -194 * 4), (0x375a, -189 * 4), (0x369d, -184 * 4),
687 (0x35e5, -179 * 4), (0x3532, -175 * 4), (0x3483, -170 * 4), (0x33d9, -166 * 4),
688 (0x3333, -162 * 4), (0x3291, -157 * 4), (0x31f4, -155 * 4), (0x3159, -150 * 4),
689 (0x30c3, -147 * 4), (0x3030, -143 * 4), (0x2fa1, -140 * 4), (0x2f15, -137 * 4),
690 (0x2e8c, -134 * 4), (0x2e06, -131 * 4), (0x2d83, -128 * 4), (0x2d03, -125 * 4),
691 (0x2c86, -123 * 4), (0x2c0b, -120 * 4), (0x2b93, -117 * 4), (0x2b1e, -115 * 4),
692 (0x2aab, -113 * 4), (0x2a3a, -110 * 4), (0x29cc, -108 * 4), (0x2960, -106 * 4),
693 (0x28f6, -104 * 4), (0x288e, -102 * 4), (0x2828, -100 * 4), (0x27c4, -98 * 4),
694 (0x2762, -96 * 4), (0x2702, -94 * 4), (0x26a4, -92 * 4), (0x2648, -91 * 4),
695 (0x25ed, -89 * 4), (0x2594, -87 * 4), (0x253d, -86 * 4), (0x24e7, -85 * 4),
696 (0x2492, -83 * 4), (0x243f, -81 * 4), (0x23ee, -80 * 4), (0x239e, -79 * 4),
697 (0x234f, -77 * 4), (0x2302, -76 * 4), (0x22b6, -74 * 4), (0x226c, -74 * 4),
698 (0x2222, -72 * 4), (0x21da, -71 * 4), (0x2193, -70 * 4), (0x214d, -69 * 4),
699 (0x2108, -67 * 4), (0x20c5, -67 * 4), (0x2082, -65 * 4), (0x2041, -65 * 4),
700];
701
702/// The reciprocal and shift for a normalized `W` (ParaLLEl-RDP `perspective_get_lut`).
703#[allow(
704 clippy::cast_sign_loss,
705 reason = "normout & 0x3fff >> 8 is 0..=63, a valid table index"
706)]
707fn perspective_get_lut(w: i32) -> (i32, i32) {
708 let shift = (14 - find_msb(w)).min(14);
709 let normout = (w << shift) & 0x3fff;
710 let wnorm = normout & 0xff;
711 let (base, slope) = PERSPECTIVE_TABLE[(normout >> 8) as usize];
712 let rcp = ((i32::from(slope) * wnorm) >> 10) + i32::from(base);
713 (rcp, shift)
714}
715
716/// Perspective-divide a texture coordinate `(s, t)` by `w` — a faithful port of
717/// ParaLLEl-RDP's `perspective_divide` (`perspective.h`): the LUT reciprocal, the
718/// shift, the `temp_mask` out-of-bounds saturation, the `w <= 0` carry, and the
719/// final 17-bit clamp. Returns the divided `(s, t)`.
720#[allow(
721 clippy::similar_names,
722 reason = "s / t / w are the RDP's own texture-coordinate names"
723)]
724fn perspective_divide(s: i32, t: i32, w: i32) -> (i32, i32) {
725 let w_carry = w <= 0;
726 let w = w & 0x7fff;
727 let (rcp, shift) = perspective_get_lut(w);
728 let prod0 = [s.wrapping_mul(rcp), t.wrapping_mul(rcp)];
729 let temp_mask = ((1 << 30) - 1) & -((1 << 29) >> shift);
730 let out_of_bounds = [prod0[0] & temp_mask, prod0[1] & temp_mask];
731 let (mut temp, prod) = if shift == 14 {
732 ([prod0[0] << 1, prod0[1] << 1], prod0)
733 } else {
734 let p = [prod0[0] >> (13 - shift), prod0[1] >> (13 - shift)];
735 (p, p)
736 };
737 if out_of_bounds != [0, 0] {
738 for c in 0..2 {
739 if out_of_bounds[c] != temp_mask && out_of_bounds[c] != 0 {
740 temp[c] = if prod[c] & (1 << 29) == 0 {
741 0x7fff
742 } else {
743 -0x8000
744 };
745 }
746 }
747 }
748 if w_carry {
749 temp = [0x7fff, 0x7fff];
750 }
751 (
752 temp[0].clamp(-0x10000, 0xffff),
753 temp[1].clamp(-0x10000, 0xffff),
754 )
755}
756
757/// The RDP's asymmetric 9-bit expand for the combiner's A/B/D inputs: subtract
758/// the 0x80 bias, sign-extend to 9 bits, add the bias back (ParaLLEl-RDP
759/// `special_expand`, `combiner.h`). The multiplier C uses a plain [`sext9`].
760const fn special_expand(v: i32) -> i32 {
761 sext9(v - 0x80) + 0x80
762}
763
764/// Evaluate one combiner channel `(A − B) * C + D` with the RDP's fixed-point
765/// rules: A/B/D through [`special_expand`], C a plain 9-bit value, a `+0x80`
766/// rounding bias applied before the `>> 8`, and D added afterwards, unscaled
767/// (ParaLLEl-RDP `combiner_equation`). No clamp here — that is per cycle.
768const fn combine_channel(a: i32, b: i32, c: i32, d: i32) -> i32 {
769 let color = (special_expand(a) - special_expand(b)) * sext9(c) + 0x80;
770 (color >> 8) + special_expand(d)
771}
772
773/// Clamp a combiner result to `[0, 255]` with the RDP's 9-bit fold — the
774/// `-0x80 / sext9 / +0x80` before the clamp is what makes 256–383 saturate and
775/// 384–511 wrap toward 0 (ParaLLEl-RDP `clamp_9bit_notrunc`, `clamping.h`).
776#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
777fn clamp_9bit(color: i32) -> u8 {
778 special_expand(color).clamp(0, 0xFF) as u8
779}
780
781/// The combiner equation's **pre-`>>8` 17-bit** result — what the chroma-key alpha
782/// compare consumes (Angrylion `color_combiner_equation`). Same terms as
783/// [`combine_channel`] but returning `((A − B) * C + (D << 8) + 0x80) & 0x1ffff`
784/// rather than the `>> 8`'d color.
785const fn combine_channel_17bit(a: i32, b: i32, c: i32, d: i32) -> i32 {
786 (((special_expand(a) - special_expand(b)) * sext9(c)) + (special_expand(d) << 8) + 0x80)
787 & 0x1_FFFF
788}
789
790/// The chroma-key alpha (Angrylion `chroma_key_min`): per channel, fold the sign of
791/// the 17-bit combined value into a distance, offset by the programmed half-width,
792/// take the minimum across R/G/B, and clamp to `[0, 0xff]`. `col17` is the pre-`>>8`
793/// combined color ([`combine_channel_17bit`]); `width` is the 12-bit `Set Key` width
794/// per channel.
795#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
796fn chroma_key_min(col17: [i32; 3], width: [u16; 3]) -> u8 {
797 let mut keyalpha = i32::MAX;
798 for ch in 0..3 {
799 // Sign-extend the 17-bit value (mask first so an unmasked caller is safe).
800 let mut k = ((col17[ch] & 0x1_FFFF) << 15) >> 15; // SIGN(col, 17)
801 if k > 0 {
802 k = if (k & 0xf) == 8 { -k + 0x10 } else { -k };
803 }
804 k += i32::from(width[ch]) << 4;
805 keyalpha = keyalpha.min(k);
806 }
807 keyalpha.clamp(0, 0xff) as u8
808}
809
810/// The combiner's "1" input — `0x100` in the internal `.8` representation, not
811/// `0xFF` (N64brew *…/Commands* §0x3C).
812const COMBINER_ONE: i16 = 0x100;
813
814/// The RGB `A` (muladd) input for channel `ch`. Table: 0 Combined, 1 Texel0,
815/// 2 Texel1, 3 Prim, 4 Shade, 5 Env, 6 One, 7 Noise (R-10 → 0), 8+ Zero.
816fn rgb_input_a(sel: u8, inp: &CombinerInputs, ch: usize) -> i16 {
817 match sel {
818 0 => i16::from(inp.combined[ch]),
819 1 => i16::from(inp.texel0[ch]),
820 2 => i16::from(inp.texel1[ch]),
821 3 => i16::from(inp.prim[ch]),
822 4 => i16::from(inp.shade[ch]),
823 5 => i16::from(inp.env[ch]),
824 6 => COMBINER_ONE,
825 _ => 0, // 7 Noise + 8+ Zero — R-10
826 }
827}
828
829/// The RGB `B` (mulsub) input. Same as `A` except 6/7 are `KeyCenter` (`Set Key`)
830/// and `ConvertK4` (`Set Convert`); 8+ read as zero.
831fn rgb_input_b(sel: u8, inp: &CombinerInputs, ch: usize) -> i16 {
832 match sel {
833 0 => i16::from(inp.combined[ch]),
834 1 => i16::from(inp.texel0[ch]),
835 2 => i16::from(inp.texel1[ch]),
836 3 => i16::from(inp.prim[ch]),
837 4 => i16::from(inp.shade[ch]),
838 5 => i16::from(inp.env[ch]),
839 6 => i16::from(inp.key_center[ch]), // Chroma-key center (Set Key R/GB)
840 7 => inp.k4, // Convert K4 (raw 0..511; combine_channel's special_expand sign-extends)
841 _ => 0, // 8+ Zero (no R-10-deferred select remains for B; both 6 and 7 wired)
842 }
843}
844
845/// The RGB `C` (mul) input (5-bit table). Alpha-channel inputs (7–12) tap the
846/// alpha of the corresponding signal; `KeyScale` (6, `Set Key`) and Convert `K5` (15)
847/// are wired, `LODFrac` (13) is R-10 → 0.
848fn rgb_input_c(sel: u8, inp: &CombinerInputs, ch: usize) -> i16 {
849 match sel {
850 0 => i16::from(inp.combined[ch]),
851 1 => i16::from(inp.texel0[ch]),
852 2 => i16::from(inp.texel1[ch]),
853 3 => i16::from(inp.prim[ch]),
854 4 => i16::from(inp.shade[ch]),
855 5 => i16::from(inp.env[ch]),
856 7 => i16::from(inp.combined[3]),
857 8 => i16::from(inp.texel0[3]),
858 9 => i16::from(inp.texel1[3]),
859 10 => i16::from(inp.prim[3]),
860 11 => i16::from(inp.shade[3]),
861 12 => i16::from(inp.env[3]),
862 6 => i16::from(inp.key_scale[ch]), // Chroma-key scale (Set Key R/GB)
863 13 => inp.lod_frac, // LOD fraction (raw 0..511; sext9 sign-extends)
864 14 => inp.prim_lod_frac, // Prim LOD fraction
865 15 => inp.k5, // Convert K5 (raw 0..511; combine_channel's sext9 sign-extends)
866 _ => 0, // 16+ Zero
867 }
868}
869
870/// The RGB `D` (add) input (3-bit table): 0 Combined … 5 Env, 6 One, 7 Zero.
871fn rgb_input_d(sel: u8, inp: &CombinerInputs, ch: usize) -> i16 {
872 match sel {
873 0 => i16::from(inp.combined[ch]),
874 1 => i16::from(inp.texel0[ch]),
875 2 => i16::from(inp.texel1[ch]),
876 3 => i16::from(inp.prim[ch]),
877 4 => i16::from(inp.shade[ch]),
878 5 => i16::from(inp.env[ch]),
879 6 => COMBINER_ONE,
880 _ => 0, // 7 Zero
881 }
882}
883
884/// The alpha `A`/`B`/`D` input (3-bit table): 0 combined-alpha … 5 env-alpha,
885/// 6 one, 7 zero.
886fn alpha_input_abd(sel: u8, inp: &CombinerInputs) -> i16 {
887 match sel {
888 0 => i16::from(inp.combined[3]),
889 1 => i16::from(inp.texel0[3]),
890 2 => i16::from(inp.texel1[3]),
891 3 => i16::from(inp.prim[3]),
892 4 => i16::from(inp.shade[3]),
893 5 => i16::from(inp.env[3]),
894 6 => COMBINER_ONE,
895 _ => 0, // 7 Zero
896 }
897}
898
899/// The alpha `C` (mul) input (3-bit table): 0 lod-frac (R-10 → 0), 1 texel0-alpha,
900/// … 5 env-alpha, 6 prim-lod-frac (R-10 → 0), 7 zero.
901fn alpha_input_c(sel: u8, inp: &CombinerInputs) -> i16 {
902 match sel {
903 1 => i16::from(inp.texel0[3]),
904 2 => i16::from(inp.texel1[3]),
905 3 => i16::from(inp.prim[3]),
906 4 => i16::from(inp.shade[3]),
907 5 => i16::from(inp.env[3]),
908 0 => inp.lod_frac, // LOD fraction (raw 0..511; sext9 sign-extends)
909 6 => inp.prim_lod_frac, // Prim LOD fraction
910 _ => 0, // 7 Zero
911 }
912}
913
914/// The blender's `P`/`M` color select: which RGB triple feeds one blend term
915/// (N64brew *…/Blender*). 0 = pixel (combiner output), 1 = memory (framebuffer),
916/// 2 = blend-color register, 3 = fog-color register.
917fn blend_rgb_input(sel: u8, inp: &BlendInputs) -> [u8; 3] {
918 let [r, g, b, _] = match sel & 0x3 {
919 1 => inp.memory,
920 2 => inp.blend_color,
921 3 => inp.fog,
922 _ => inp.pixel,
923 };
924 [r, g, b]
925}
926
927/// The blender's `A` (1b) alpha weight: 0 = pixel alpha, 1 = fog alpha,
928/// 2 = shade alpha, 3 = zero (N64brew *…/Blender*).
929fn blend_a_input(sel: u8, inp: &BlendInputs) -> u8 {
930 match sel & 0x3 {
931 1 => inp.fog[3],
932 2 => inp.shade_alpha,
933 3 => 0,
934 _ => inp.pixel[3],
935 }
936}
937
938/// The blender's `B` (2b) alpha weight: 0 = `1 − A`, 1 = memory alpha (framebuffer
939/// coverage), 2 = one (0xFF), 3 = zero (N64brew *…/Blender*).
940///
941/// The `1 − A` case is the one's complement of the **already-selected `A` weight**,
942/// not of pixel alpha specifically — the ParaLLEl-RDP constant is named
943/// `INV_PIXEL_ALPHA` but computes `~a0` (`blender.h:106`), so `A` selecting fog or
944/// shade alpha makes `B` their complement too. `a0_full` is that resolved `A` weight
945/// **before** the `>> 3` (the complement is taken on the full 8-bit value, then both
946/// weights are shifted — `blender.h:106` vs `:112`).
947fn blend_b_input(sel: u8, inp: &BlendInputs, a0_full: u8) -> u8 {
948 match sel & 0x3 {
949 1 => inp.memory[3],
950 2 => 0xFF,
951 3 => 0,
952 _ => !a0_full,
953 }
954}
955
956/// Wrap one raw texture coordinate (`s10.5` fixed point) into a tile-relative
957/// integer texel, applying the tile's shift, tile-origin subtraction, mirror, and
958/// mask — the COPY-mode order (no clamp). Matches the ParaLLEl-RDP reference
959/// (`texture.h`): clamp to `i16`, then shift (codes 1–10 shift right, 11–15 shift
960/// left by `16−code`), subtract `SL<<3`, take the integer part (`>>5`), then
961/// mirror-on-alternate-spans and mask to `mask` bits (`mask == 0` = no wrap).
962fn wrap_coord(coord: i32, shift: u8, mask: u8, mirror: bool, lo: u16) -> i32 {
963 let shift = shift.min(15); // the hardware shift field is 4 bits (0..15)
964 let c = coord.clamp(-0x8000, 0x7FFF);
965 let shifted = if shift <= 10 {
966 c >> shift
967 } else {
968 // Left shift by (16 − shift), truncated to 16 bits (sign-preserving).
969 i32::from((c as i16).wrapping_shl(u32::from(16 - shift)))
970 };
971 let mut s = (shifted - (i32::from(lo) << 3)) >> 5;
972 let mask = mask.min(10); // hardware caps the mask width at 10
973 if mask != 0 {
974 let m = 1i32 << mask;
975 if mirror && s & m != 0 {
976 s ^= m - 1; // reflect on odd mask-sized spans
977 }
978 s &= m - 1;
979 }
980 s
981}
982
983/// Widen a 5-bit channel to 8 bits by bit-replication (`v<<3 | v>>2`).
984const fn widen5(v: u32) -> u8 {
985 ((v << 3) | (v >> 2)) as u8
986}
987
988/// Widen a 4-bit channel to 8 bits by bit-replication (`v<<4 | v`).
989const fn widen4(v: u32) -> u8 {
990 ((v << 4) | v) as u8
991}
992
993/// Widen a 3-bit channel to 8 bits by bit-replication.
994const fn widen3(v: u32) -> u8 {
995 ((v << 5) | (v << 2) | (v >> 1)) as u8
996}
997
998/// Decode a 16-bit RGBA5551 word to `[R, G, B, A]` (5→8 replication; 1-bit alpha).
999const fn decode_rgba16(w: u32) -> [u8; 4] {
1000 [
1001 widen5((w >> 11) & 0x1F),
1002 widen5((w >> 6) & 0x1F),
1003 widen5((w >> 1) & 0x1F),
1004 if w & 1 != 0 { 0xFF } else { 0 },
1005 ]
1006}
1007
1008/// Maximum texels a single `Load Block` may transfer (N64brew *…/Commands*
1009/// §Load Block); a load over this writes nothing into TMEM.
1010pub const LOAD_BLOCK_MAX_TEXELS: u32 = 2048;
1011
1012/// Bytes per texel for a texel-size code, or `None` for 4-bit (`size` 0), which
1013/// is sub-byte and needs nibble addressing: 8bpp=1, 16bpp=2, 32bpp=4.
1014const fn bytes_per_texel(size: u8) -> Option<u32> {
1015 match size {
1016 1 => Some(1),
1017 2 => Some(2),
1018 3 => Some(4),
1019 _ => None,
1020 }
1021}
1022const OP_SET_PRIM_DEPTH: u8 = 0x2E;
1023const OP_SET_OTHER_MODES: u8 = 0x2F;
1024const OP_FILL_RECTANGLE: u8 = 0x36;
1025const OP_SET_FILL_COLOR: u8 = 0x37;
1026const OP_SET_FOG_COLOR: u8 = 0x38;
1027const OP_SET_BLEND_COLOR: u8 = 0x39;
1028const OP_SET_CONVERT: u8 = 0x2C;
1029const OP_SET_KEY_GB: u8 = 0x2A;
1030const OP_SET_KEY_R: u8 = 0x2B;
1031const OP_SET_PRIM_COLOR: u8 = 0x3A;
1032const OP_SET_ENV_COLOR: u8 = 0x3B;
1033const OP_SET_COMBINE_MODE: u8 = 0x3C;
1034
1035/// The alpha mul-select that reads the LOD fraction. It is **zero**, which is also
1036/// the reset/default select — so a `Set Combine` that leaves the alpha mul field
1037/// clear really does ask for `LODFrac`, exactly as on hardware (R-13).
1038const LOD_FRAC_ALPHA_MUL_SELECT: u8 = 0;
1039const OP_SET_TEXTURE_IMAGE: u8 = 0x3D;
1040const OP_SET_DEPTH_IMAGE: u8 = 0x3E;
1041const OP_SET_COLOR_IMAGE: u8 = 0x3F;
1042
1043/// One cycle of the blender: the `P`/`M` color selects and the `A`/`B` alpha
1044/// selects for `P * A + M * (B + 1)` (`Set Other Modes`, 0x2F). Each is 2-bit.
1045#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1046pub struct BlendCycle {
1047 /// `P` (1a) color select: 0 pixel, 1 memory, 2 blend, 3 fog.
1048 pub p: u8,
1049 /// `A` (1b) alpha select: 0 pixel-alpha, 1 fog-alpha, 2 shade-alpha, 3 zero.
1050 pub a: u8,
1051 /// `M` (2a) color select (same table as `P`).
1052 pub m: u8,
1053 /// `B` (2b) alpha select: 0 `1−A`, 1 memory-alpha, 2 one, 3 zero.
1054 pub b: u8,
1055}
1056
1057/// The `Set Other Modes` (0x2F) render-mode state the blender and cycle control
1058/// need.
1059///
1060/// Coverage, RGB dither, and alpha-compare are decoded here and now applied
1061/// (dither on both pixel paths; alpha-compare gates the write on both). The
1062/// remaining decoded-but-unused fields — the AA-edge divider LUT, the
1063/// interpenetrating-Z blend-shift, `color_on_cvg`, and coverage write-back — are
1064/// the **open residual R-11**.
1065#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1066#[allow(
1067 clippy::struct_excessive_bools,
1068 reason = "these are independent hardware mode bits from Set Other Modes, not a state machine"
1069)]
1070// This render state grows a field nearly every sprint (mid_texel, key_en, … most
1071// recently); `#[non_exhaustive]` keeps that additive for external consumers, who
1072// build it from `Default` rather than an exhaustive literal (the in-crate decoder
1073// still uses a full literal, which same-crate construction permits).
1074#[non_exhaustive]
1075pub struct OtherModes {
1076 /// Cycle type: 0 = 1-cycle, 1 = 2-cycle, 2 = copy, 3 = fill.
1077 pub cycle_type: u8,
1078 /// The two blender cycles (cycle 0 chains into cycle 1 in 2-cycle mode).
1079 pub blend: [BlendCycle; 2],
1080 /// Force the no-divide blend form even on the final cycle.
1081 pub force_blend: bool,
1082 /// Read the framebuffer (memory) color into the blend.
1083 pub image_read_en: bool,
1084 /// Coverage write-back mode: 0 clamp, 1 wrap, 2 full, 3 save.
1085 pub cvg_dest: u8,
1086 /// Z test / update enables and the Z mode (T-33-004 consumes these).
1087 pub z_compare_en: bool,
1088 /// Z-buffer update enable.
1089 pub z_update_en: bool,
1090 /// Z mode: 0 opaque, 1 interpenetrating, 2 transparent, 3 decal.
1091 pub z_mode: u8,
1092 /// Alpha-compare enable (gates the pixel write; R-11).
1093 pub alpha_compare_en: bool,
1094 /// Chroma-key enable (bit 40): the combiner outputs the sub-A color and derives
1095 /// the pixel alpha from the key window (`chroma_key_min`). R-10.
1096 pub key_en: bool,
1097 /// Perspective-correct texturing (bit 51): divide the interpolated `S`/`T` by `W`.
1098 pub persp_tex_en: bool,
1099 /// Anti-aliasing enable (bit 3): sub-pixel edge coverage governs which edge
1100 /// pixels draw and enables the edge blend (N64brew *…/Commands* §0x2F bit 3).
1101 pub aa_enable: bool,
1102 /// RGB dither mode (bits 39:38): 0 magic, 1 bayer, 2 noise, 3 off. Applied
1103 /// to the combined RGB per pixel before write-back (N64brew *…/Commands*
1104 /// §0x2F; parallel-rdp `dither.c`). Modes 0/1 dither; 2 (noise, **R-10**)
1105 /// currently reads the magic cell; 3 never rounds up (dither off).
1106 pub rgb_dither_mode: u8,
1107 /// Texture sample type (bit 45): `false` = point-sample one texel, `true` =
1108 /// the N64's 3-point bilinear filter (R-13). Applied in the triangle sampler.
1109 pub sample_type: bool,
1110 /// LOD enable (bit 48): the derivative-based mip level drives tile selection.
1111 /// Decoded and fed to the LOD fraction; LOD-driven **tile** selection is the
1112 /// remaining R-13 residual, so this does not yet re-tile.
1113 pub tex_lod_en: bool,
1114 /// Sharpen-texture enable (bit 49): keeps the LOD fraction live when
1115 /// magnifying and sets its `0x100` bit (R-13).
1116 pub sharpen_tex_en: bool,
1117 /// Detail-texture enable (bit 50): keeps the LOD fraction live when
1118 /// magnifying (R-13).
1119 pub detail_tex_en: bool,
1120 /// **TLUT enable** (bit 47). N64brew *…/Commands* §0x2F: *"`tlut_en`: Enables
1121 /// Texture Look-Up Table (TLUT) sampling. Texels are first fetched from low
1122 /// TMEM that are then used to index a palette in high TMEM to find the final
1123 /// color values."*
1124 ///
1125 /// This is the flag that decides whether a palette lookup happens — **not**
1126 /// the tile's format field. Keying off the format alone is wrong in two
1127 /// directions, and **only one of them is fixed**:
1128 ///
1129 /// - **Implemented:** a CI tile with `tlut_en` clear is no longer
1130 /// palette-mapped. Pinned by `ci4_tlut_disabled_16`, whose golden is all
1131 /// black where the `tlut_en`-set twin renders the full palette.
1132 /// - **Deferred:** a **non-CI** tile with `tlut_en` set is still not
1133 /// palette-mapped, though hardware would sample it through the TLUT. No
1134 /// vector covers that case, and the RGBA/IA/I formats index the palette
1135 /// differently enough that implementing it from the prose alone would be
1136 /// inventing behavior. It stays wrong-but-honest until a vector defines it,
1137 /// the same posture as `tlut_type`'s IA16 palettes below.
1138 pub tlut_en: bool,
1139 /// TLUT texel format (bit 46): `false` = RGBA16, `true` = IA16
1140 /// (N64brew *…/Commands* §0x2F). Decoded so it is available and so the flag
1141 /// is not silently ignored; **IA16 palettes are still deferred** — the lookup
1142 /// assumes RGBA16, and implementing IA16 without a vector would be inventing
1143 /// behavior rather than emulating it.
1144 pub tlut_type: bool,
1145 /// Mid-texel filter (bit 44, R-13): when set and the bilinear sample lands
1146 /// exactly on the texel center (`sfrac == tfrac == 0x10`), the four neighbors
1147 /// are averaged instead of the 3-point triangle pick (Angrylion `tex.c`, the
1148 /// `center`/`centerrg` case). Only meaningful with `sample_type`.
1149 pub mid_texel: bool,
1150}
1151
1152/// The resolved per-pixel blender input colors (each RGBA8888).
1153#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
1154pub struct BlendInputs {
1155 /// The combiner's output ("pixel color").
1156 pub pixel: [u8; 4],
1157 /// The framebuffer's current color (memory), with coverage in its alpha.
1158 pub memory: [u8; 4],
1159 /// The blend-color register (`Set Blend Color`, 0x39).
1160 pub blend_color: [u8; 4],
1161 /// The fog-color register (`Set Fog Color`, 0x38).
1162 pub fog: [u8; 4],
1163 /// The interpolated shade alpha.
1164 pub shade_alpha: u8,
1165}
1166
1167/// The Z-buffer read and render-mode flags [`Rdp::depth_test`] needs beyond the
1168/// incoming pixel's own `z`/`dz` (grouped so the signature stays legible).
1169#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
1170pub struct DepthInputs {
1171 /// The 14-bit compressed depth already stored for this pixel.
1172 pub current_depth: u16,
1173 /// The 4-bit `dz` already stored for this pixel.
1174 pub current_dz: u8,
1175 /// The coverage already accumulated in the framebuffer pixel.
1176 pub current_coverage: i32,
1177 /// `Set Other Modes` `z_compare_en`: run the depth comparison at all.
1178 pub z_compare: bool,
1179 /// `Set Other Modes` `z_mode`: 0 opaque, 1 interpenetrating, 2 transparent, 3 decal.
1180 pub z_mode: u8,
1181 /// `Set Other Modes` `force_blend`.
1182 pub force_blend: bool,
1183 /// Anti-aliasing enable (`Set Other Modes` `antialias_en`).
1184 pub aa_enable: bool,
1185}
1186
1187/// The outcome of a per-pixel depth test: whether the pixel is written, and the
1188/// blend/coverage state the blender consumes.
1189#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1190pub struct DepthResult {
1191 /// The pixel passes the depth test and is written.
1192 pub depth_pass: bool,
1193 /// Enable the blender for this pixel (vs. an opaque overwrite).
1194 pub blend_en: bool,
1195 /// Coverage overflowed (`>= 8`): the surface differs, wrap the coverage.
1196 pub coverage_wrap: bool,
1197 /// The two blender coverage shifts (`[dz→mem, mem→dz]`, each clamped `0..=4`).
1198 pub blend_shift: [u8; 2],
1199 /// The (possibly interpenetrating-reduced) coverage count carried forward.
1200 pub coverage_count: i32,
1201}
1202
1203/// The decoded per-triangle depth setup for the per-pixel path: the `s15.16`
1204/// z-coefficient and its per-x / per-major-edge deltas, plus the primitive `dz`
1205/// (linear) and `dz_compressed` (4-bit stored form) for the test and writeback.
1206#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
1207#[allow(
1208 clippy::similar_names,
1209 reason = "dzdx / dzde / dz are the N64 RDP's own z-coefficient names"
1210)]
1211struct ZTriSetup {
1212 z_base: i32,
1213 dzdx: i32,
1214 dzde: i32,
1215 dz: i32,
1216 dz_compressed: i32,
1217}
1218
1219/// The decoded per-triangle shade setup for the per-pixel path: the `s15.16` RGBA
1220/// base color (at the top vertex) and its per-x (`dx`) and per-major-edge (`de`)
1221/// deltas. The per-scanline `dy` term (sub-pixel snap) is part 2c.
1222#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
1223struct ShadeSetup {
1224 base: [i32; 4],
1225 dx: [i32; 4],
1226 de: [i32; 4],
1227}
1228
1229/// The decoded per-triangle texture setup: `S`/`T`/`W` (`W` for the perspective
1230/// divide). `base`/`dx`/`de` are the `s16.16` base at the top vertex and its per-x
1231/// and per-major-edge deltas.
1232#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
1233struct TexSetup {
1234 base: [i32; 3],
1235 dx: [i32; 3],
1236 de: [i32; 3],
1237 /// Per-**Y** derivative (`DsDy`/`DtDy`/`DwDy`, words 5/7). Unlike `de` (the
1238 /// major-edge step the scanline walk uses), this is the true vertical
1239 /// gradient the **LOD** needs for its second delta pair (R-13).
1240 dy: [i32; 3],
1241}
1242
1243/// Unpack an RGBA8888 register word into `[r, g, b, a]` bytes.
1244const fn unpack_rgba(w: u32) -> [u8; 4] {
1245 w.to_be_bytes()
1246}
1247
1248/// Pack an RGBA8888 color into a 16-bit RGBA5551 framebuffer pixel
1249/// (`R[15:11] G[10:6] B[5:1] A[0]`).
1250const fn pack_rgba5551(rgba: [u8; 4]) -> u16 {
1251 ((rgba[0] as u16 >> 3) << 11)
1252 | ((rgba[1] as u16 >> 3) << 6)
1253 | ((rgba[2] as u16 >> 3) << 1)
1254 | (rgba[3] as u16 >> 7)
1255}
1256
1257/// Unpack a 16-bit RGBA5551 framebuffer pixel to RGBA8888, widening each 5-bit
1258/// channel by high-bit replication (`v << 3 | v >> 2`) and the 1-bit alpha to
1259/// `0x00`/`0xFF`.
1260const fn unpack_rgba5551(p: u16) -> [u8; 4] {
1261 let r = (p >> 11) & 0x1F;
1262 let g = (p >> 6) & 0x1F;
1263 let b = (p >> 1) & 0x1F;
1264 [
1265 ((r << 3) | (r >> 2)) as u8,
1266 ((g << 3) | (g >> 2)) as u8,
1267 ((b << 3) | (b >> 2)) as u8,
1268 if p & 1 != 0 { 0xFF } else { 0 },
1269 ]
1270}
1271
1272/// The number of Y-subpixels the RDP samples per scanline for coverage
1273/// (parallel-rdp `coverage.h` `SUBPIXELS`).
1274pub const COVERAGE_SUBPIXELS: usize = 4;
1275
1276/// `log2` of the 4 sub-scanlines per pixel row: the triangle edge slopes are dx
1277/// per **pixel** row but the edge-walk steps per **quarter**-pixel, so each slope
1278/// is pre-shifted `>> SUB_SCANLINE_SHIFT` at decode (ledger R-14).
1279const SUB_SCANLINE_SHIFT: i32 = 2;
1280
1281/// Left-edge poison for a sub-scanline outside the triangle: larger than any real
1282/// `s.3` edge, so every X-sample tests as clipped and `min4` ignores it.
1283const SPAN_X_POISON_LEFT: i32 = i32::MAX;
1284/// Right-edge poison (mirror of [`SPAN_X_POISON_LEFT`]).
1285const SPAN_X_POISON_RIGHT: i32 = i32::MIN;
1286
1287/// The minimum of the four per-Y-subpixel left edges (parallel-rdp `span_setup.comp` `min4`).
1288fn min4(v: &[i32; COVERAGE_SUBPIXELS]) -> i32 {
1289 v[0].min(v[1]).min(v[2]).min(v[3])
1290}
1291/// The maximum of the four per-Y-subpixel right edges (`span_setup.comp` `max4`).
1292fn max4(v: &[i32; COVERAGE_SUBPIXELS]) -> i32 {
1293 v[0].max(v[1]).max(v[2]).max(v[3])
1294}
1295
1296/// Quantize a signed edge X to the 3-fraction-bit sub-pixel domain used by
1297/// [`compute_coverage`], with the RDP sticky bit.
1298///
1299/// Any discarded fraction bit forces the low output bit set, so a
1300/// truncated-but-nonzero coordinate never snaps exactly onto a sub-pixel
1301/// boundary — this is what makes the half-open `<` / `>=` edge tests in
1302/// [`compute_coverage`] bit-exact (parallel-rdp `span_setup.comp:60-66`).
1303///
1304/// **Fixed-point domain.** parallel-rdp's `setup.xh` is `s.15` (its `base_x =
1305/// xh >> 15`), so it quantizes with `>> 12` to reach the `s.3` coverage domain.
1306/// Our edge values are the raw command `s.16` (`major >> 16` is the pixel), one
1307/// fraction bit wider, so we shift `>> 13` and take the sticky bit over the low
1308/// 13 discarded bits — the same 3-fraction-bit result.
1309///
1310/// This and [`compute_coverage`] are the sub-pixel coverage primitives (T-33-004
1311/// slice 2c); the rasterizer's edge-walk still unions the four sub-scanlines into
1312/// a whole-pixel bounding span (**open residual R-9**) until the coverage
1313/// integration lands, so these have no runtime caller yet.
1314#[must_use]
1315pub const fn quantize_x(x: i32) -> i32 {
1316 let sticky = (x & 0x1FFF != 0) as i32;
1317 (x >> 13) | sticky
1318}
1319
1320/// The 8-bit sub-pixel coverage mask for integer pixel column `x`, given the
1321/// per-Y-subpixel left/right span edges (each `s.3`, from [`quantize_x`]).
1322///
1323/// The RDP samples 4 Y-subpixels × 2 X-samples = 8 sub-positions. The two
1324/// X-samples sit at sub-pixel fractions that alternate by Y-subpixel — `{0, 4}`
1325/// for Y-subpixels 0/2 and `{2, 6}` for 1/3 — the RDP's diamond sample pattern
1326/// (`coverage.h:31-44` `u16x4(0, 4, 2, 6)`). A sample is covered when it lies in
1327/// the half-open span `[xleft, xright)` of its Y-subpixel.
1328///
1329/// **Bit layout.** The oracle's `clip_x0 * (1,2,4,8) + clip_x1 * (16,32,64,128)`
1330/// packs the two X-samples of each Y-subpixel into adjacent bits — bit
1331/// `2·Ysub + Xsample` — so the mask reads (LSB→MSB) `Y0X0 Y0X1 Y1X0 Y1X1 Y2X0
1332/// Y2X1 Y3X0 Y3X1`, *not* all-X0 then all-X1. Bit 0 is therefore the top-left
1333/// sample (Y-subpixel 0, X-sample 0), which is the one the AA-off path tests.
1334/// The popcount ([`u8::count_ones`]) is the coverage count (0–8) regardless of order.
1335///
1336/// See [`quantize_x`] for the domain of `xleft`/`xright` and why this has no
1337/// runtime caller yet.
1338#[must_use]
1339pub fn compute_coverage(
1340 xleft: [i32; COVERAGE_SUBPIXELS],
1341 xright: [i32; COVERAGE_SUBPIXELS],
1342 x: i32,
1343) -> u8 {
1344 // The four lanes carry X-sample offsets {0, 4, 2, 6}; `xshift = (x << 3) + offset`.
1345 let base = x << 3;
1346 let xshift = [base, base + 4, base + 2, base + 6];
1347 // `.xxyy` / `.zzww`: lanes 0-3 test Y-subpixels {0,0,1,1} then {2,2,3,3}.
1348 let ysub_lo = [0usize, 0, 1, 1];
1349 let ysub_hi = [2usize, 2, 3, 3];
1350 let mut clip = 0u8;
1351 let mut lane = 0;
1352 while lane < COVERAGE_SUBPIXELS {
1353 let lo = ysub_lo[lane];
1354 if xshift[lane] < xleft[lo] || xshift[lane] >= xright[lo] {
1355 clip |= 1 << lane;
1356 }
1357 let hi = ysub_hi[lane];
1358 if xshift[lane] < xleft[hi] || xshift[lane] >= xright[hi] {
1359 clip |= 1 << (lane + 4);
1360 }
1361 lane += 1;
1362 }
1363 !clip
1364}
1365
1366/// The RDP's 4×4 "magic" ordered-dither matrix (parallel-rdp/angrylion `dither.c`).
1367#[rustfmt::skip]
1368const DITHER_MAGIC: [u8; 16] = [
1369 0, 6, 1, 7,
1370 4, 2, 5, 3,
1371 3, 5, 2, 4,
1372 7, 1, 6, 0,
1373];
1374/// The RDP's 4×4 "bayer" ordered-dither matrix (`dither.c`).
1375#[rustfmt::skip]
1376const DITHER_BAYER: [u8; 16] = [
1377 0, 4, 1, 5,
1378 4, 0, 5, 1,
1379 3, 7, 2, 6,
1380 7, 3, 6, 2,
1381];
1382
1383/// The 3-bit RGB dither value for pixel `(x, y)` under the RGB dither mode
1384/// (`Set Other Modes` bits 39:38): 0 magic, 1 bayer, 2 noise (**R-10**, read as
1385/// the magic cell for now), 3 "constant 7" — which never rounds up, i.e. dither
1386/// off. (parallel-rdp `dither.c` `get_dither_noise`.)
1387fn rgb_dither_value(mode: u8, x: u32, y: u32) -> i32 {
1388 let idx = ((y & 3) * 4 + (x & 3)) as usize;
1389 match mode {
1390 // Mode 2 (noise) intentionally reuses the magic matrix pending a real
1391 // noise source (**R-10**); a per-pixel noise value replaces this then.
1392 0 | 2 => i32::from(DITHER_MAGIC[idx]),
1393 1 => i32::from(DITHER_BAYER[idx]),
1394 _ => 7, // 3: constant 7 -> no dithering
1395 }
1396}
1397
1398/// Apply the RDP's ordered RGB dither to a color (`dither.c` `rgb_dither`): each
1399/// channel rounds up to the next 5-bit level `(c & 0xf8) + 8` (saturating at 255)
1400/// **iff** the dither value is less than the channel's low 3 bits, else it is left
1401/// unchanged. Alpha is untouched. `dith` is the shared 3-bit value (matrix cell).
1402fn apply_rgb_dither(rgb: [u8; 4], dith: i32) -> [u8; 4] {
1403 let channel = |c: u8| -> u8 {
1404 let c = i32::from(c);
1405 let rounded = if c > 247 { 255 } else { (c & 0xF8) + 8 };
1406 // `replacesign` is all-ones when `dith < (c & 7)`, selecting the rounded value.
1407 let replacesign = (dith - (c & 7)) >> 31;
1408 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1409 {
1410 (c + ((rounded - c) & replacesign)) as u8
1411 }
1412 };
1413 [channel(rgb[0]), channel(rgb[1]), channel(rgb[2]), rgb[3]]
1414}
1415
1416/// One cycle of the color combiner.
1417///
1418/// The four RGB input selects and the four alpha input selects for
1419/// `(A − B) * C + D` (`Set Combine Mode`, 0x3C). A/B/D RGB are 4-bit (D 3-bit),
1420/// C RGB is 5-bit; the alpha selects are all 3-bit.
1421#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1422pub struct CombineCycle {
1423 /// RGB `A` (muladd) select.
1424 pub rgb_a: u8,
1425 /// RGB `B` (mulsub) select.
1426 pub rgb_b: u8,
1427 /// RGB `C` (mul) select.
1428 pub rgb_c: u8,
1429 /// RGB `D` (add) select.
1430 pub rgb_d: u8,
1431 /// Alpha `A` select.
1432 pub a_a: u8,
1433 /// Alpha `B` select.
1434 pub a_b: u8,
1435 /// Alpha `C` select.
1436 pub a_c: u8,
1437 /// Alpha `D` select.
1438 pub a_d: u8,
1439}
1440
1441/// The two-cycle color-combiner configuration (`Set Combine Mode`, 0x3C).
1442#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1443pub struct CombineMode {
1444 /// Cycle-0 selects (the first stage in 2-cycle mode).
1445 pub cyc0: CombineCycle,
1446 /// Cycle-1 selects (the only stage used in 1-cycle mode).
1447 pub cyc1: CombineCycle,
1448}
1449
1450/// The resolved per-pixel combiner input signals (each RGBA8888).
1451///
1452/// The combiner muxes these by the [`CombineCycle`] selects. The register-sourced
1453/// exotic inputs (prim-LOD-frac, the convert `K4`/`K5`, the chroma-key center/scale)
1454/// are wired; the rest (noise, the derivative `lod_frac`, the YUV `K0`–`K3` convert)
1455/// are not modeled yet (**open residual R-10**) and read as zero.
1456///
1457/// This is **transient** per-pixel state — built in `combined_color`, consumed by
1458/// `combine`, and discarded — never stored in `System`. It is therefore `pub(crate)`
1459/// (not part of this crate's public API), does not derive `Serialize`/`Deserialize`
1460/// (never in a save-state), and needs no `#[non_exhaustive]` (a new field is always a
1461/// compatible change within the crate). Construct it with `..Default::default()`.
1462#[derive(Debug, Default, Clone, Copy)]
1463pub(crate) struct CombinerInputs {
1464 /// The previous cycle's output (cycle 0's result feeds cycle 1's `Combined`).
1465 pub combined: [u8; 4],
1466 /// Texel from tile 0.
1467 pub texel0: [u8; 4],
1468 /// Texel from tile 1.
1469 pub texel1: [u8; 4],
1470 /// The primitive color (`Set Prim Color`, 0x3A).
1471 pub prim: [u8; 4],
1472 /// The interpolated shade color.
1473 pub shade: [u8; 4],
1474 /// The environment color (`Set Env Color`, 0x3B).
1475 pub env: [u8; 4],
1476 /// Primitive LOD fraction (`Set Prim Color` word-0 low byte) — combiner mul
1477 /// input (RGB select 14, alpha select 6). `0..=255` (R-10).
1478 pub prim_lod_frac: i16,
1479 /// The derivative-computed **LOD fraction** — combiner mul input (RGB select
1480 /// 13, alpha select 0). Held **raw** (`0..=0x1ff`; the `0x100` bit is set by
1481 /// `sharpen_tex_en`), sign-extended downstream by `sext9` exactly like
1482 /// `K4`/`K5`. R-13.
1483 pub lod_frac: i16,
1484 /// `Set Convert` (0x2C) `K4` — combiner RGB sub-B input (select 7). Held as the
1485 /// **raw 0..511** value the hardware stores; `combine_channel` sign-extends it via
1486 /// `special_expand` (bit-identical to Angrylion's `special_9bit_exttable`), so a
1487 /// bit-8-set value reads as negative. Storing raw matches Angrylion `rdp_set_convert`
1488 /// — sign-extending at decode would double-apply. R-10.
1489 pub k4: i16,
1490 /// `Set Convert` (0x2C) `K5` — combiner RGB mul input (select 15). Held as the
1491 /// **raw 0..511** value; `combine_channel` sign-extends it via `sext9`
1492 /// (Angrylion `SIGNF(c, 9)`). Stored raw, like `k4`. R-10.
1493 pub k5: i16,
1494 /// Chroma-key center `[r, g, b]` (`Set Key R`/`GB`) — the combiner RGB sub-B
1495 /// input (select 6), fed as an 8-bit value like `prim`/`env`. R-10.
1496 pub key_center: [u8; 3],
1497 /// Chroma-key scale `[r, g, b]` (`Set Key R`/`GB`) — the combiner RGB mul input
1498 /// (select 6), 8-bit. R-10.
1499 pub key_scale: [u8; 3],
1500}
1501
1502/// TMEM size in bytes — 4 KiB of on-chip texture memory.
1503///
1504/// Addressed as 512 64-bit words. The upper half (byte >= 0x800 / word >= 0x100)
1505/// holds TLUTs and the high halves of 32-bit / YUV textures (N64brew
1506/// *…/Commands* §Set Tile).
1507pub const TMEM_SIZE: usize = 4096;
1508
1509/// One of the RDP's eight tile descriptors.
1510///
1511/// The format/size/addressing state that binds a region of TMEM to a texture,
1512/// set by `Set Tile` (0x35) and sized by `Set Tile Size` (0x32) / the load
1513/// commands. All fields are decoded straight from the command word (N64brew
1514/// *…/Commands* §0x35/§0x32).
1515// The four bools are the hardware's four independent clamp/mirror bit-flags (one
1516// clamp + one mirror per S/T axis); they are decoded straight from command bits
1517// 8/9/18/19, so an enum would misrepresent the register rather than clarify it.
1518#[allow(clippy::struct_excessive_bools)]
1519#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1520pub struct TileDescriptor {
1521 /// Texel format: RGBA=0, YUV=1, CI=2, IA=3, I=4 (`Set Tile` bits 55:53).
1522 pub format: u8,
1523 /// Texel size code: 4bpp=0, 8bpp=1, 16bpp=2, 32bpp=3 (bits 52:51).
1524 pub size: u8,
1525 /// Row stride in 64-bit TMEM words (bits 49:41).
1526 pub line: u16,
1527 /// Base TMEM address in 64-bit words (bits 40:32); word 0x100 = byte 0x800.
1528 pub tmem_addr: u16,
1529 /// Palette index, the high half of the TLUT address for CI4 tiles only
1530 /// (bits 23:20).
1531 pub palette: u8,
1532 // T-axis fields precede S-axis, matching the command word's MSB→LSB order
1533 // (T in bits 19:10, S in bits 9:0) and the `set_tile` decoder.
1534 /// Clamp T when sampling outside the tile (bit 19).
1535 pub clamp_t: bool,
1536 /// Mirror T on every other wrap (bit 18).
1537 pub mirror_t: bool,
1538 /// Number of T integer-coordinate bits used for wrap; 0 = all (bits 17:14).
1539 pub mask_t: u8,
1540 /// T coordinate shift code, per the shift table (bits 13:10).
1541 pub shift_t: u8,
1542 /// Clamp S when sampling outside the tile (bit 9).
1543 pub clamp_s: bool,
1544 /// Mirror S on every other wrap (bit 8).
1545 pub mirror_s: bool,
1546 /// Number of S integer-coordinate bits used for wrap; 0 = all (bits 7:4).
1547 pub mask_s: u8,
1548 /// S coordinate shift code, per the shift table (bits 3:0).
1549 pub shift_s: u8,
1550 /// Tile-size upper-left S (`u10.2`), from `Set Tile Size` / the loaders.
1551 pub sl: u16,
1552 /// Tile-size upper-left T (`u10.2`).
1553 pub tl: u16,
1554 /// Tile-size lower-right S (`u10.2`).
1555 pub sh: u16,
1556 /// Tile-size lower-right T (`u10.2`).
1557 pub th: u16,
1558}
1559
1560/// Proof that a step needs the bus, produced only by [`Rdp::tick_without_bus`] and
1561/// consumed only by [`Rdp::tick_with_bus`].
1562///
1563/// The two halves exist for a caller that cannot hand out a borrow of this struct
1564/// without first moving the struct itself — it needs to know whether the step will use
1565/// the bus *before* paying the 344-byte `core::mem::take` that arranging one costs.
1566/// (In this workspace that caller is the Bus, which owns every chip.) The bus half
1567/// then has real preconditions:
1568/// the pipeline is unfrozen, `stall` is zero, and the command FIFO is non-empty.
1569///
1570/// Those preconditions are carried by this token rather than by a comment, an
1571/// `assert!`, or a `debug_assert!` alone. A comment is not checked; a `debug_assert`
1572/// compiles out, so a release build would decode a command from an empty FIFO in
1573/// silence; and a release guard that returns early would make a caller who never calls
1574/// `tick_without_bus` hang, because `stall` would never count down. Requiring the token
1575/// removes all three: calling the bus half out of order does not compile.
1576///
1577/// The field is private and the type has no constructor, so it cannot be forged.
1578///
1579/// **Dropping a token loses a cycle, not the work.** The `Some` path mutates
1580/// nothing — it is reached only once `stall` is zero, so no decrement has happened, and
1581/// no FIFO pointer moves until the bus half runs. The command is therefore still
1582/// pending and the next `rdp_tick` retries the identical step. What is lost is the
1583/// *step*: the RDP made no progress during that GCLK and is one cycle late from then
1584/// on. That is the failure mode worth naming here, because it is a timing divergence
1585/// with **no wrong state anywhere** — correct-but-late, which no state comparison can
1586/// see. Hence `#[must_use]`, below, rather than a `debug_assert` on the token count.
1587///
1588/// What makes ignoring one loud is the `#[must_use]` on
1589/// [`Rdp::tick_without_bus`] **itself**, not the one on this type: an attribute on `T`
1590/// does not propagate through `Option<T>`, and `Option` — unlike `Result` — is not
1591/// `#[must_use]` either. Verified by discarding the call and watching the lint appear
1592/// only once the attribute moved to the function.
1593///
1594/// **`Copy` and `Clone` are deliberately not derived.** Either would let a caller keep
1595/// a token past the step it authorized and present it again after the state it attested
1596/// to had changed — the same hole as taking it by reference. `Debug` is derived because
1597/// it cannot duplicate the value.
1598#[derive(Debug)]
1599#[must_use = "a step that needs the bus is not finished until `tick_with_bus` runs it"]
1600pub struct NeedsBus(());
1601
1602/// RDP state (skeleton).
1603///
1604/// Holds the command-FIFO pointers, the current render mode (other-modes),
1605/// scissor rectangle, the color-image / Z-image RDRAM addresses, the
1606/// texture-image source registers, the eight tile descriptors, and TMEM. The
1607/// texel loads and the sampler/combiner that consume this state land in the rest
1608/// of the sprint. `#[non_exhaustive]`: this render state grows every sprint
1609/// (other-modes and the combiner latches are still to come), so adding a field
1610/// must not be a breaking change. Construct via [`Rdp::new`]; the workspace
1611/// never uses a struct literal.
1612#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1613#[non_exhaustive]
1614pub struct Rdp {
1615 /// DP command FIFO start (`DPC_START`).
1616 pub cmd_start: u32,
1617 /// DP command FIFO end (`DPC_END`).
1618 pub cmd_end: u32,
1619 /// DP command FIFO current (`DPC_CURRENT`).
1620 pub cmd_current: u32,
1621 /// DP command FIFO status (`DPC_STATUS`): FREEZE, START/END-valid, XBUS,
1622 /// and (later) the busy/counter bits.
1623 pub status: u32,
1624 /// Color-image (framebuffer) base in RDRAM (`Set Color Image`, 0x3F).
1625 pub color_image: u32,
1626 /// Color-image pixel size code (`Set Color Image` size\[1:0\]): 0 = 4-bit,
1627 /// 1 = 8-bit, 2 = 16-bit, 3 = 32-bit. Bytes-per-pixel derive from it.
1628 pub color_image_size: u8,
1629 /// Color-image pixel format code (`Set Color Image` format\[2:0\]); the same
1630 /// format enumeration as textures. Stored for later pipeline stages — the
1631 /// FILL path writes the raw fill value and does not consult it.
1632 pub color_image_format: u8,
1633 /// Color-image width in pixels (`Set Color Image` width\[9:0\] + 1). The row
1634 /// stride is `width * bytes_per_pixel`.
1635 pub color_image_width: u16,
1636 /// Z-image base in RDRAM (`Set Depth Image`, 0x3E).
1637 pub z_image: u32,
1638 /// Primitive depth `z` (`Set Primitive Depth`, 0x2E): the s15.3 depth used when
1639 /// `Set Other Modes` `z_source_sel` selects it (and the only depth source for
1640 /// rectangle commands). Stored as the raw 16-bit field.
1641 pub prim_z: u16,
1642 /// Primitive `dz` (`Set Primitive Depth`, 0x2E), the raw 16-bit field.
1643 pub prim_dz: u16,
1644 /// FILL-mode color register (`Set Fill Color`, 0x37): a 32-bit value written
1645 /// verbatim to the color image. Its interpretation depends on the pixel size
1646 /// — one RGBA32, two RGBA16 (even pixel = upper half, odd = lower), or four
1647 /// 8-bit values repeating every four pixels.
1648 pub fill_color: u32,
1649 /// Scissor rectangle (`Set Scissor`, 0x2D), the four `u10.2` screen
1650 /// coordinates that bound every primitive: upper-left (x, y) and lower-right
1651 /// (x, y). Pixels outside it are neither processed nor written.
1652 pub scissor_ulx: u16,
1653 /// Scissor upper-left y (`u10.2`). See [`Rdp::scissor_ulx`].
1654 pub scissor_uly: u16,
1655 /// Scissor lower-right x (`u10.2`). See [`Rdp::scissor_ulx`].
1656 pub scissor_lrx: u16,
1657 /// Scissor lower-right y (`u10.2`). See [`Rdp::scissor_ulx`].
1658 pub scissor_lry: u16,
1659 /// Count of commands the FIFO decoder has retired. A **retired-work tally**,
1660 /// not a cycle position: nothing schedules against it (the residue
1661 /// invariant governs only `master_ticks`), it is derived from the command
1662 /// stream, and it exists so tests can witness that the decoder consumed the
1663 /// number of commands it should. Wraps rather than panicking.
1664 pub commands_processed: u64,
1665 /// GCLK cycles the pipeline is currently stalled, counted **down** one per
1666 /// `tick`; while non-zero the FIFO does not advance. Set by the sync
1667 /// commands to their documented fixed stalls ([`SYNC_LOAD_GCLK`] etc.). This
1668 /// is a stall countdown, not a cycle position — it is decremented, nothing
1669 /// derives a clock from it, and it does not touch the derive-don't-increment
1670 /// rule (only `master_ticks` is ever incremented; ADR 0006).
1671 pub stall: u32,
1672 /// Texture-image (`Set Texture Image`, 0x3D) format code — the RDRAM source
1673 /// for texture loads. The wiki notes this format has no effect on any
1674 /// operation (only the tile format matters); stored for completeness.
1675 pub tex_image_format: u8,
1676 /// Texture-image texel size code (0x3D size\[1:0\]): 4/8/16/32bpp = 0/1/2/3.
1677 /// Drives the per-texel address stride during a load.
1678 pub tex_image_size: u8,
1679 /// Texture-image width in pixels (0x3D width\[9:0\] + 1); the row stride of
1680 /// the RDRAM source during a load.
1681 pub tex_image_width: u16,
1682 /// Texture-image base address in RDRAM (0x3D dramAddress\[23:0\]).
1683 pub tex_image_addr: u32,
1684 /// The eight tile descriptors (`Set Tile` / `Set Tile Size` / the loaders).
1685 pub tiles: [TileDescriptor; 8],
1686 /// The color-combiner configuration (`Set Combine Mode`, 0x3C).
1687 pub combine: CombineMode,
1688 /// The render-mode / blender configuration (`Set Other Modes`, 0x2F).
1689 pub other_modes: OtherModes,
1690 /// The primitive color, RGBA8888 (`Set Prim Color`, 0x3A).
1691 pub prim_color: u32,
1692 /// Primitive LOD fraction (`Set Prim Color` word-0 low byte) — a combiner mul
1693 /// input (R-10).
1694 pub prim_lod_frac: u8,
1695 /// `Set Prim Color` `min_level` (word-0 bits 12:8): the floor the LOD is held
1696 /// at while magnifying (Angrylion `lodfrac_lodtile_signals`). R-13.
1697 pub min_level: u8,
1698 /// The current primitive's `level[2:0]` (triangle command bits 53:51) — the
1699 /// number of mip levels past the base tile. Per-primitive render state, set
1700 /// by the triangle decode, exactly as Angrylion holds it (`rasterizer.c`
1701 /// `state[wid].max_level = (ewdata[0] >> 19) & 7`). R-13.
1702 pub max_level: u8,
1703 /// `Set Convert` (0x2C) `K4`/`K5` — combiner sub-B / mul inputs, raw 9-bit
1704 /// (`K0..K3`, the YUV-convert coefficients, are deferred). R-10.
1705 pub k4: i16,
1706 /// `Set Convert` `K5` (see `k4`).
1707 pub k5: i16,
1708 /// Chroma-key **center** per channel `[r, g, b]` (`Set Key R` 0x2B / `Set Key GB`
1709 /// 0x2A) — the combiner RGB sub-B input (select 6). `0..=255`. R-10.
1710 pub key_center: [u8; 3],
1711 /// Chroma-key **scale** per channel `[r, g, b]` (`Set Key R`/`GB`) — the combiner
1712 /// RGB mul input (select 6). `0..=255`. R-10.
1713 pub key_scale: [u8; 3],
1714 /// Chroma-key **width** per channel `[r, g, b]` (`Set Key R`/`GB`, 12-bit) — the
1715 /// half-width of the key window, consumed by the `key_en` chroma-key alpha compare
1716 /// (`chroma_key_min`), not the combiner mux. R-10.
1717 pub key_width: [u16; 3],
1718 /// The environment color, RGBA8888 (`Set Env Color`, 0x3B).
1719 pub env_color: u32,
1720 /// The blend color, RGBA8888 (`Set Blend Color`, 0x39).
1721 pub blend_color: u32,
1722 /// The fog color, RGBA8888 (`Set Fog Color`, 0x38).
1723 pub fog_color: u32,
1724 /// On-chip texture memory (4 KiB). **Lazily allocated**: `None` until the
1725 /// first byte is written, and read as all-zero while `None`. This keeps
1726 /// [`Rdp`]'s `Default` cheap, which matters because `Bus::rdp_tick` does a
1727 /// `core::mem::take` every RCP step — a `None` placeholder is swapped in with
1728 /// no 4 KiB allocation or copy, while the real TMEM box moves by pointer.
1729 #[serde(with = "rustyn64_snapshot::opt_boxed_bytes")]
1730 tmem: Option<alloc::boxed::Box<[u8; TMEM_SIZE]>>,
1731}
1732
1733impl Rdp {
1734 /// Construct at power-on.
1735 #[must_use]
1736 pub fn new() -> Self {
1737 Self::default()
1738 }
1739
1740 /// Read a DP command register by word offset within the `0x0410_0000`
1741 /// block: 0 `DPC_START`, 1 `DPC_END`, 2 `DPC_CURRENT`, 3 `DPC_STATUS`. The
1742 /// clock/busy/counter registers (4..=7) are not modeled and read zero.
1743 #[must_use]
1744 pub const fn dpc_read(&self, offset: u32) -> u32 {
1745 match offset & 7 {
1746 0 => self.cmd_start,
1747 1 => self.cmd_end,
1748 2 => self.cmd_current,
1749 3 => self.status,
1750 _ => 0,
1751 }
1752 }
1753
1754 /// Write a DP command register (word offsets as in [`Rdp::dpc_read`]).
1755 ///
1756 /// The FIFO uses a double-latch pinned by n64-systemtest's `RSP STATUS:
1757 /// start-valid` and documented in the N64brew wiki (*Reality Display
1758 /// Processor Interface*, the `DPC_END` section):
1759 ///
1760 /// - Writing `DPC_START` latches the (masked) address and sets `START_VALID`
1761 /// **only if it was clear** — a second write while valid is ignored.
1762 /// - Writing `DPC_END` latches the end address, then branches on
1763 /// `START_VALID` (the wiki's `START_PENDING`): if **set**, this is a fresh
1764 /// transfer — copy the pending start into `DPC_CURRENT` and clear
1765 /// `START_VALID`. If **clear**, it is an *incremental* transfer that
1766 /// continues from the current position, so `DPC_CURRENT` is left alone
1767 /// (rewinding it would reprocess already-consumed commands). On unfrozen
1768 /// hardware the transfer also runs; while frozen only the latch happens.
1769 pub const fn dpc_write(&mut self, offset: u32, value: u32) {
1770 match offset & 7 {
1771 0 => {
1772 if self.status & DP_STATUS_START_VALID == 0 {
1773 self.cmd_start = value & DPC_ADDR_MASK;
1774 self.status |= DP_STATUS_START_VALID;
1775 }
1776 }
1777 1 => {
1778 self.cmd_end = value & DPC_ADDR_MASK;
1779 if self.status & DP_STATUS_START_VALID != 0 {
1780 self.cmd_current = self.cmd_start;
1781 self.status &= !DP_STATUS_START_VALID;
1782 }
1783 }
1784 3 => self.dpc_write_status(value),
1785 _ => {}
1786 }
1787 }
1788
1789 /// Apply a `DPC_STATUS` write, whose bits are set/clear *commands* rather
1790 /// than the status layout read back. Only XBUS and FREEZE are modeled; the
1791 /// FLUSH/TMEM/PIPE/CMD/CLOCK-counter commands come with the FIFO drain.
1792 // TODO(T-RDP-01): when `SET_FLUSH` (pipeline flush) lands here, it must also
1793 // clear `self.stall` — a flush discards in-flight pipeline work, so a
1794 // leftover sync-stall countdown must not persist across it. Subsystem-scoped
1795 // (pre-ticket) rather than T-31-003, which is the fill pipeline, not flush.
1796 const fn dpc_write_status(&mut self, value: u32) {
1797 const CLEAR_XBUS: u32 = 0x1;
1798 const SET_XBUS: u32 = 0x2;
1799 const CLEAR_FREEZE: u32 = 0x4;
1800 const SET_FREEZE: u32 = 0x8;
1801 if value & CLEAR_XBUS != 0 {
1802 self.status &= !DP_STATUS_XBUS;
1803 }
1804 if value & SET_XBUS != 0 {
1805 self.status |= DP_STATUS_XBUS;
1806 }
1807 if value & CLEAR_FREEZE != 0 {
1808 self.status &= !DP_STATUS_FREEZE;
1809 }
1810 if value & SET_FREEZE != 0 {
1811 self.status |= DP_STATUS_FREEZE;
1812 }
1813 }
1814
1815 /// Advance the RDP by one rasterization step: decode the command at
1816 /// `DPC_CURRENT` and consume its whole length, so the FIFO drains one
1817 /// command per scheduler tick rather than in a burst.
1818 ///
1819 /// Hot path: keep allocation-free. No-op while the FIFO is empty
1820 /// (`DPC_CURRENT >= DPC_END`) or the DP is frozen (`DPC_STATUS.FREEZE`).
1821 ///
1822 /// The command length comes from [`command::command_len_words`], which
1823 /// recognizes every opcode `0x00`–`0x3F`; consuming the exact length is what
1824 /// keeps a multi-word primitive from desyncing the pointer. Today the
1825 /// decoder only advances and counts — no primitive is rasterized yet.
1826 ///
1827 /// Commands are read from RDRAM (the `XBUS` bit clear). The `XBUS`/DMEM
1828 /// command source is not yet wired: the `rdpq` microcode that drives us DMAs
1829 /// its list to RDRAM, so the RDRAM path is the one exercised. With `XBUS`
1830 /// set the decoder **stalls** rather than mis-reading RDRAM as the command
1831 /// stream — decoding DMEM commands out of RDRAM would treat parameter data
1832 /// as opcodes and desync.
1833 ///
1834 /// Dispatch so far (`dispatch`) covers the four sync commands and the FILL
1835 /// pipeline (Set Color Image, Set Fill Color, Set Scissor, Fill Rectangle).
1836 /// Everything else is still recognized-and-consumed only.
1837 ///
1838 /// **This is the whole-step entry point and is not deprecated.** It is what a
1839 /// caller uses when it already holds the bus and has nothing to decide — tests,
1840 /// and any future embedder. `Bus::rdp_tick` in `rustyn64-core` instead calls the two
1841 /// halves directly, because it must know whether the step needs the bus *before*
1842 /// arranging one: the arranging is a `core::mem::take` of this whole struct.
1843 /// Splitting is worth it only for that caller, which is why the convenient form
1844 /// stays.
1845 pub fn tick<B: VideoBus>(&mut self, bus: &mut B) {
1846 if let Some(proof) = self.tick_without_bus() {
1847 self.tick_with_bus(proof, bus);
1848 }
1849 }
1850
1851 /// The part of a step that needs **no bus access**, returning `None` when it
1852 /// finished the step on its own and `Some(NeedsBus)` when work remains.
1853 ///
1854 /// **This advances state**, despite the `Option` return: on the stalling path it
1855 /// burns one GCLK. It is named `tick_*` rather than `is_*` for that reason — in
1856 /// this codebase `tick` means *advance by one step*, as in `Bus::rsp_tick` and
1857 /// `Cpu::tick_at`.
1858 ///
1859 /// Split out so a caller can decide whether to pay for bus access *before*
1860 /// arranging it. `Bus::rdp_tick` moves this whole struct out of the Bus with
1861 /// `core::mem::take` to satisfy the borrow checker — 344 bytes read and written,
1862 /// plus a default written back, on **every RCP step** — and on most steps the
1863 /// answer here is `None`, so that shuffle bought nothing
1864 /// (`docs/performance.md` §"The Bus split-borrow moves 1.35 GB a frame").
1865 ///
1866 /// It lives here rather than in the Bus because it is a statement about *this*
1867 /// chip's early-outs: if they change, this changes with them, in the same file.
1868 /// [`Rdp::tick`] calls it too, so there is one implementation and no way for the
1869 /// two to disagree.
1870 ///
1871 /// Returns [`NeedsBus`] when the step still has work that requires RDRAM. That
1872 /// token is the only way to reach [`Rdp::tick_with_bus`], so the two halves cannot
1873 /// be called out of order — the preconditions are carried by the type rather than
1874 /// by a comment or an assertion.
1875 #[must_use = "a `Some` means the step is unfinished and needs `tick_with_bus`"]
1876 pub fn tick_without_bus(&mut self) -> Option<NeedsBus> {
1877 // Frozen or DMEM-sourced (XBUS, not yet wired): the pipeline counter is
1878 // halted, so do not even burn a stall cycle.
1879 if self.status & (DP_STATUS_FREEZE | DP_STATUS_XBUS) != 0 {
1880 return None;
1881 }
1882 // A prior sync is still stalling the pipeline — burn one GCLK and hold
1883 // the FIFO until the stall expires.
1884 if self.stall > 0 {
1885 self.stall -= 1;
1886 return None;
1887 }
1888 // An empty command FIFO. A plain `>=` is right because these are RDRAM
1889 // addresses, not ring indices: `DPC_START`/`DPC_END` are latched through
1890 // `DPC_ADDR_MASK` in [`Rdp::dpc_write`], `cmd_current` only ever advances by a
1891 // decoded command's length, and the hardware has no wrap — a driver that wants
1892 // to restart writes `DPC_START` again. So there is no wrapped case for this
1893 // comparison to get wrong.
1894 //
1895 // The *partially written* case cannot be decided here: its length comes from an
1896 // opcode that lives in RDRAM, which is exactly why the check below is on the far
1897 // side of the split.
1898 if self.cmd_current >= self.cmd_end {
1899 return None;
1900 }
1901 Some(NeedsBus(()))
1902 }
1903
1904 /// The remainder of a step, once [`Rdp::tick_without_bus`] has handed back a
1905 /// [`NeedsBus`].
1906 ///
1907 /// Reachable only with a [`NeedsBus`], which [`Rdp::tick_without_bus`] hands out
1908 /// exactly when the FIFO is non-empty and the pipeline is neither frozen nor
1909 /// stalled. Those preconditions are therefore not asserted here: an assertion that
1910 /// cannot fire is dead code that reads like a safeguard.
1911 ///
1912 /// The token is taken **by value**, not by reference, so it is consumed: one
1913 /// `tick_without_bus` authorizes exactly one bus half. Relaxing this to
1914 /// `&NeedsBus` would let a caller hold one and re-enter after the state it
1915 /// attested to had changed, which is the whole property being bought here.
1916 pub fn tick_with_bus<B: VideoBus>(&mut self, _proof: NeedsBus, bus: &mut B) {
1917 let word0_hi = bus.rdram_read_u32(self.cmd_current);
1918 let opcode = command::opcode_of(word0_hi);
1919 let len_bytes = command::command_len_words(opcode) * 8;
1920 // Consume a command only once it is present in full. The `rdpq`
1921 // microcode advances `DPC_END` incrementally as it fills the buffer, so
1922 // `DPC_END` can land mid-command; consuming a partially-written
1923 // multi-word primitive would decode against unwritten RDRAM. The guard
1924 // above guarantees `cmd_current < cmd_end`, so the subtraction cannot
1925 // underflow.
1926 if self.cmd_end - self.cmd_current < len_bytes {
1927 return;
1928 }
1929 // The low half of the first command word. Multi-word commands (e.g.
1930 // Texture Rectangle, 2 words) read their later words through `cmd_base`,
1931 // the command's RDRAM address captured *before* the pointer advances.
1932 let word0_lo = bus.rdram_read_u32(self.cmd_current.wrapping_add(4));
1933 let cmd_base = self.cmd_current;
1934 self.cmd_current = self.cmd_current.wrapping_add(len_bytes);
1935 self.commands_processed = self.commands_processed.wrapping_add(1);
1936 self.dispatch(opcode, word0_hi, word0_lo, cmd_base, bus);
1937 }
1938
1939 /// Act on a just-consumed command. Only the sync commands are handled so
1940 /// far; every other opcode is a recognized no-op until its handler lands.
1941 ///
1942 /// - `Sync Load`/`Pipe`/`Tile` (0x26/0x27/0x28) each stall the pipeline for
1943 /// a fixed, unconditional number of GCLK cycles (25/50/33) — the RDP waits
1944 /// the full time whether or not the sync was needed, which is why the
1945 /// stall is a constant and not a wait on an internal signal.
1946 /// - `Sync Full` (0x29) **raises the DP interrupt** (`raise_dp_interrupt`) —
1947 /// the only part of the command implemented. On hardware it first waits for
1948 /// all staged pipeline/memory work and halts the pipeline counter; neither
1949 /// is modeled (there is no asynchronous pipeline work yet, and no pipeline
1950 /// counter), so the interrupt is raised as soon as the command is
1951 /// dispatched. A *preceding* sync stall still delays this dispatch via the
1952 /// `stall` gate above (checked before a command is dispatched), so a queued
1953 /// stall drains before the interrupt fires.
1954 ///
1955 /// On stall resolution: per-command *execution* cost is not modeled yet —
1956 /// every command is consumed in a single placeholder `tick` — so the `stall`
1957 /// set here is the documented pipeline stall *layered on top of* that one
1958 /// consume tick, not a claim about total command latency (the next command
1959 /// resumes after `1 + N` ticks). The stall itself is exactly the documented
1960 /// N GCLK; exact per-command base timing is deferred to the command-timing
1961 /// model.
1962 ///
1963 /// The rectangle arms take the command's two 32-bit halves (`hi` = RDRAM bits
1964 /// 63:32, `lo` = 31:0). `Fill Rectangle` covers the rectangle ∩ scissor; what
1965 /// it writes depends on `Set Other Modes.cycle_type` — the fill register in
1966 /// FILL/COPY, the combiner output in 1-/2-cycle (ledger R-21).
1967 fn dispatch<B: VideoBus>(&mut self, opcode: u8, hi: u32, lo: u32, cmd_base: u32, bus: &mut B) {
1968 match opcode {
1969 OP_SYNC_LOAD => self.stall = SYNC_LOAD_GCLK,
1970 OP_SYNC_PIPE => self.stall = SYNC_PIPE_GCLK,
1971 OP_SYNC_TILE => self.stall = SYNC_TILE_GCLK,
1972 OP_SYNC_FULL => bus.raise_dp_interrupt(),
1973 OP_SET_COLOR_IMAGE => {
1974 // format[2:0] = hi 23:21, size[1:0] = hi 20:19, width[9:0] = hi
1975 // 9:0 (minus one), dramAddress[23:0] = lo 23:0.
1976 self.color_image_format = ((hi >> 21) & 0x7) as u8;
1977 self.color_image_size = ((hi >> 19) & 0x3) as u8;
1978 self.color_image_width = ((hi & 0x3FF) as u16).wrapping_add(1);
1979 self.color_image = lo & 0x00FF_FFFF;
1980 }
1981 OP_SET_FILL_COLOR => self.fill_color = lo,
1982 OP_SET_PRIM_COLOR => {
1983 // word 0 (hi): min_level[12:8] (the LOD magnify floor, R-13),
1984 // prim_lod_frac[7:0]; word 1 (lo): RGBA.
1985 self.prim_color = lo;
1986 self.prim_lod_frac = (hi & 0xFF) as u8;
1987 self.min_level = ((hi >> 8) & 0x1F) as u8;
1988 }
1989 OP_SET_CONVERT => {
1990 // K4 = lo[17:9], K5 = lo[8:0], stored as the raw 0..511 value the
1991 // hardware holds (matching Angrylion `rdp_set_convert`); the combiner
1992 // sign-extends them downstream (`special_expand`/`sext9`), so signing
1993 // here would double-apply. K0..K3 (the YUV-convert coefficients, in the
1994 // hi word and lo[31:18]) are deliberately ignored — deferred, R-10.
1995 self.k4 = ((lo >> 9) & 0x1FF) as i16;
1996 self.k5 = (lo & 0x1FF) as i16;
1997 }
1998 OP_SET_KEY_GB => {
1999 // word 0 (hi): width_g[23:12], width_b[11:0]; word 1 (lo):
2000 // center_g[31:24], scale_g[23:16], center_b[15:8], scale_b[7:0]
2001 // (Angrylion `rdp_set_key_gb`). Widths feed the `key_en` alpha compare.
2002 self.key_width[1] = ((hi >> 12) & 0xFFF) as u16;
2003 self.key_width[2] = (hi & 0xFFF) as u16;
2004 self.key_center[1] = (lo >> 24) as u8;
2005 self.key_scale[1] = (lo >> 16) as u8;
2006 self.key_center[2] = (lo >> 8) as u8;
2007 self.key_scale[2] = lo as u8;
2008 }
2009 OP_SET_KEY_R => {
2010 // word 1 (lo): width_r[27:16] (12-bit), center_r[15:8], scale_r[7:0]
2011 // (Angrylion `rdp_set_key_r`, `(args[1] >> 16) & 0xfff`).
2012 self.key_width[0] = ((lo >> 16) & 0xFFF) as u16;
2013 self.key_center[0] = (lo >> 8) as u8;
2014 self.key_scale[0] = lo as u8;
2015 }
2016 OP_SET_ENV_COLOR => self.env_color = lo,
2017 OP_SET_BLEND_COLOR => self.blend_color = lo,
2018 OP_SET_FOG_COLOR => self.fog_color = lo,
2019 OP_SET_DEPTH_IMAGE => self.z_image = lo & 0x00FF_FFFF,
2020 OP_SET_PRIM_DEPTH => {
2021 // z[15:0] = lo 31:16 (s15.3, integer part), dz[15:0] = lo 15:0.
2022 self.prim_z = (lo >> 16) as u16;
2023 self.prim_dz = lo as u16;
2024 }
2025 OP_SET_OTHER_MODES => self.set_other_modes(hi, lo),
2026 OP_SET_COMBINE_MODE => self.set_combine_mode(hi, lo),
2027 OP_SET_SCISSOR => {
2028 // upper-left x/y = hi 23:12 / 11:0, lower-right x/y = lo 23:12 /
2029 // 11:0 (all u10.2). The field/odd interlace bits (lo 25/24) are
2030 // not modeled yet.
2031 self.scissor_ulx = ((hi >> 12) & 0xFFF) as u16;
2032 self.scissor_uly = (hi & 0xFFF) as u16;
2033 self.scissor_lrx = ((lo >> 12) & 0xFFF) as u16;
2034 self.scissor_lry = (lo & 0xFFF) as u16;
2035 }
2036 OP_FILL_RECTANGLE => self.fill_rectangle(hi, lo, bus),
2037 OP_SET_TEXTURE_IMAGE => {
2038 // Same field layout as Set Color Image: format[55:53] = hi
2039 // 23:21, size[52:51] = hi 20:19, width[41:32] = hi 9:0 (minus
2040 // one), dramAddress[23:0] = lo 23:0.
2041 self.tex_image_format = ((hi >> 21) & 0x7) as u8;
2042 self.tex_image_size = ((hi >> 19) & 0x3) as u8;
2043 self.tex_image_width = ((hi & 0x3FF) as u16).wrapping_add(1);
2044 self.tex_image_addr = lo & 0x00FF_FFFF;
2045 }
2046 OP_SET_TILE => self.set_tile(hi, lo),
2047 OP_SET_TILE_SIZE => self.set_tile_size(hi, lo),
2048 OP_LOAD_TILE => self.load_tile(hi, lo, bus),
2049 OP_LOAD_BLOCK => self.load_block(hi, lo, bus),
2050 OP_LOAD_TLUT => self.load_tlut(hi, lo, bus),
2051 OP_TEXTURE_RECTANGLE => self.texture_rectangle(hi, lo, cmd_base, bus, false),
2052 OP_TEXTURE_RECTANGLE_FLIP => self.texture_rectangle(hi, lo, cmd_base, bus, true),
2053 // Fill Triangle and its shade/texture/Z variants (0x08–0x0F): flat-fill
2054 // the triangle for now (the shade/texture/Z coefficient blocks and the
2055 // combiner/blender come later in Sprint 3).
2056 0x08..=0x0F => self.triangle_fill(hi, lo, cmd_base, bus),
2057 // TODO(T-31-004): remaining opcodes are recognized and
2058 // length-consumed by `tick`, but not yet dispatched — an
2059 // intentional, documented no-op at this stage, not a silent discard.
2060 // Handlers arrive per ticket (VI scan-out, then texture / combiner /
2061 // blender), and `docs/rdp.md` is the authoritative list of what is
2062 // dispatched versus recognized-only, so a later missing arm is caught
2063 // against that spec rather than passing silently here.
2064 _ => {}
2065 }
2066 }
2067
2068 /// Bytes per pixel for the current color-image size, or `None` for the
2069 /// 4-bit mode, which cannot be a FILL-mode render target (it would crash the
2070 /// real RDP — N64brew *…/Commands* §Set Color Image hazards).
2071 const fn color_image_bpp(&self) -> Option<u32> {
2072 match self.color_image_size {
2073 1 => Some(1), // 8-bit
2074 2 => Some(2), // 16-bit
2075 3 => Some(4), // 32-bit
2076 _ => None, // 4-bit: crash on the real RDP
2077 }
2078 }
2079
2080 /// Render a `Fill Rectangle` (0x36) over the rectangle ∩ scissor.
2081 ///
2082 /// **What is written depends on `Set Other Modes.cycle_type`** (ledger R-21).
2083 /// FILL and COPY repeat the `Set Fill Color` register verbatim; 1-/2-cycle
2084 /// treat the rectangle as an ordinary primitive and take the **combiner**
2085 /// output, then alpha-compare and dither, exactly as the triangle path does.
2086 /// A rectangle carries no shade or texture block, so the combiner sees only
2087 /// its register inputs and the result is constant across the whole rectangle.
2088 ///
2089 /// FILL mode "repeats the 32-bit value verbatim out to memory", which
2090 /// resolves per pixel by size (N64brew *…/Commands* §Set Fill Color):
2091 /// 32-bit writes the whole color; 16-bit takes the upper half for even
2092 /// pixels and the lower half for odd; 8-bit takes byte `x & 3`. Coordinates
2093 /// are `u10.2`; FILL mode floors the upper-left and draws through the pixel
2094 /// **containing** the lower-right coordinate (inclusive), with `yl | 3` forcing
2095 /// the last scanline whole (Angrylion `rasterizer.c`). The **scissor** clips
2096 /// with an **exclusive** lower-right. This integer-coordinate rule is
2097 /// oracle-validated against Angrylion (`docs/accuracy-ledger.md` R-3, the
2098 /// seeded-fuzz corpus); the remaining residual is **sub-pixel** (fractional)
2099 /// rectangle edges, which the whole-pixel corpus does not exercise.
2100 fn fill_rectangle<B: VideoBus>(&self, hi: u32, lo: u32, bus: &mut B) {
2101 let Some(bpp) = self.color_image_bpp() else {
2102 return; // 4-bit target: the real RDP crashes; we skip.
2103 };
2104 // No color image configured yet (width is field+1, so a real Set Color
2105 // Image never yields 0). Rendering before it is a documented hazard —
2106 // the real RDP writes to an unspecified location — so we write nothing
2107 // rather than smear every row onto offset 0 with a zero stride.
2108 if self.color_image_width == 0 {
2109 return;
2110 }
2111 // Rectangle: lower-right x/y = hi 23:12 / 11:0, upper-left x/y = lo 23:12
2112 // / 11:0 (all u10.2). Floor the upper-left. The lower-right is **inclusive**
2113 // of the pixel that contains the coordinate, and in FILL/COPY mode the RDP
2114 // forces the low two bits of `yl` set before the shift (Angrylion
2115 // `rasterizer.c` `rdp_fill_rect`: `yl |= 3`, so the final scanline is filled
2116 // whole). Both confirmed against the Angrylion conformance oracle (ledger
2117 // R-3): a rect whose lower-right lands on an integer pixel boundary still
2118 // draws that pixel row/column. Convert the inclusive pixel index to a
2119 // half-open bound with `+ 1`.
2120 let rect_xh = (lo >> 12) & 0xFFF; // rect upper-left x, u10.2
2121 let rx0 = rect_xh >> 2;
2122 let ry0 = (lo & 0xFFF) >> 2;
2123 let rx1 = (((hi >> 12) & 0xFFF) >> 2) + 1;
2124 let ry1 = (((hi & 0xFFF) | 3) >> 2) + 1;
2125 // Scissor clip is **asymmetric** (ledger R-15, oracle-confirmed against
2126 // Angrylion `rasterizer.c` `edgewalker_for_prims`). The **Y** lower-right is
2127 // **exclusive** (scanlines at or past `scissor.yl` are dropped:
2128 // `invaly = k >= yllimit`) — the RDP shows this because FILL/COPY forces the
2129 // *rectangle's* `yl | 3` while the scissor's `yl` stays raw. The **X**
2130 // lower-right is **inclusive** of the pixel containing `scissor.xl`, EXCEPT
2131 // that the horizontal clip drops the whole scanline when the rectangle lies
2132 // entirely at or past the scissor's right edge (`allover`: the rect's left
2133 // edge is `>= scissor.xl`) — without that guard the inclusive bound would
2134 // spuriously draw the single boundary column when a rect starts exactly on
2135 // the scissor's right edge.
2136 let scissor_xl = u32::from(self.scissor_lrx);
2137 if rect_xh >= scissor_xl {
2138 return; // rectangle entirely right of the scissor: nothing drawn
2139 }
2140 let sx0 = u32::from(self.scissor_ulx) >> 2;
2141 let sy0 = u32::from(self.scissor_uly) >> 2;
2142 let sx1 = (scissor_xl >> 2) + 1; // inclusive right pixel -> half-open
2143 // Exclusive Y: `(lry + 3) >> 2` keeps a row iff any of its four sub-scanlines
2144 // is `< lry` (Angrylion drops `k >= yllimit`), so a fractional `lry` still
2145 // draws the partially-covered row while an integer `lry` drops it whole.
2146 let sy1 = (u32::from(self.scissor_lry) + 3) >> 2;
2147 // Intersection of rectangle and scissor (half-open), then a hard clip to the
2148 // color-image width: a pixel at or past the stride would spill into the next
2149 // row, so the inclusive X bounds never write beyond the framebuffer.
2150 let x0 = rx0.max(sx0);
2151 let y0 = ry0.max(sy0);
2152 let x1 = rx1.min(sx1).min(u32::from(self.color_image_width));
2153 let y1 = ry1.min(sy1);
2154 if x0 >= x1 || y0 >= y1 {
2155 return;
2156 }
2157 let stride = u32::from(self.color_image_width) * bpp;
2158 // **Only FILL and COPY take the fill register.** In 1-/2-cycle mode a
2159 // rectangle is an ordinary primitive and goes through the combiner; it
2160 // carries no shade or texture, so the combiner sees only its register
2161 // inputs (prim/env/…) and the fill register is never consulted. Confirmed
2162 // against the Angrylion oracle (ledger R-21, vector `fill_rect_1cycle_16`):
2163 // a 1-cycle rectangle with a green fill register and a *distinct* prim
2164 // color renders the **prim** color in all 64 pixels.
2165 if matches!(
2166 self.other_modes.cycle_type,
2167 CYCLE_TYPE_COPY | CYCLE_TYPE_FILL
2168 ) {
2169 for y in y0..y1 {
2170 let row = self.color_image.wrapping_add(y * stride);
2171 for x in x0..x1 {
2172 self.fill_pixel(row, x, bpp, bus);
2173 }
2174 }
2175 return;
2176 }
2177
2178 // 1-/2-cycle: the combiner output is **loop-invariant**. A rectangle has
2179 // no shade or texture block, so `combined_color` reads only the prim/env/
2180 // key/convert registers and the interpolation origin
2181 // (`major_x`/`line`/`y_base`/`x`) is unused — hence the zeros. Evaluating
2182 // it once per rectangle rather than once per pixel is not a speculative
2183 // optimization but a statement of that invariance: if this ever needs to
2184 // move back inside the loop, something has started varying per pixel and
2185 // the change deserves the scrutiny.
2186 let (base, _shade_alpha) = self.combined_color(None, None, 0, 0, 0, 0, 0);
2187 // Alpha-compare gates the write exactly as on the triangle path — and on
2188 // an invariant alpha it either passes for every pixel or for none, so the
2189 // whole rectangle is rejected here rather than per pixel.
2190 if !self.alpha_compare_passes(base[3]) {
2191 return;
2192 }
2193 for y in y0..y1 {
2194 let row = self.color_image.wrapping_add(y * stride);
2195 for x in x0..x1 {
2196 // Dither is the one genuinely per-pixel step, so it works on a
2197 // copy and must not mutate the shared `base`.
2198 let mut color = base;
2199 self.dither_pixel(&mut color, x, y);
2200 Self::write_pixel(row, x, bpp, color, bus);
2201 }
2202 }
2203 }
2204
2205 /// Apply a `Texture Rectangle` (0x24) / `Flip` (0x25) in **COPY mode**: blit a
2206 /// tile into the color image. Word 0 carries the screen rectangle (`u10.2`)
2207 /// and the tile; word 1 (read from `cmd_base`) carries the texture start
2208 /// (`S`/`T`, `s10.5`) and the per-pixel increments (`DsDx`/`DtDy`, `s5.10`).
2209 ///
2210 /// COPY mode is a raw texel blit — no combiner or blender. The lower-right
2211 /// screen bound is inclusive. For a 16-bit color image the texel bits are
2212 /// copied verbatim (a direct 16-bit copy). `S` steps across X and `T` down Y
2213 /// (`Flip` swaps them); the horizontal step is scaled by the 4-pixels-per-cycle
2214 /// factor (`>> (5 + dx_shift)`) so a 1:1 blit's `DsDx = 4.0` advances one texel
2215 /// per pixel.
2216 ///
2217 /// Scope (**open residual R-8**): wired for a **16-bit tile → 16-bit color
2218 /// image** (the first-picture path). `Flip`, the 8/32-bit and TLUT copy paths,
2219 /// non-1:1 sub-texel selection, and the copy alpha-compare are deferred to the
2220 /// ParaLLEl-RDP fuzz validation (Sprint 3); an unsupported configuration draws
2221 /// nothing.
2222 // The coordinate/address arithmetic casts here (screen/texel coords to `i32`
2223 // and back to `u32` offsets) wrap deliberately: a degenerate coordinate wraps
2224 // into the framebuffer/TMEM space rather than trapping. `bus` IS used mutably
2225 // (`rdram_write` in the inner loop); `needless_pass_by_ref_mut` mis-analyzes the
2226 // mutable trait call nested past the early-return guard (a known false positive).
2227 #[allow(
2228 clippy::cast_sign_loss,
2229 clippy::cast_possible_wrap,
2230 clippy::needless_pass_by_ref_mut
2231 )]
2232 fn texture_rectangle<B: VideoBus>(
2233 &mut self,
2234 hi: u32,
2235 lo: u32,
2236 cmd_base: u32,
2237 bus: &mut B,
2238 flip: bool,
2239 ) {
2240 // Word 0: screen rectangle (u10.2) + tile index.
2241 let xl = (hi >> 12) & 0xFFF;
2242 let yl = hi & 0xFFF;
2243 let tile_idx = ((lo >> 24) & 0x7) as usize;
2244 let xh = (lo >> 12) & 0xFFF;
2245 let yh = lo & 0xFFF;
2246 // Word 1: texture start (s10.5) + increments (s5.10).
2247 let w1_hi = bus.rdram_read_u32(cmd_base.wrapping_add(8));
2248 let w1_lo = bus.rdram_read_u32(cmd_base.wrapping_add(12));
2249 // S/T are signed s10.5 (a scrolled/wrapped tile can start negative), so
2250 // sign-extend like DsDx/DtDy — a plain mask would read bit 15 as +32768.
2251 let s_start = sext16(w1_hi >> 16);
2252 let t_start = sext16(w1_hi);
2253 let dsdx = sext16(w1_lo >> 16);
2254 let dtdy = sext16(w1_lo);
2255
2256 let tile = self.tiles[tile_idx];
2257 // Only the 16-bit -> 16-bit copy is wired (R-8): both the 1:1 and the non-1:1
2258 // 4-pixels-per-cycle cases now model correctly, but `Flip` (0x25), 8/32-bit,
2259 // and TLUT copy are still unsupported and draw nothing.
2260 if flip || tile.size != 2 || self.color_image_size != 2 || self.color_image_width == 0 {
2261 return;
2262 }
2263 let dx_shift = 2u32; // 4 pixels per 64-bit cycle for a 16-bit image.
2264 // Integer pixel bounds; COPY mode's lower-right is inclusive.
2265 let px0 = xh >> 2;
2266 let py0 = yh >> 2;
2267 // Clip to the scissor (floor upper-left, and the rect's inclusive lower-right).
2268 let x_lo = px0.max(u32::from(self.scissor_ulx) >> 2);
2269 let y_lo = py0.max(u32::from(self.scissor_uly) >> 2);
2270 let x_hi =
2271 (xl >> 2).min((u32::from(self.scissor_lrx).wrapping_add(3) >> 2).saturating_sub(1));
2272 let y_hi =
2273 (yl >> 2).min((u32::from(self.scissor_lry).wrapping_add(3) >> 2).saturating_sub(1));
2274 let stride = u32::from(self.color_image_width) * 2;
2275 for py in y_lo..=y_hi {
2276 let row = (py - py0) as i32;
2277 let t105 = t_start + ((dtdy * row) >> 5);
2278 let t_tex = wrap_coord(t105, tile.shift_t, tile.mask_t, tile.mirror_t, tile.tl);
2279 let swap = ((t_tex & 1) << 2) as u32;
2280 let t_row = u32::from(tile.line)
2281 .wrapping_mul(8)
2282 .wrapping_mul(t_tex as u32);
2283 let row_addr = self.color_image.wrapping_add(py.wrapping_mul(stride));
2284 for px in x_lo..=x_hi {
2285 let col = (px - px0) as i32;
2286 // COPY mode processes **4 pixels per cycle**: each cycle reads a
2287 // 64-bit TMEM word (4 consecutive 16-bit texels) and writes them to
2288 // 4 output pixels. So the base texel is evaluated at the cycle's
2289 // first column (advancing by `DsDx * 4` texels per cycle), and the
2290 // within-cycle offset is a direct `+0..3` TMEM increment — NOT a
2291 // per-pixel coordinate step (N64brew *…/Commands* §Texture Rectangle
2292 // copy; ledger R-8). For `DsDx = 4.0` (1:1) this reduces to `s = col`.
2293 let cycle_start = col & !3;
2294 let within = col & 3;
2295 let base_s105 = s_start + ((dsdx * cycle_start) >> (5 + dx_shift));
2296 let base_tex =
2297 wrap_coord(base_s105, tile.shift_s, tile.mask_s, tile.mirror_s, tile.sl);
2298 let s_tex = base_tex + within;
2299 // Raw 16-bit texel fetch (RGBA16 addressing, no decode).
2300 let boff = (u32::from(tile.tmem_addr).wrapping_mul(8))
2301 .wrapping_add(t_row)
2302 .wrapping_add((s_tex as u32).wrapping_mul(2))
2303 ^ swap;
2304 let texel = self.tmem_u16(boff);
2305 let addr = row_addr.wrapping_add(px.wrapping_mul(2));
2306 bus.rdram_write(addr, (texel >> 8) as u8);
2307 bus.rdram_write(addr.wrapping_add(1), (texel & 0xFF) as u8);
2308 }
2309 }
2310 }
2311
2312 /// Write the FILL-mode color to one pixel of the color image (shared by the
2313 /// fill rectangle and the flat-fill triangle). `bpp` is 1/2/4; the 16-bit case
2314 /// takes the upper half of the fill register for even `x` and the lower for
2315 /// odd, and the 8-bit case cycles the four bytes — as `Set Fill Color` defines.
2316 fn fill_pixel<B: VideoBus>(&self, row_addr: u32, x: u32, bpp: u32, bus: &mut B) {
2317 let addr = row_addr.wrapping_add(x.wrapping_mul(bpp));
2318 let color = self.fill_color.to_be_bytes();
2319 match bpp {
2320 4 => {
2321 for (i, b) in color.iter().enumerate() {
2322 bus.rdram_write(addr.wrapping_add(i as u32), *b);
2323 }
2324 }
2325 2 => {
2326 let half = if x & 1 == 0 { 0 } else { 2 };
2327 bus.rdram_write(addr, color[half]);
2328 bus.rdram_write(addr.wrapping_add(1), color[half + 1]);
2329 }
2330 1 => bus.rdram_write(addr, color[(x & 3) as usize]),
2331 _ => {}
2332 }
2333 }
2334
2335 /// Flat-fill a `Fill Triangle` (0x08) or one of its shade/texture/Z variants
2336 /// (0x09–0x0F). Decode the three edges (major `H` yh→yl, minor `M` yh→ym, minor
2337 /// `L` ym→yl), walk each scanline's span between the major edge and the active
2338 /// minor edge, and write the FILL-mode color into the span — the FILL-cycle
2339 /// path (a 1-/2-cycle triangle is colored by the combiner/blender, which is
2340 /// later in Sprint 3, so the shade/texture/Z coefficient words are ignored
2341 /// here, only length-consumed).
2342 ///
2343 /// Y is `s11.2` (four sub-scanlines per pixel); X and the slopes are `s11.16` /
2344 /// `s13.16`. Per sub-scanline the edge X is `x0 + (y − yh_base) * slope`,
2345 /// reduced to a whole pixel (`>> 16`); `lmajor`/`flip` (bit 55) selects which
2346 /// edge is the left bound. Matched to the ParaLLEl-RDP `interpolate_x` walk
2347 /// (native scaling — no upscale sub-pixel bit); the bit-exact sub-pixel
2348 /// coverage (`quantize_x` sticky bit) and attribute interpolation are deferred
2349 /// to the fuzz-validated pipeline (**open residual R-9**).
2350 // `bus` IS used mutably (`fill_pixel` → `rdram_write`); the lint mis-analyzes the
2351 // call nested past the early returns. `xl`/`xh`/`xm` etc. are the hardware edge names.
2352 #[allow(
2353 clippy::cast_sign_loss,
2354 clippy::cast_possible_truncation,
2355 clippy::needless_pass_by_ref_mut,
2356 clippy::similar_names
2357 )]
2358 #[allow(
2359 clippy::too_many_lines,
2360 reason = "the rasterizer's edge decode, span walk, and the flat/shaded/depth render paths are one tightly-coupled unit; splitting further would fragment the shared setup"
2361 )]
2362 fn triangle_fill<B: VideoBus>(&mut self, hi: u32, lo: u32, cmd_base: u32, bus: &mut B) {
2363 let Some(bpp) = self.color_image_bpp() else {
2364 return;
2365 };
2366 if self.color_image_width == 0 {
2367 return;
2368 }
2369 let flip = hi >> 23 & 1 != 0;
2370 // The primitive's base tile (bits 50:48 of the command = `hi` bits 18:16;
2371 // `(ewdata[0] >> 16) & 7` in Angrylion `rasterizer.c`). Selects the tile
2372 // descriptor the texture sampler reads (and `base+1` in 2-cycle mode).
2373 let base_tile = ((hi >> 16) & 7) as usize;
2374 // `level[2:0]` (bits 53:51): the mip-level count, which the LOD fraction
2375 // reads (`max_level == 0` means "no mip chain", so the LOD is fully
2376 // distant). Per-primitive state, as in Angrylion `rasterizer.c`. R-13.
2377 self.max_level = ((hi >> 19) & 7) as u8;
2378 let yl = sext(hi & 0x3FFF, 14);
2379 let mut ym = sext(lo >> 16 & 0x3FFF, 14);
2380 let yh = sext(lo & 0x3FFF, 14);
2381 if yl <= yh {
2382 return; // degenerate
2383 }
2384 // The triangle setup guarantees yh <= ym <= yl (sorted vertices); clamp
2385 // malformed input into range so the M/L edge split stays well-defined.
2386 ym = ym.clamp(yh, yl);
2387 // Edge coefficients: words 1 (L), 2 (H major), 3 (M).
2388 //
2389 // The slopes `dx?dy` are `s13.16` **dx per pixel-row** (N64brew *…/Commands*
2390 // §Edge Coefficients: "change in x per change in y", with `yh/ym/yl` in
2391 // `s11.2` *screen* pixels). The edge-walk below advances the edge per
2392 // Y-subpixel — `y = line*4 + sub` is in **quarter-pixel** units — so each
2393 // slope is pre-shifted `>> SUB_SCANLINE_SHIFT` to a per-quarter-pixel step
2394 // (parallel-rdp `span_setup.comp:167`, where `setup.dxhdy = raw >> 2`).
2395 // Omitting this advanced every edge 4× too fast (ledger R-14, caught by the
2396 // T-33-005 conformance gate against Angrylion). The arithmetic shift rounds
2397 // a negative slope toward −∞, matching the hardware (`fill_tri_neg_16`).
2398 let xl = sext(
2399 bus.rdram_read_u32(cmd_base.wrapping_add(8)) & 0x0FFF_FFFF,
2400 28,
2401 );
2402 let dxldy = sext(
2403 bus.rdram_read_u32(cmd_base.wrapping_add(12)) & 0x3FFF_FFFF,
2404 30,
2405 ) >> SUB_SCANLINE_SHIFT;
2406 let xh = sext(
2407 bus.rdram_read_u32(cmd_base.wrapping_add(16)) & 0x0FFF_FFFF,
2408 28,
2409 );
2410 let dxhdy = sext(
2411 bus.rdram_read_u32(cmd_base.wrapping_add(20)) & 0x3FFF_FFFF,
2412 30,
2413 ) >> SUB_SCANLINE_SHIFT;
2414 let xm = sext(
2415 bus.rdram_read_u32(cmd_base.wrapping_add(24)) & 0x0FFF_FFFF,
2416 28,
2417 );
2418 let dxmdy = sext(
2419 bus.rdram_read_u32(cmd_base.wrapping_add(28)) & 0x3FFF_FFFF,
2420 30,
2421 ) >> SUB_SCANLINE_SHIFT;
2422
2423 // Scissor in integer pixels (u10.2 -> pixel).
2424 let sx0 = i32::from(self.scissor_ulx) >> 2;
2425 let sx1 = i32::from(self.scissor_lrx) >> 2;
2426 let sy0 = i32::from(self.scissor_uly) >> 2;
2427 let sy1 = i32::from(self.scissor_lry) >> 2;
2428 let start_line = (yh >> 2).max(sy0);
2429 let end_line = ((yl - 1) >> 2).min(sy1 - 1);
2430 let yh_base = yh & !3;
2431 let width = i32::from(self.color_image_width);
2432 let stride = (width as u32).wrapping_mul(bpp);
2433
2434 // Per-pixel depth path (T-33-004 PR-B part 2a): active when the command
2435 // carries z-coefficients (bit 56, the opcode's low bit) and `Set Other
2436 // Modes` enables the depth test or update. The z-suffix follows the 4-word
2437 // base plus the shade (bit 58) and texture (bit 57) blocks, in that order.
2438 let z_setup = if self.other_modes.z_compare_en || self.other_modes.z_update_en {
2439 Self::decode_triangle_z(hi, cmd_base, bus)
2440 } else {
2441 None
2442 };
2443 // Shade block (bit 58): when present, the pixel color comes from the
2444 // combiner fed the interpolated shade, not the FILL register (T-33-004
2445 // PR-B 2b — the first shaded triangle).
2446 let shade_setup = Self::decode_shade(hi, cmd_base, bus);
2447 // Texture block (bit 57): the combiner samples tile 0 at the interpolated
2448 // (non-perspective) coordinate. The perspective divide is a later slice.
2449 let tex_setup = Self::decode_texture(hi, cmd_base, bus);
2450 let has_color = shade_setup.is_some() || tex_setup.is_some();
2451 let y_base = yh >> 2;
2452
2453 // 1-/2-cycle mode rasterizes with sub-pixel coverage; FILL/COPY mode
2454 // (`cycle_type >= 2`) rounds to whole pixels (N64brew *…/Commands*: FILL is
2455 // "without subpixel accuracy"), which the union span already models exactly.
2456 let subpixel = self.other_modes.cycle_type < 2;
2457 // Sub-pixel scissor bounds (`s.3`): the raw `s10.2` scissor is one fraction
2458 // bit narrower, so `<< 1` lifts it (parallel-rdp `span_setup.comp:196`).
2459 let sc_lo = i32::from(self.scissor_ulx) << 1;
2460 let sc_hi = i32::from(self.scissor_lrx) << 1;
2461 for line in start_line..=end_line {
2462 let mut span_l = i32::MAX;
2463 let mut span_r = i32::MIN;
2464 // Per-Y-subpixel `s.3` edges for the sub-pixel coverage path.
2465 let mut xleft = [SPAN_X_POISON_LEFT; COVERAGE_SUBPIXELS];
2466 let mut xright = [SPAN_X_POISON_RIGHT; COVERAGE_SUBPIXELS];
2467 for sub in 0..4 {
2468 let y = line * 4 + sub;
2469 if y < yh || y >= yl {
2470 continue;
2471 }
2472 let major = i64::from(xh) + i64::from(y - yh_base) * i64::from(dxhdy);
2473 let minor = if y < ym {
2474 i64::from(xm) + i64::from(y - yh_base) * i64::from(dxmdy)
2475 } else {
2476 i64::from(xl) + i64::from(y - ym) * i64::from(dxldy)
2477 };
2478 // Whole-pixel edges for the union span (distinct from the scanline
2479 // interpolation origin `major_x` computed after the sub-loop).
2480 let major_px = (major >> 16) as i32;
2481 let minor_px = (minor >> 16) as i32;
2482 let (xl_i, xr_i) = if flip {
2483 (major_px, minor_px)
2484 } else {
2485 (minor_px, major_px)
2486 };
2487 if xl_i > xr_i {
2488 continue;
2489 }
2490 // The union bounding span (FILL / COPY / depth paths).
2491 span_l = span_l.min(xl_i);
2492 span_r = span_r.max(xr_i);
2493 // Sub-pixel edges (`s.3`, sticky-bit snapped) for the coverage path:
2494 // quantize the `s.16` major/minor and clamp to the scissor.
2495 let (raw_l, raw_r) = if flip { (major, minor) } else { (minor, major) };
2496 #[allow(clippy::cast_possible_truncation)]
2497 let el = quantize_x(sext(raw_l as u32, 27)).clamp(sc_lo, sc_hi);
2498 #[allow(clippy::cast_possible_truncation)]
2499 let er = quantize_x(sext(raw_r as u32, 27)).clamp(sc_lo, sc_hi);
2500 if (el >> 1) <= (er >> 1) {
2501 xleft[sub as usize] = el;
2502 xright[sub as usize] = er;
2503 }
2504 }
2505 let (x0, x1) = if subpixel {
2506 (
2507 (min4(&xleft) >> 3).max(0),
2508 (max4(&xright) >> 3).min(width - 1),
2509 )
2510 } else {
2511 (span_l.max(sx0).max(0), span_r.min(sx1).min(width - 1))
2512 };
2513 if x0 > x1 {
2514 continue;
2515 }
2516 let row_addr = self
2517 .color_image
2518 .wrapping_add((line as u32).wrapping_mul(stride));
2519 // The major-edge x at this scanline (s15.16), the interpolation origin
2520 // shared by the depth and shade interpolators.
2521 let major_x = i64::from(xh) + i64::from(line * 4 - yh_base) * i64::from(dxhdy);
2522 if let Some(z) = z_setup {
2523 let cov = if subpixel {
2524 Some((&xleft, &xright))
2525 } else {
2526 None
2527 };
2528 self.depth_span(
2529 row_addr,
2530 x0,
2531 x1,
2532 line,
2533 bpp,
2534 major_x,
2535 y_base,
2536 &z,
2537 cov,
2538 shade_setup.as_ref(),
2539 tex_setup.as_ref(),
2540 base_tile,
2541 bus,
2542 );
2543 } else if has_color {
2544 // The no-Z path (a triangle with no z-suffix): the combiner color.
2545 // In 1-/2-cycle mode each pixel's sub-pixel coverage gates the write
2546 // and is stored in the pixel alpha (the AA/`cvg_dest` write-back); the
2547 // memory-read blender still lives only on the depth path (R-9/R-11).
2548 #[allow(clippy::cast_sign_loss, reason = "x >= 0 within a clipped span")]
2549 for x in x0..=x1 {
2550 let (mut color, _shade_alpha) = self.combined_color(
2551 shade_setup.as_ref(),
2552 tex_setup.as_ref(),
2553 base_tile,
2554 major_x,
2555 line,
2556 y_base,
2557 x,
2558 );
2559 // Alpha-compare (Set Other Modes bit 0) gates the write on the
2560 // combiner output alpha, evaluated BEFORE coverage overwrites the
2561 // alpha byte.
2562 if !self.alpha_compare_passes(color[3]) {
2563 continue;
2564 }
2565 if subpixel {
2566 match self.pixel_coverage(xleft, xright, x) {
2567 Some(cov) => color[3] = cov << 5,
2568 None => continue,
2569 }
2570 }
2571 self.dither_pixel(&mut color, x as u32, line as u32);
2572 Self::write_pixel(row_addr, x as u32, bpp, color, bus);
2573 }
2574 } else {
2575 #[allow(clippy::cast_sign_loss, reason = "x >= 0 within a clipped span")]
2576 for x in x0..=x1 {
2577 self.fill_pixel(row_addr, x as u32, bpp, bus);
2578 }
2579 }
2580 }
2581 }
2582
2583 /// Decode the z-coefficient suffix of a `Fill Triangle` — present when bit 56
2584 /// (the opcode's low bit) is set. The suffix follows the 4-word base plus the
2585 /// shade (bit 58, +8 words) and texture (bit 57, +8 words) blocks, in that
2586 /// order; each field is `s15.16`. Returns `None` when there is no z-suffix.
2587 #[allow(
2588 clippy::cast_possible_wrap,
2589 reason = "the s15.16 z-coefficients are the raw command bits reinterpreted as signed"
2590 )]
2591 #[allow(
2592 clippy::similar_names,
2593 reason = "dzdx / dzde are the N64 RDP's own z-coefficient names"
2594 )]
2595 fn decode_triangle_z<B: VideoBus>(hi: u32, cmd_base: u32, bus: &B) -> Option<ZTriSetup> {
2596 if (hi >> 24) & 1 == 0 {
2597 return None;
2598 }
2599 let has_shade = (hi >> 26) & 1 != 0;
2600 let has_tex = (hi >> 25) & 1 != 0;
2601 let zoff = (4 + 8 * u32::from(has_shade) + 8 * u32::from(has_tex)) * 8;
2602 let za = cmd_base.wrapping_add(zoff);
2603 let z_base = bus.rdram_read_u32(za) as i32;
2604 let dzdx = bus.rdram_read_u32(za.wrapping_add(4)) as i32;
2605 let dzde = bus.rdram_read_u32(za.wrapping_add(8)) as i32;
2606 // `dzdy` (za + 12) is the 4th z-suffix word; it feeds only the sub-pixel
2607 // snap (part 2c), so the per-scanline path here uses `dzde` and leaves it.
2608 //
2609 // Primitive dz for the stored value and the test tolerance: the integer
2610 // depth gradient (first cut — the exact setup derivation is R-9/R-12).
2611 // `saturating_abs` avoids the `i32::MIN.abs()` overflow panic on the
2612 // unvalidated RDRAM coefficients.
2613 let dz = dzdx.saturating_abs().max(dzde.saturating_abs()) >> 16;
2614 Some(ZTriSetup {
2615 z_base,
2616 dzdx,
2617 dzde,
2618 dz,
2619 dz_compressed: dz_compress(dz).min(0xf),
2620 })
2621 }
2622
2623 /// Decode the 8-word shade coefficient block of a `Fill Triangle` (present when
2624 /// bit 58 is set) into [`ShadeSetup`]. It follows the 4-word base immediately
2625 /// (before the texture/z blocks). Per channel the value is `s15.16`: the base's
2626 /// int part is 9-bit signed, the deltas' int parts 16-bit (N64brew *…/Commands*
2627 /// §Fill Shaded Triangle). Returns `None` when there is no shade block.
2628 #[allow(
2629 clippy::cast_possible_wrap,
2630 reason = "the s15.16 delta is the raw (int << 16 | frac) bits reinterpreted as signed"
2631 )]
2632 fn decode_shade<B: VideoBus>(hi: u32, cmd_base: u32, bus: &B) -> Option<ShadeSetup> {
2633 if (hi >> 26) & 1 == 0 {
2634 return None;
2635 }
2636 let sa = cmd_base.wrapping_add(4 * 8);
2637 let w = |word: u32| bus.rdram_read_u32(sa.wrapping_add(word * 4));
2638 // Words: 0 int-base, 1 dx-int, 2 frac-base, 3 dx-frac, 4 de-int, 5 dy-int,
2639 // 6 de-frac, 7 dy-frac. Each 64-bit word packs R/G/B/A; read as two u32
2640 // halves — channels 0/1 (R/G) in the hi u32, 2/3 (B/A) in the lo. Within a
2641 // u32, the even channel (R/B) is the high 16 bits, the odd (G/A) the low.
2642 let u32_index = |base_word: u32, ch: usize| base_word * 2 + (ch >> 1) as u32;
2643 let field = |base_word: u32, ch: usize, mask: u32| {
2644 let shift = 16 * (1 - (ch & 1) as u32);
2645 (w(u32_index(base_word, ch)) >> shift) & mask
2646 };
2647 let mut shade = ShadeSetup::default();
2648 for ch in 0..4 {
2649 // Base: 9-bit int (word 0) + 16-bit frac (word 2) -> a 25-bit s(9).16.
2650 let i9 = field(0, ch, 0x1FF);
2651 let bf = field(2, ch, 0xFFFF);
2652 shade.base[ch] = sext((i9 << 16) | bf, 25);
2653 // Deltas: 16-bit int + 16-bit frac -> a full s15.16, reinterpreted.
2654 let assemble =
2655 |iw: u32, fw: u32| ((field(iw, ch, 0xFFFF) << 16) | field(fw, ch, 0xFFFF)) as i32;
2656 shade.dx[ch] = assemble(1, 3);
2657 shade.de[ch] = assemble(4, 6);
2658 }
2659 Some(shade)
2660 }
2661
2662 /// Write one RGBA8888 pixel to the color image at `(row_addr, x)`: direct for a
2663 /// 32-bit image, packed to RGBA5551 for a 16-bit one (matching `fill_pixel`'s
2664 /// addressing). Other sizes are unsupported and write nothing.
2665 fn write_pixel<B: VideoBus>(row_addr: u32, x: u32, bpp: u32, rgba: [u8; 4], bus: &mut B) {
2666 let addr = row_addr.wrapping_add(x.wrapping_mul(bpp));
2667 match bpp {
2668 4 => {
2669 for (i, b) in rgba.iter().enumerate() {
2670 bus.rdram_write(addr.wrapping_add(i as u32), *b);
2671 }
2672 }
2673 2 => {
2674 let p = pack_rgba5551(rgba).to_be_bytes();
2675 bus.rdram_write(addr, p[0]);
2676 bus.rdram_write(addr.wrapping_add(1), p[1]);
2677 }
2678 _ => {}
2679 }
2680 }
2681
2682 /// Read the current color-image pixel at `(row_addr, x)` as RGBA8888 — the
2683 /// blender's `memory_color`. The inverse of [`Self::write_pixel`]: direct for a
2684 /// 32-bit image, RGBA5551 widened (5→8 bits) for a 16-bit one.
2685 ///
2686 /// An 8-bit color image (`bpp == 1`, a legal but unsupported render target —
2687 /// [`Self::color_image_bpp`] returns `Some(1)` for it) reads as transparent
2688 /// black, mirroring [`Self::write_pixel`]'s silent no-op for the same size (the
2689 /// blended result is discarded there anyway, so no draw happens either way). It
2690 /// stays a graceful default rather than a panic because `bpp` derives from a
2691 /// `Set Color Image` field under ROM control (module 60: never panic on external
2692 /// input, and a `debug_assert` would fire on that legal configuration).
2693 fn read_pixel<B: VideoBus>(row_addr: u32, x: u32, bpp: u32, bus: &B) -> [u8; 4] {
2694 let addr = row_addr.wrapping_add(x.wrapping_mul(bpp));
2695 match bpp {
2696 4 => [
2697 bus.rdram_read(addr),
2698 bus.rdram_read(addr.wrapping_add(1)),
2699 bus.rdram_read(addr.wrapping_add(2)),
2700 bus.rdram_read(addr.wrapping_add(3)),
2701 ],
2702 2 => {
2703 let p = u16::from_be_bytes([
2704 bus.rdram_read(addr),
2705 bus.rdram_read(addr.wrapping_add(1)),
2706 ]);
2707 unpack_rgba5551(p)
2708 }
2709 // 8-bit (bpp 1) and any other size: transparent black, as documented above.
2710 _ => [0; 4],
2711 }
2712 }
2713
2714 /// Decode the 8-word texture coefficient block of a `Fill Triangle` (present
2715 /// when bit 57 is set) into [`TexSetup`]. It follows the 4-word base plus the
2716 /// shade block (if bit 58 set), before the z block. Each word packs `S`/`T`/`W`
2717 /// into bits 63:48/47:32/31:16 (`s16.16`); this slice keeps only `S`/`T` (both
2718 /// in the hi u32) — the `W` perspective term is the deferred perspective slice.
2719 #[allow(
2720 clippy::cast_possible_wrap,
2721 reason = "the s16.16 coordinate is the raw (int << 16 | frac) bits reinterpreted"
2722 )]
2723 fn decode_texture<B: VideoBus>(hi: u32, cmd_base: u32, bus: &B) -> Option<TexSetup> {
2724 if (hi >> 25) & 1 == 0 {
2725 return None;
2726 }
2727 let has_shade = (hi >> 26) & 1;
2728 let ta = cmd_base.wrapping_add((4 + 8 * has_shade) * 8);
2729 let w = |word: u32| bus.rdram_read_u32(ta.wrapping_add(word * 4));
2730 // Each 64-bit word packs S (bits 63:48), T (47:32), W (31:16): S and T are in
2731 // the hi u32 (shift 16 / 0), W in the lo u32 (shift 16). `c` = 0 S, 1 T, 2 W.
2732 let field = |base_word: u32, c: usize| {
2733 let u32_off = u32::from(c == 2);
2734 let shift = if c == 1 { 0 } else { 16 };
2735 (w(base_word * 2 + u32_off) >> shift) & 0xFFFF
2736 };
2737 let assemble = |iw: u32, fw: u32, c: usize| ((field(iw, c) << 16) | field(fw, c)) as i32;
2738 let mut tex = TexSetup::default();
2739 for c in 0..3 {
2740 tex.base[c] = assemble(0, 2, c); // int word 0, frac word 2
2741 tex.dx[c] = assemble(1, 3, c); // per-x
2742 tex.de[c] = assemble(4, 6, c); // per-major-edge
2743 tex.dy[c] = assemble(5, 7, c); // per-y (LOD only)
2744 }
2745 Some(tex)
2746 }
2747
2748 /// Sample `tile` at the raw `s10.5` coordinate `(s105, t105)` — point or the
2749 /// 3-point bilinear filter per `Set Other Modes.sample_type` (R-13). Point
2750 /// applies the tile transform ([`sample_coord`]) and fetches one texel;
2751 /// bilinear runs [`sample_axis`] per axis for `(base, frac)`, fetches the four
2752 /// texels at `(s,t)/(s+1,t)/(s,t+1)/(s+1,t+1)`, and blends with
2753 /// [`bilinear_3point`]. The four texels share the one base clamp/mask; the exact
2754 /// mask-wrap-seam `sdiff`/`tdiff` (`0`/`-1` at the wrap edge) is deferred — the
2755 /// neighbor is the base plus one, correct except across a mask seam.
2756 #[allow(
2757 clippy::cast_sign_loss,
2758 clippy::cast_possible_wrap,
2759 reason = "fetch_texel masks the coordinate into the 4 KiB TMEM space, as the point path does"
2760 )]
2761 fn sample_texel(&self, tile: &TileDescriptor, s105: i32, t105: i32) -> [u8; 4] {
2762 if !self.other_modes.sample_type {
2763 let s = sample_coord(
2764 s105,
2765 tile.shift_s,
2766 tile.mask_s,
2767 tile.mirror_s,
2768 tile.clamp_s,
2769 tile.sl,
2770 tile.sh,
2771 );
2772 let t = sample_coord(
2773 t105,
2774 tile.shift_t,
2775 tile.mask_t,
2776 tile.mirror_t,
2777 tile.clamp_t,
2778 tile.tl,
2779 tile.th,
2780 );
2781 return self.fetch_texel(tile, s, t);
2782 }
2783 let (sb, sfrac, sdiff) = sample_axis(
2784 s105,
2785 tile.shift_s,
2786 tile.mask_s,
2787 tile.mirror_s,
2788 tile.clamp_s,
2789 tile.sl,
2790 tile.sh,
2791 false,
2792 );
2793 let (tb, tfrac, tdiff) = sample_axis(
2794 t105,
2795 tile.shift_t,
2796 tile.mask_t,
2797 tile.mirror_t,
2798 tile.clamp_t,
2799 tile.tl,
2800 tile.th,
2801 true,
2802 );
2803 // The neighbor is the masked base plus the mask-coupled diff (+1 / 0 / -1 /
2804 // wrap), NOT re-masked — `mask_coupled` chose `diff` for exactly this. The
2805 // diff keeps the neighbor non-negative (the -1 case only fires when base>=1,
2806 // the wrap case lands on 0), so the `as u32` never wraps to a huge value.
2807 debug_assert!(
2808 sb + sdiff >= 0 && tb + tdiff >= 0,
2809 "neighbor texel is non-negative"
2810 );
2811 let (s0, t0) = (sb as u32, tb as u32);
2812 let (s1, t1) = ((sb + sdiff) as u32, (tb + tdiff) as u32);
2813 bilinear_3point(
2814 self.fetch_texel(tile, s0, t0),
2815 self.fetch_texel(tile, s1, t0),
2816 self.fetch_texel(tile, s0, t1),
2817 self.fetch_texel(tile, s1, t1),
2818 sfrac,
2819 tfrac,
2820 self.other_modes.mid_texel,
2821 )
2822 }
2823
2824 /// Compute a pixel's color through the combiner from the interpolated shade
2825 /// and/or sampled texel (plus the prim/env registers). `shade`/`tex` are the
2826 /// decoded setups, `None` when that attribute is absent. The two-cycle mode
2827 /// comes from `Set Other Modes`. Texture uses the interpolated coordinate
2828 /// (`interpolate_st`, perspective-correct when `persp_tex_en` is set) sampled
2829 /// from `base_tile` (and `base_tile + 1` in 2-cycle).
2830 ///
2831 /// Returns the combiner output **and** the interpolated shade alpha, which the
2832 /// blender selects separately (`A`-select 2) from the combiner output alpha
2833 /// (`A`-select 0) — the two differ whenever the alpha combiner transforms the
2834 /// shade alpha. Shade alpha is `0` when the triangle carries no shade block.
2835 #[allow(
2836 clippy::too_many_arguments,
2837 reason = "per-pixel combiner inputs: setups, base tile, and interpolation origin"
2838 )]
2839 fn combined_color(
2840 &self,
2841 shade: Option<&ShadeSetup>,
2842 tex: Option<&TexSetup>,
2843 base_tile: usize,
2844 major_x: i64,
2845 line: i32,
2846 y_base: i32,
2847 x: i32,
2848 ) -> ([u8; 4], u8) {
2849 let mut inp = CombinerInputs {
2850 prim: unpack_rgba(self.prim_color),
2851 env: unpack_rgba(self.env_color),
2852 // R-10 register-sourced exotic combiner inputs: prim-LOD-frac, the Set
2853 // Convert K4/K5, and the chroma-key center/scale. Still deferred (read as
2854 // zero): noise, the derivative-computed lod_frac, and the YUV K0..K3 convert.
2855 prim_lod_frac: i16::from(self.prim_lod_frac),
2856 k4: self.k4,
2857 k5: self.k5,
2858 key_center: self.key_center,
2859 key_scale: self.key_scale,
2860 ..CombinerInputs::default()
2861 };
2862 if let Some(shade) = shade {
2863 inp.shade =
2864 interpolate_shade(&shade.base, &shade.dx, &shade.de, major_x, line, y_base, x);
2865 }
2866 if let Some(tex) = tex {
2867 let persp = self.other_modes.persp_tex_en;
2868 let stw = interpolate_stw_raw(tex, major_x, line, y_base, x);
2869 let [s105, t105] = divide_stw(stw, persp);
2870 // The derivative-computed LOD fraction (R-13). Gated two ways: on
2871 // Angrylion's `dolod` (something actually consumes the fraction), so
2872 // the common path pays neither the two extra perspective divides nor
2873 // any behavior change; and on **2-cycle** mode, because only the
2874 // 2-cycle LOD form is modeled — the 1-cycle form needs span-edge
2875 // signals the rasterizer does not have, so it stays deferred and
2876 // reads zero rather than being approximated with the wrong formula.
2877 // The primitive's base tile comes from the triangle command (bits 50:48,
2878 // `(ewdata[0] >> 16) & 7` — Angrylion `rasterizer.c`); the sampler reads
2879 // that descriptor, not a hardwired tile 0. In 2-cycle mode the second
2880 // texel comes from the next tile (the `RENDERTILE`/`RENDERTILE+1` case),
2881 // and `combine` swaps texel0/texel1 for the second cycle so cycle 1's
2882 // TEXEL0 reads it.
2883 let (mut tile0, mut tile1) = (base_tile, (base_tile + 1) & 7);
2884 if self.other_modes.cycle_type == CYCLE_TYPE_2CYCLE && self.lod_active() {
2885 // The 2-cycle LOD (R-13). Gated on Angrylion's `dolod` — something
2886 // consumes it — so the common path pays neither the two extra
2887 // perspective divides nor any behavior change; and on 2-cycle mode,
2888 // because only the 2-cycle LOD form is modeled (the 1-cycle form
2889 // needs span-edge signals the rasterizer does not have, so it stays
2890 // deferred rather than being approximated with the wrong formula).
2891 let sig = self.lod_2cycle(tex, stw, persp, [s105, t105]);
2892 inp.lod_frac = sig.frac;
2893 // With `tex_lod_en` the LOD also picks the mip pair to sample.
2894 if self.other_modes.tex_lod_en {
2895 (tile0, tile1) = lod_mip_tiles(
2896 base_tile,
2897 sig,
2898 self.max_level,
2899 self.other_modes.sharpen_tex_en,
2900 self.other_modes.detail_tex_en,
2901 );
2902 }
2903 }
2904 inp.texel0 = self.sample_texel(&self.tiles[tile0], s105, t105);
2905 if self.other_modes.cycle_type == CYCLE_TYPE_2CYCLE {
2906 inp.texel1 = self.sample_texel(&self.tiles[tile1], s105, t105);
2907 }
2908 }
2909 let shade_alpha = inp.shade[3];
2910 (
2911 self.combine(inp, self.other_modes.cycle_type == CYCLE_TYPE_2CYCLE),
2912 shade_alpha,
2913 )
2914 }
2915
2916 /// Whether the LOD fraction has to be computed for this primitive — the
2917 /// Angrylion `other_modes.f.dolod` gate (`rdp.c`): either `tex_lod_en` is on,
2918 /// or the combiner actually selects `LODFrac` as a mul input (RGB select 13 /
2919 /// alpha select 0) in either cycle. When false the fraction is unread, so
2920 /// skipping it is behavior-preserving as well as cheaper.
2921 fn lod_active(&self) -> bool {
2922 let uses = |c: &CombineCycle| c.rgb_c == 13 || c.a_c == LOD_FRAC_ALPHA_MUL_SELECT;
2923 self.other_modes.tex_lod_en || uses(&self.combine.cyc0) || uses(&self.combine.cyc1)
2924 }
2925
2926 /// The 2-cycle LOD fraction — a port of Angrylion `tclod_2cycle` (`tcoord.c`)
2927 /// down to its `lf` output.
2928 ///
2929 /// The LOD is the larger of the texture-coordinate deltas to the next pixel in
2930 /// **x** (`stw + dsdx`) and the next scanline in **y** (`stw + dsdy`, the true
2931 /// vertical gradient — *not* the major-edge `de` the scanline walk uses), each
2932 /// taken through the same perspective divide as the pixel's own coordinate.
2933 /// A coordinate with bits 18:17 set clamps the LOD to its maximum.
2934 ///
2935 /// Scope: this is the **2-cycle** form. The 1-cycle form
2936 /// (`tclod_1cycle_current_simple`) differs — it compares the `x+1` and `x+2`
2937 /// taps and needs the span-edge signals (`endspan`/`longspan`/`midspan`,
2938 /// `validline`) that the rasterizer does not model — so it stays deferred
2939 /// under R-13 rather than being approximated with the 2-cycle formula.
2940 #[allow(
2941 clippy::similar_names,
2942 reason = "nexts/nextt and nextys/nextyt are Angrylion's own names for the two LOD taps"
2943 )]
2944 fn lod_2cycle(&self, tex: &TexSetup, stw: [i32; 3], persp: bool, init: [i32; 2]) -> LodSignals {
2945 // Step the raw accumulator by a derivative, then divide exactly as the
2946 // pixel's own coordinate was divided.
2947 let step = |d: [i32; 3]| {
2948 let mut n = stw;
2949 for (o, dv) in n.iter_mut().zip(d) {
2950 *o = o.wrapping_add(dv);
2951 }
2952 divide_stw(n, persp)
2953 };
2954 // The hardware truncates each derivative before use: `dsdx & ~0x1f`,
2955 // `dsdy & ~0x7fff` (Angrylion `spans_ds` / `spans_dsdy`).
2956 let dx = [tex.dx[0] & !0x1F, tex.dx[1] & !0x1F, tex.dx[2] & !0x1F];
2957 let dy = [
2958 tex.dy[0] & !0x7FFF,
2959 tex.dy[1] & !0x7FFF,
2960 tex.dy[2] & !0x7FFF,
2961 ];
2962 let [nexts, nextt] = step(dx);
2963 let [nextys, nextyt] = step(dy);
2964 let lodclamp = (init[0] | init[1] | nexts | nextt | nextys | nextyt) & 0x6_0000 != 0;
2965 let mut lod = 0;
2966 if !lodclamp {
2967 lod = lod_delta(init[0], nexts, init[1], nextt, 0);
2968 lod = lod_delta(init[0], nextys, init[1], nextyt, lod);
2969 }
2970 lod_signals(
2971 lodclamp,
2972 lod,
2973 self.min_level,
2974 self.max_level,
2975 self.other_modes.sharpen_tex_en,
2976 self.other_modes.detail_tex_en,
2977 )
2978 }
2979
2980 /// The stored coverage value (`0..=7`) for pixel column `x` under sub-pixel
2981 /// coverage, or `None` when the pixel is not drawn.
2982 ///
2983 /// A zero mask kills the pixel. With anti-aliasing off, only the first
2984 /// sub-sample (mask bit 0 — the top-left) matters, so a pixel whose top-left
2985 /// sample is outside the span is dropped (parallel-rdp `shading.h:171-178`). The
2986 /// stored coverage is the `cvg_dest` write-back: **clamp** (mode 0) stores the
2987 /// no-blend `(count - 1) & 7` (`coverage.h`) and **full** (mode 2) stores `7`
2988 /// (so a partially-covered edge pixel gets the full RGBA5551 alpha bit). Both
2989 /// pack into the pixel's alpha/coverage bits — full coverage (count 8) stores 7,
2990 /// so the alpha bit (`cov >> 2`) is set. The **wrap** (1) and **save** (3) modes
2991 /// need the memory-read coverage accumulator (R-9 slice 2c-2) and are deferred.
2992 fn pixel_coverage(
2993 &self,
2994 xleft: [i32; COVERAGE_SUBPIXELS],
2995 xright: [i32; COVERAGE_SUBPIXELS],
2996 x: i32,
2997 ) -> Option<u8> {
2998 let mask = compute_coverage(xleft, xright, x);
2999 if mask == 0 || (!self.other_modes.aa_enable && (mask & 1) == 0) {
3000 return None;
3001 }
3002 #[allow(clippy::cast_possible_truncation)] // count_ones() is 1..=8 here
3003 let count = mask.count_ones() as u8;
3004 // `cvg_dest = 2` (full) forces full coverage regardless of the sample count.
3005 Some(if self.other_modes.cvg_dest == 2 {
3006 7
3007 } else {
3008 (count - 1) & 7
3009 })
3010 }
3011
3012 /// Apply the ordered RGB dither to a combined pixel color in place, mirroring
3013 /// Angrylion's `rgb_dither`. Dither is part of the 1-/2-cycle pixel pipeline
3014 /// only (FILL/COPY bypass the combiner), and RGB dither mode 3 is "off" — both
3015 /// return early. Alpha is untouched. `(x, y)` index the 4×4 dither matrix
3016 /// (**R-10**: noise mode 2 reads the magic cell for now).
3017 /// Whether a pixel survives **alpha-compare** (`Set Other Modes` bit 0). With
3018 /// the gate off, every pixel passes. With it on, the combiner output alpha must
3019 /// be `>=` the threshold — the `Set Blend Color` alpha (the dithered-threshold
3020 /// variant, `dither_alpha_en`, is a deferred residual). Below-threshold pixels
3021 /// are not written (N64brew *…/Blender* §Alpha compare).
3022 fn alpha_compare_passes(&self, alpha: u8) -> bool {
3023 if !self.other_modes.alpha_compare_en {
3024 return true;
3025 }
3026 #[allow(clippy::cast_possible_truncation)] // low byte of the RGBA8888 register
3027 let threshold = (self.blend_color & 0xFF) as u8;
3028 alpha >= threshold
3029 }
3030
3031 fn dither_pixel(&self, color: &mut [u8; 4], x: u32, y: u32) {
3032 // FILL/COPY bypass the combiner, and mode 3 ("off") never rounds up — both
3033 // are the common case, so skip the per-pixel work outright rather than run
3034 // it to a no-op result.
3035 if self.other_modes.cycle_type >= 2 || self.other_modes.rgb_dither_mode == 3 {
3036 return;
3037 }
3038 let dith = rgb_dither_value(self.other_modes.rgb_dither_mode, x, y);
3039 *color = apply_rgb_dither(*color, dith);
3040 }
3041
3042 /// Render one scanline's span with the per-pixel depth test (T-33-004 PR-B 2a):
3043 /// for each pixel, interpolate the depth, test it against the Z buffer, and —
3044 /// only if it passes — write the color and (when `z_update`) the depth.
3045 ///
3046 /// In 1-/2-cycle mode `cov` carries the per-Y-subpixel edges: each pixel is gated
3047 /// by [`Self::pixel_coverage`] (an uncovered pixel is skipped before the depth
3048 /// test) and the coverage count is stored in the pixel alpha, identical to the
3049 /// no-Z shaded path (validated against Angrylion by `shade_depth_tri_frac_16`).
3050 /// FILL/COPY mode passes `None` and keeps whole-pixel coverage. The depth test
3051 /// itself still uses a full count (`8`); the coverage-weighted interpenetration
3052 /// path is the deferred R-9/R-12 residual.
3053 #[allow(
3054 clippy::too_many_arguments,
3055 reason = "an internal rasterizer span helper; a struct would only relocate the parameters"
3056 )]
3057 #[allow(
3058 clippy::cast_sign_loss,
3059 reason = "x and line are >= 0 within a clipped span"
3060 )]
3061 #[allow(
3062 clippy::similar_names,
3063 reason = "mem_z / mem_dz mirror the memory depth/dz pair they hold"
3064 )]
3065 fn depth_span<B: VideoBus>(
3066 &self,
3067 row_addr: u32,
3068 x0: i32,
3069 x1: i32,
3070 line: i32,
3071 bpp: u32,
3072 major_x: i64,
3073 y_base: i32,
3074 z: &ZTriSetup,
3075 cov: Option<(&[i32; COVERAGE_SUBPIXELS], &[i32; COVERAGE_SUBPIXELS])>,
3076 shade: Option<&ShadeSetup>,
3077 tex: Option<&TexSetup>,
3078 base_tile: usize,
3079 bus: &mut B,
3080 ) {
3081 let yu = line as u32;
3082 for x in x0..=x1 {
3083 // In 1-/2-cycle mode, sub-pixel coverage gates the pixel and drives the
3084 // stored alpha; an uncovered pixel is skipped before the depth test (no
3085 // Z read/write). FILL/COPY mode (`cov == None`) keeps whole-pixel span.
3086 let pixel_cov = match cov {
3087 Some((xleft, xright)) => match self.pixel_coverage(*xleft, *xright, x) {
3088 Some(c) => Some(c),
3089 None => continue,
3090 },
3091 None => None,
3092 };
3093 let z_px = interpolate_z(z.z_base, z.dzdx, z.dzde, major_x, line, y_base, x);
3094 let xu = x as u32;
3095 let (mem_z, mem_dz) = self.zbuffer_read(xu, yu, bus);
3096 let dinp = DepthInputs {
3097 current_depth: mem_z,
3098 current_dz: mem_dz,
3099 current_coverage: 0,
3100 z_compare: self.other_modes.z_compare_en,
3101 z_mode: self.other_modes.z_mode,
3102 force_blend: self.other_modes.force_blend,
3103 // AA-edge coverage (the `aa_enable && farther` blend path) is slice 2c;
3104 // until per-pixel coverage exists, only `force_blend` drives the blender.
3105 aa_enable: false,
3106 };
3107 let dr = Self::depth_test(z_px, z.dz, z.dz_compressed, 8, &dinp);
3108 if dr.depth_pass {
3109 // Shaded/textured triangles take the combiner color; else the FILL register.
3110 if shade.is_some() || tex.is_some() {
3111 let (mut color, shade_alpha) =
3112 self.combined_color(shade, tex, base_tile, major_x, line, y_base, x);
3113 // Alpha-compare gates the write AND the z-write on the combiner
3114 // alpha (before blend/coverage touch the alpha byte). This is
3115 // observably equivalent to the RDP's pre-depth-test ordering
3116 // because the compare is depth-independent: a pixel is written
3117 // (and its depth stored) only when both depth and alpha pass.
3118 if !self.alpha_compare_passes(color[3]) {
3119 continue;
3120 }
3121 // The blender runs only when the depth test enabled it (translucent /
3122 // AA-edge pixels); an opaque pixel keeps the combiner color, matching
3123 // the reference's `!blend_en` fast-path. The full opaque-alpha fast path
3124 // and coverage-driven alpha are R-11.
3125 if dr.blend_en {
3126 let memory = Self::read_pixel(row_addr, xu, bpp, bus);
3127 let rgb = self.blend(BlendInputs {
3128 pixel: color,
3129 memory,
3130 blend_color: unpack_rgba(self.blend_color),
3131 fog: unpack_rgba(self.fog_color),
3132 // The blender's shade-alpha mux input (`A`-select 2) is the
3133 // interpolated shade alpha, independent of the combiner output
3134 // alpha (`A`-select 0, carried in `pixel[3]`).
3135 shade_alpha,
3136 });
3137 // The blender produces RGB only; the alpha byte keeps the combiner
3138 // output alpha unless sub-pixel coverage overrides it below.
3139 color = [rgb[0], rgb[1], rgb[2], color[3]];
3140 }
3141 // Store the sub-pixel coverage in the pixel alpha (1-/2-cycle mode).
3142 if let Some(c) = pixel_cov {
3143 color[3] = c << 5;
3144 }
3145 self.dither_pixel(&mut color, xu, yu);
3146 Self::write_pixel(row_addr, xu, bpp, color, bus);
3147 } else {
3148 self.fill_pixel(row_addr, xu, bpp, bus);
3149 }
3150 if self.other_modes.z_update_en {
3151 #[allow(
3152 clippy::cast_sign_loss,
3153 clippy::cast_possible_truncation,
3154 reason = "dz_compressed is clamped to 0..=0xf in decode_triangle_z"
3155 )]
3156 self.zbuffer_write(xu, yu, z_px, z.dz_compressed as u8, bus);
3157 }
3158 }
3159 }
3160 }
3161
3162 /// Apply a `Set Combine Mode` (0x3C): decode the 16 mux input selects for both
3163 /// cycles into [`CombineMode`] (N64brew *…/Commands* §0x3C, matched to the
3164 /// ParaLLEl-RDP field layout).
3165 fn set_combine_mode(&mut self, hi: u32, lo: u32) {
3166 self.combine.cyc0 = CombineCycle {
3167 rgb_a: ((hi >> 20) & 0xF) as u8,
3168 rgb_c: ((hi >> 15) & 0x1F) as u8,
3169 rgb_b: ((lo >> 28) & 0xF) as u8,
3170 rgb_d: ((lo >> 15) & 0x7) as u8,
3171 a_a: ((hi >> 12) & 0x7) as u8,
3172 a_c: ((hi >> 9) & 0x7) as u8,
3173 a_b: ((lo >> 12) & 0x7) as u8,
3174 a_d: ((lo >> 9) & 0x7) as u8,
3175 };
3176 self.combine.cyc1 = CombineCycle {
3177 rgb_a: ((hi >> 5) & 0xF) as u8,
3178 rgb_c: (hi & 0x1F) as u8,
3179 rgb_b: ((lo >> 24) & 0xF) as u8,
3180 rgb_d: ((lo >> 6) & 0x7) as u8,
3181 a_a: ((lo >> 21) & 0x7) as u8,
3182 a_c: ((lo >> 18) & 0x7) as u8,
3183 a_b: ((lo >> 3) & 0x7) as u8,
3184 a_d: (lo & 0x7) as u8,
3185 };
3186 }
3187
3188 /// Evaluate the color combiner for one cycle, returning the RGBA8888 output.
3189 ///
3190 /// Muxes the [`CombinerInputs`] by the cycle's selects into `(A − B) * C + D`
3191 /// per channel (`combine_channel`), clamps to `[0, 255]` (`clamp_9bit`), and
3192 /// does the same for alpha. The RGB and alpha combiners use different
3193 /// input tables (N64brew *…/Commands* §0x3C). The register-sourced exotic inputs
3194 /// (prim-LOD-frac, the convert `K4`/`K5`, and the chroma-key center/scale) are
3195 /// wired; the remaining exotic inputs (noise, the derivative `lod_frac`, and the
3196 /// YUV `K0`–`K3` convert) are **open residual R-10** and read as zero.
3197 #[must_use]
3198 pub(crate) fn combine_cycle(cfg: CombineCycle, inp: &CombinerInputs) -> [u8; 4] {
3199 // RGB: A/B share the muladd/mulsub table, C the wide mul table, D the add.
3200 let mut out = [0u8; 4];
3201 for (ch, o) in out.iter_mut().enumerate().take(3) {
3202 let a = i32::from(rgb_input_a(cfg.rgb_a, inp, ch));
3203 let b = i32::from(rgb_input_b(cfg.rgb_b, inp, ch));
3204 let c = i32::from(rgb_input_c(cfg.rgb_c, inp, ch));
3205 let d = i32::from(rgb_input_d(cfg.rgb_d, inp, ch));
3206 *o = clamp_9bit(combine_channel(a, b, c, d));
3207 }
3208 // Alpha: A/B/D share one 3-bit table; C its own.
3209 let a = i32::from(alpha_input_abd(cfg.a_a, inp));
3210 let b = i32::from(alpha_input_abd(cfg.a_b, inp));
3211 let c = i32::from(alpha_input_c(cfg.a_c, inp));
3212 let d = i32::from(alpha_input_abd(cfg.a_d, inp));
3213 out[3] = clamp_9bit(combine_channel(a, b, c, d));
3214 out
3215 }
3216
3217 /// Evaluate the whole combiner for a pixel: cycle 1 alone in 1-cycle mode, or
3218 /// cycle 0 feeding cycle 1's `Combined` input in 2-cycle mode. `two_cycle`
3219 /// comes from `Set Other Modes` (T-33-003).
3220 #[must_use]
3221 /// Run the combiner. In `two_cycle` mode the caller must have populated
3222 /// `inp.texel1` (the `tile+1` sample) — the render path (`combined_color`)
3223 /// samples it whenever `cycle_type` is 2-cycle. The swap below then matches the
3224 /// hardware unconditionally; a `two_cycle` call that left `texel1` at its default
3225 /// would feed cycle 1 a zeroed `texel0`, which is a caller contract violation,
3226 /// not a mode this function guards against.
3227 pub(crate) fn combine(&self, mut inp: CombinerInputs, two_cycle: bool) -> [u8; 4] {
3228 if two_cycle {
3229 inp.combined = Self::combine_cycle(self.combine.cyc0, &inp);
3230 // The hardware pipelines the two texels: cycle 1's TEXEL0 reads the
3231 // second tile's sample and TEXEL1 the first, so the combiner swaps them
3232 // before cycle 1 (`combiner_2cycle_cycle1`, R-13).
3233 core::mem::swap(&mut inp.texel0, &mut inp.texel1);
3234 }
3235 let cyc = self.combine.cyc1;
3236 if self.other_modes.key_en {
3237 // Chroma-key alpha compare (Angrylion `combiner_1cycle` key_en path, R-10):
3238 // the RGB output is the sub-A "chromabypass" color (clamped), and the pixel
3239 // alpha is derived from the key window over the pre-`>>8` 17-bit combined
3240 // color. Gated on `key_en` so the common path (below) is byte-identical.
3241 let mut col17 = [0i32; 3];
3242 let mut rgb = [0u8; 3];
3243 for (ch, (c17, out)) in col17.iter_mut().zip(rgb.iter_mut()).enumerate() {
3244 let a = i32::from(rgb_input_a(cyc.rgb_a, &inp, ch));
3245 let b = i32::from(rgb_input_b(cyc.rgb_b, &inp, ch));
3246 let c = i32::from(rgb_input_c(cyc.rgb_c, &inp, ch));
3247 let d = i32::from(rgb_input_d(cyc.rgb_d, &inp, ch));
3248 *c17 = combine_channel_17bit(a, b, c, d);
3249 *out = clamp_9bit(a); // chromabypass = sub-A input, clamped
3250 }
3251 let keyalpha = chroma_key_min(col17, self.key_width);
3252 return [rgb[0], rgb[1], rgb[2], keyalpha];
3253 }
3254 Self::combine_cycle(cyc, &inp)
3255 }
3256
3257 /// Decode `Set Other Modes` (0x2F) into [`OtherModes`]. The blend selects and
3258 /// the cycle-control / Z / coverage flags all live in this one word; only the
3259 /// subset the blender and cycle control consume today is used, but the full
3260 /// layout is decoded so nothing silently reads as its `Default` (N64brew
3261 /// *…/Commands* §0x2F, cross-checked against parallel-rdp `rdp_common.hpp`).
3262 ///
3263 /// `hi` is command bits 63:32 and `lo` bits 31:0. The blend selects pack two
3264 /// cycles into `lo` bits 31:16 (`P0 P1 A0 A1 M0 M1 B0 B1`, MSB-first, 2 bits
3265 /// each); the cycle type is `hi` bits 53:52.
3266 fn set_other_modes(&mut self, hi: u32, lo: u32) {
3267 self.other_modes = OtherModes {
3268 cycle_type: ((hi >> 20) & 0x3) as u8,
3269 blend: [
3270 BlendCycle {
3271 p: ((lo >> 30) & 0x3) as u8,
3272 a: ((lo >> 26) & 0x3) as u8,
3273 m: ((lo >> 22) & 0x3) as u8,
3274 b: ((lo >> 18) & 0x3) as u8,
3275 },
3276 BlendCycle {
3277 p: ((lo >> 28) & 0x3) as u8,
3278 a: ((lo >> 24) & 0x3) as u8,
3279 m: ((lo >> 20) & 0x3) as u8,
3280 b: ((lo >> 16) & 0x3) as u8,
3281 },
3282 ],
3283 force_blend: (lo >> 14) & 1 != 0,
3284 image_read_en: (lo >> 6) & 1 != 0,
3285 cvg_dest: ((lo >> 8) & 0x3) as u8,
3286 z_compare_en: (lo >> 4) & 1 != 0,
3287 z_update_en: (lo >> 5) & 1 != 0,
3288 z_mode: ((lo >> 10) & 0x3) as u8,
3289 alpha_compare_en: lo & 1 != 0,
3290 key_en: (hi >> 8) & 1 != 0, // command bit 40
3291 persp_tex_en: (hi >> 19) & 1 != 0, // command bit 51
3292 aa_enable: (lo >> 3) & 1 != 0, // command bit 3
3293 rgb_dither_mode: ((hi >> 6) & 0x3) as u8, // command bits 39:38
3294 tlut_en: (hi >> 15) & 1 != 0, // command bit 47
3295 tlut_type: (hi >> 14) & 1 != 0, // command bit 46
3296 sample_type: (hi >> 13) & 1 != 0, // command bit 45
3297 mid_texel: (hi >> 12) & 1 != 0, // command bit 44
3298 detail_tex_en: (hi >> 18) & 1 != 0, // command bit 50
3299 sharpen_tex_en: (hi >> 17) & 1 != 0, // command bit 49
3300 tex_lod_en: (hi >> 16) & 1 != 0, // command bit 48
3301 };
3302 }
3303
3304 /// Evaluate one blender cycle: `(P * a0 + M * (a1 + 1)) >> 5`, the divide-free
3305 /// form the hardware uses whenever the result is not an anti-aliased edge
3306 /// (N64brew *…/Blender*; parallel-rdp `shaders/blender.h`). `a0 = A >> 3` and
3307 /// `a1 = B >> 3` map the 8-bit alpha selects to the 5-bit blend weights, and
3308 /// the `+ 1` on the `M` term is real hardware, not a rounding fudge.
3309 ///
3310 /// The color selects (`P`, `M`) pick an RGB triple; the alpha selects (`A`,
3311 /// `B`) pick a scalar weight — `B`'s `1 − A` case complements the resolved `A`
3312 /// weight, so `A` is computed first and handed to `blend_b_input`. The result
3313 /// is masked to 8 bits, **not** clamped: the reference casts through `u8` and
3314 /// re-masks (`blender.h:142`), so an over-range blend wraps exactly as hardware
3315 /// does — software is expected to keep `a0 + a1 + 1 ≈ 32`.
3316 ///
3317 /// The final-cycle early-return fast paths (opaque passthrough, `color_on_cvg`),
3318 /// the anti-aliased divider path, alpha-compare, dither, and Z are **open
3319 /// residual R-11**; `blend_cycle` always takes the no-divide branch for now.
3320 #[must_use]
3321 pub fn blend_cycle(cycle: BlendCycle, inp: &BlendInputs) -> [u8; 3] {
3322 let p = blend_rgb_input(cycle.p, inp);
3323 let m = blend_rgb_input(cycle.m, inp);
3324 let a0_full = blend_a_input(cycle.a, inp);
3325 let a0 = u32::from(a0_full >> 3);
3326 let a1 = u32::from(blend_b_input(cycle.b, inp, a0_full) >> 3);
3327 let mut out = [0u8; 3];
3328 for (ch, o) in out.iter_mut().enumerate() {
3329 let blended = u32::from(p[ch]) * a0 + u32::from(m[ch]) * (a1 + 1);
3330 *o = ((blended >> 5) & 0xFF) as u8;
3331 }
3332 out
3333 }
3334
3335 /// Evaluate the whole blender for a pixel: blend cycle 0 alone in 1-cycle
3336 /// mode, or cycle 0's RGB fed back as the pixel color into cycle 1 in
3337 /// 2-cycle mode (N64brew *…/Blender*).
3338 ///
3339 /// Only `pixel.rgb` chains between cycles — **`pixel.a` is deliberately left
3340 /// unchanged**, so both cycles' `A`/`B` alpha selects see the original combiner
3341 /// alpha. This matches the reference, which reassigns `pixel_color.rgb` only
3342 /// before the second `blender()` call (parallel-rdp `memory_interfacing.h:536`);
3343 /// the blender produces no alpha of its own (`blender.h` returns `u8x3`).
3344 ///
3345 /// **Precondition: only valid for cycle types 0 (1-cycle) and 1 (2-cycle).** Copy
3346 /// (2) and Fill (3) bypass the blender on hardware — the pixel comes straight from
3347 /// the texel copy / fill register — so the pixel pipeline (T-33-004) must gate on
3348 /// `cycle_type` and not route those modes through here. This method is not given a
3349 /// fabricated Copy/Fill result, because the honest contract is "not called", not
3350 /// "called and returns something"; for cycle type 0 it correctly runs cycle 0 once.
3351 #[must_use]
3352 pub fn blend(&self, mut inp: BlendInputs) -> [u8; 3] {
3353 if self.other_modes.cycle_type == 1 {
3354 let rgb0 = Self::blend_cycle(self.other_modes.blend[0], &inp);
3355 inp.pixel[0] = rgb0[0];
3356 inp.pixel[1] = rgb0[1];
3357 inp.pixel[2] = rgb0[2];
3358 return Self::blend_cycle(self.other_modes.blend[1], &inp);
3359 }
3360 Self::blend_cycle(self.other_modes.blend[0], &inp)
3361 }
3362
3363 /// The per-pixel depth test and coverage/blend derivation — a faithful port of
3364 /// ParaLLEl-RDP's `depth_test.h` (the Angrylion-parity reference).
3365 ///
3366 /// `z`/`dz` are this pixel's decompressed 18-bit depth and its raw delta;
3367 /// `dz_compressed` is `dz`'s 4-bit `log2`; `coverage_count` is this pixel's
3368 /// span coverage. [`DepthInputs`] carries the Z-buffer read and the render-mode
3369 /// flags. When `z_compare` is off the pixel always passes; otherwise the four Z
3370 /// modes (opaque/interpenetrating/transparent/decal) apply, with the
3371 /// coplanar/precision-factor handling of the stored `dz`. Interpenetrating mode
3372 /// can *reduce* the returned `coverage_count`. No buffer is touched here — the
3373 /// caller (the pixel pipeline, PR-B) reads/writes the Z buffer and applies the
3374 /// result; today this has no runtime caller, so the oracle is unchanged.
3375 #[must_use]
3376 #[allow(
3377 clippy::cast_possible_truncation,
3378 clippy::cast_sign_loss,
3379 reason = "dz_compressed is clamped to 0..=0xf up front, so both blend_shift \
3380 branches (clamp(0,4) and min(0xf - dz_compressed, 4)) yield 0..=4 \
3381 before the u8 cast — the DepthResult invariant holds"
3382 )]
3383 #[allow(
3384 clippy::similar_names,
3385 reason = "memory_z / memory_dz / dz are the oracle's own names (depth_test.h)"
3386 )]
3387 pub fn depth_test(
3388 z: i32,
3389 dz: i32,
3390 dz_compressed: i32,
3391 mut coverage_count: i32,
3392 inp: &DepthInputs,
3393 ) -> DepthResult {
3394 let depth_pass;
3395 let blend_en;
3396 let coverage_wrap;
3397 let mut blend_shift = [0u8; 2];
3398 // Sanitize every input to its hardware domain up front, so the shifts and
3399 // sums below are bounded for any `i32`/`u8` a caller passes (`depth_test` is
3400 // public and the pipeline that will call it clamps identically — the oracle
3401 // clamps `z` to the 18-bit range in `clamping.h`, and `current_dz`/
3402 // `current_depth`/`dz_compressed` are 4-/14-/4-bit storage fields).
3403 let z = z.clamp(0, 0x3_FFFF); // 18-bit UNORM depth
3404 let dz = dz.clamp(0, 0x3_FFFF); // depth-range delta (non-negative)
3405 let dz_compressed = dz_compressed.clamp(0, 0xf); // 4-bit log2, as `0xf - …` assumes
3406 let current_depth = inp.current_depth & 0x3FFF; // 14-bit stored z
3407 let current_dz = i32::from(inp.current_dz & 0xf); // 4-bit stored dz
3408
3409 if inp.z_compare {
3410 let memory_z = z_decompress(current_depth);
3411 let mut memory_dz = dz_decompress(current_dz);
3412 let precision_factor = (i32::from(current_depth) >> 11) & 0xf;
3413 let mut coplanar = false;
3414
3415 blend_shift[0] = (dz_compressed - current_dz).clamp(0, 4) as u8;
3416 blend_shift[1] = (current_dz - dz_compressed).clamp(0, 4) as u8;
3417
3418 if precision_factor < 3 {
3419 if memory_dz == 0x8000 {
3420 coplanar = true;
3421 memory_dz = 0xffff;
3422 } else {
3423 memory_dz = (memory_dz << 1).max(16 >> precision_factor);
3424 }
3425 }
3426
3427 let mut combined_dz = combine_dz(dz | memory_dz);
3428 let combined_dz_interpenetrate = combined_dz;
3429 combined_dz <<= 3;
3430
3431 let farther = coplanar || (z + combined_dz) >= memory_z;
3432 let overflow = (coverage_count + inp.current_coverage) >= 8;
3433
3434 blend_en = inp.force_blend || (!overflow && inp.aa_enable && farther);
3435 coverage_wrap = overflow;
3436
3437 let max_z = memory_z == 0x3_FFFF;
3438 let front = z < memory_z;
3439 let nearer = coplanar || (z - combined_dz) <= memory_z;
3440 let opaque_pass = max_z || if overflow { front } else { nearer };
3441
3442 depth_pass = match inp.z_mode {
3443 // Interpenetrating: a decal-like intersect modifies coverage; else
3444 // it falls back to the opaque less-than test.
3445 1 if front && farther && overflow => {
3446 let ip = dz_compress(combined_dz_interpenetrate & 0xffff);
3447 let cvg_coeff = ((memory_z >> ip) - (z >> ip)) & 0xf;
3448 coverage_count = ((cvg_coeff * coverage_count) >> 3).min(8);
3449 true
3450 }
3451 0 | 1 => opaque_pass,
3452 2 => front || max_z, // transparent
3453 _ => farther && nearer && !max_z, // decal (3)
3454 };
3455 } else {
3456 blend_shift[1] = (0xf - dz_compressed).min(4) as u8;
3457 let overflow = (coverage_count + inp.current_coverage) >= 8;
3458 blend_en = inp.force_blend || (!overflow && inp.aa_enable);
3459 coverage_wrap = overflow;
3460 depth_pass = true;
3461 }
3462
3463 DepthResult {
3464 depth_pass,
3465 blend_en,
3466 coverage_wrap,
3467 blend_shift,
3468 coverage_count,
3469 }
3470 }
3471
3472 /// Byte address of the 16-bit Z-buffer entry for pixel `(x, y)`: entries are
3473 /// 16-bit, based at `z_image`. The row stride reuses `color_image_width` — the
3474 /// N64 RDP addresses the depth buffer with the *color* buffer's width (there is
3475 /// no separate depth-image width register), so the two buffers share a geometry.
3476 fn zbuffer_addr(&self, x: u32, y: u32) -> u32 {
3477 let index = y
3478 .wrapping_mul(u32::from(self.color_image_width))
3479 .wrapping_add(x);
3480 self.z_image.wrapping_add(index.wrapping_mul(2))
3481 }
3482
3483 /// Read the Z-buffer entry at `(x, y)` as `(compressed_z, dz)`.
3484 ///
3485 /// The 16-bit halfword holds the 14-bit compressed `z` in bits 15:2 and the
3486 /// **high** two bits of the 4-bit `dz` in bits 1:0; the **low** two bits of
3487 /// `dz` come from the RDRAM hidden bits — matching ParaLLEl-RDP's
3488 /// `load_vram_depth`. `dz` is returned as the 4-bit value `0..=15`.
3489 #[must_use]
3490 pub fn zbuffer_read<B: VideoBus>(&self, x: u32, y: u32, bus: &B) -> (u16, u8) {
3491 let addr = self.zbuffer_addr(x, y);
3492 let word = (u16::from(bus.rdram_read(addr)) << 8)
3493 | u16::from(bus.rdram_read(addr.wrapping_add(1)));
3494 let hidden = bus.rdram_read_hidden(addr) & 0x3;
3495 let compressed_z = word >> 2;
3496 let dz = (((word & 0x3) as u8) << 2) | hidden;
3497 (compressed_z, dz)
3498 }
3499
3500 /// Write the Z-buffer entry at `(x, y)`: compress the 18-bit `z`, pack it with
3501 /// `dz`'s high two bits into the halfword, and store `dz`'s low two bits in the
3502 /// RDRAM hidden bits — matching ParaLLEl-RDP's `store_vram_depth`. `dz` is the
3503 /// 4-bit compressed delta (`0..=15`); `z` is the 18-bit UNORM depth.
3504 pub fn zbuffer_write<B: VideoBus>(&self, x: u32, y: u32, z: i32, dz: u8, bus: &mut B) {
3505 let addr = self.zbuffer_addr(x, y);
3506 let dz = dz & 0xf;
3507 let word = (z_compress(z) << 2) | u16::from(dz >> 2);
3508 let bytes = word.to_be_bytes();
3509 bus.rdram_write(addr, bytes[0]);
3510 bus.rdram_write(addr.wrapping_add(1), bytes[1]);
3511 bus.rdram_write_hidden(addr, dz & 0x3);
3512 }
3513
3514 /// Apply a `Set Tile` (0x35): decode the descriptor at `index` (bits 26:24)
3515 /// from the command word. Pure state — no texel is moved (that is the load
3516 /// commands). Field layout: format 55:53, size 52:51, line 49:41, TMEM
3517 /// address 40:32; then per-axis clamp/mirror/mask/shift with T in bits 19:10
3518 /// and S in bits 9:0 (N64brew *…/Commands* §0x35).
3519 fn set_tile(&mut self, hi: u32, lo: u32) {
3520 let index = ((lo >> 24) & 0x7) as usize;
3521 self.tiles[index] = TileDescriptor {
3522 format: ((hi >> 21) & 0x7) as u8,
3523 size: ((hi >> 19) & 0x3) as u8,
3524 line: ((hi >> 9) & 0x1FF) as u16,
3525 tmem_addr: (hi & 0x1FF) as u16,
3526 palette: ((lo >> 20) & 0xF) as u8,
3527 clamp_t: (lo >> 19) & 1 != 0,
3528 mirror_t: (lo >> 18) & 1 != 0,
3529 mask_t: ((lo >> 14) & 0xF) as u8,
3530 shift_t: ((lo >> 10) & 0xF) as u8,
3531 clamp_s: (lo >> 9) & 1 != 0,
3532 mirror_s: (lo >> 8) & 1 != 0,
3533 mask_s: ((lo >> 4) & 0xF) as u8,
3534 shift_s: (lo & 0xF) as u8,
3535 // Set Tile does not touch the tile-size coords; preserve them.
3536 ..self.tiles[index]
3537 };
3538 }
3539
3540 /// Apply a `Set Tile Size` (0x32): the clamp/mask/mirror extents for the
3541 /// descriptor at `index` (bits 26:24). Upper-left `SL`/`TL` in bits 55:44 /
3542 /// 43:32, lower-right `SH`/`TH` in bits 23:12 / 11:0, all `u10.2` (N64brew
3543 /// *…/Commands* §0x32).
3544 fn set_tile_size(&mut self, hi: u32, lo: u32) {
3545 let index = ((lo >> 24) & 0x7) as usize;
3546 let tile = &mut self.tiles[index];
3547 tile.sl = ((hi >> 12) & 0xFFF) as u16;
3548 tile.tl = (hi & 0xFFF) as u16;
3549 tile.sh = ((lo >> 12) & 0xFFF) as u16;
3550 tile.th = (lo & 0xFFF) as u16;
3551 }
3552
3553 /// Write one byte into TMEM, allocating the 4 KiB backing box on first use.
3554 ///
3555 /// `offset` is a byte address, masked into the 4 KiB space (loads past the end
3556 /// wrap to the start — N64brew *…/Commands* §Load Tile). This is the single
3557 /// allocation seam the load commands share, so `get_or_insert_with` is not
3558 /// repeated per command.
3559 fn tmem_write(&mut self, offset: usize, byte: u8) {
3560 let tmem = self
3561 .tmem
3562 .get_or_insert_with(|| alloc::boxed::Box::new([0u8; TMEM_SIZE]));
3563 tmem[offset & (TMEM_SIZE - 1)] = byte;
3564 }
3565
3566 /// Apply a `Load Tile` (0x34): copy a rectangle of texels from the current
3567 /// texture image in RDRAM into the tile's TMEM region, then update the tile
3568 /// size for rendering. `SL`/`TL`/`SH`/`TH` (bits 55:44 / 43:32 / 23:12 / 11:0)
3569 /// are `u10.2`; the `.2` fraction is floored and the span is **inclusive**
3570 /// (`SH − SL + 1` texels per row). Rows advance by the tile's `line` stride.
3571 ///
3572 /// The TMEM byte placement mirrors the sampler exactly: an **odd-row 32-bit
3573 /// word swap** (`dst ^= (t & 1) << 2`) applies to every size, and **32-bit
3574 /// RGBA is split** — R,G into the low half of TMEM, B,A into the high half,
3575 /// stepping two bytes per texel and masking to `0x7FF`. Both are the read-side
3576 /// layout in the ParaLLEl-RDP reference (MIT); see `docs/rdp.md` §TMEM loads.
3577 ///
3578 /// Scope: 8/16/32-bit texels (`size` 1/2/3). 4-bit (`size` 0) loading needs
3579 /// nibble addressing and lands with the CI4/I4 decoders (T-32-003); an
3580 /// unsupported size writes nothing (**open residual R-7**).
3581 fn load_tile<B: VideoBus>(&mut self, hi: u32, lo: u32, bus: &B) {
3582 let index = ((lo >> 24) & 0x7) as usize;
3583 let sl = ((hi >> 12) & 0xFFF) >> 2;
3584 let tl = (hi & 0xFFF) >> 2;
3585 let sh = ((lo >> 12) & 0xFFF) >> 2;
3586 let th = (lo & 0xFFF) >> 2;
3587 // Reject a degenerate/inverted range the same way as every other unsupported
3588 // path (write nothing) rather than letting the `& 0xFFF` wrap produce a large
3589 // bogus width/height: `sh < sl` would otherwise iterate ~4095 texels of
3590 // garbage. A well-formed load has SL <= SH and TL <= TH.
3591 if th < tl || sh < sl {
3592 return;
3593 }
3594 let width = (sh - sl + 1) & 0xFFF;
3595 let height = th - tl + 1;
3596 if width == 0 {
3597 return;
3598 }
3599 let tile = self.tiles[index];
3600 let Some(dst_bpt) = bytes_per_texel(tile.size) else {
3601 return; // 4-bit (or unmapped) not loaded here — R-7.
3602 };
3603 // The texture image should match the tile size (documented hazard). A 4-bit
3604 // (or unmapped) source size has no byte stride, so bail rather than fall back
3605 // to the tile stride and read out of bounds — R-7.
3606 let Some(src_bpt) = bytes_per_texel(self.tex_image_size) else {
3607 return;
3608 };
3609 let split = tile.size == 3; // 32-bit RGBA uses the split TMEM layout.
3610 let tmem_base = u32::from(tile.tmem_addr) * 8;
3611 let stride = u32::from(tile.line) * 8;
3612 let tex_w = u32::from(self.tex_image_width);
3613 for t in 0..height {
3614 let swap = (t & 1) << 2;
3615 for s in 0..width {
3616 let src_pixel = (tl + t) * tex_w + (sl + s);
3617 let src = self.tex_image_addr.wrapping_add(src_pixel * src_bpt);
3618 if split {
3619 // R,G -> low half; B,A -> high half (offset by 0x800). The swap is
3620 // applied per final byte so it stays correct regardless of alignment.
3621 let bo = (tmem_base + stride * t + s * 2) & 0x7FF;
3622 self.tmem_write((bo ^ swap) as usize, bus.rdram_read(src));
3623 self.tmem_write(
3624 ((bo + 1) ^ swap) as usize,
3625 bus.rdram_read(src.wrapping_add(1)),
3626 );
3627 self.tmem_write(
3628 ((bo + 0x800) ^ swap) as usize,
3629 bus.rdram_read(src.wrapping_add(2)),
3630 );
3631 self.tmem_write(
3632 ((bo + 0x801) ^ swap) as usize,
3633 bus.rdram_read(src.wrapping_add(3)),
3634 );
3635 } else {
3636 let base = tmem_base + stride * t + s * dst_bpt;
3637 for i in 0..dst_bpt {
3638 // XOR the swap into each final byte address, not the base.
3639 self.tmem_write(
3640 ((base + i) ^ swap) as usize,
3641 bus.rdram_read(src.wrapping_add(i)),
3642 );
3643 }
3644 }
3645 }
3646 }
3647 // Load Tile updates the descriptor's tile size for rendering.
3648 let tile = &mut self.tiles[index];
3649 tile.sl = ((hi >> 12) & 0xFFF) as u16;
3650 tile.tl = (hi & 0xFFF) as u16;
3651 tile.sh = ((lo >> 12) & 0xFFF) as u16;
3652 tile.th = (lo & 0xFFF) as u16;
3653 }
3654
3655 /// Apply a `Load Block` (0x33): stream a linear run of texels from the current
3656 /// texture image into the tile's TMEM region. `SL`/`SH` (bits 55:44 / 23:12)
3657 /// are `u12.0` integer texels; `SH − SL + 1` is the count (**inclusive**), and
3658 /// a count over [`LOAD_BLOCK_MAX_TEXELS`] writes nothing. The low field
3659 /// (bits 11:0) is **`dxt`** (`u1.11`): a running counter `T = (word * dxt) >>
3660 /// 11` over each 64-bit TMEM word decides line parity, and an odd line swaps
3661 /// that word's two 32-bit halves (`dst ^= 4`).
3662 ///
3663 /// Scope: 8/16-bit texels (`size` 1/2). The 32-bit split path and 4-bit are
3664 /// deferred (**open residual R-7**); an unsupported size writes nothing.
3665 fn load_block<B: VideoBus>(&mut self, hi: u32, lo: u32, bus: &B) {
3666 let index = ((lo >> 24) & 0x7) as usize;
3667 let slo = (hi >> 12) & 0xFFF;
3668 let tlo = hi & 0xFFF;
3669 let shi = (lo >> 12) & 0xFFF;
3670 let dxt = lo & 0xFFF;
3671 // An inverted range writes nothing (as in `load_tile`); without this an
3672 // extreme `shi < slo` (slo >= 2049) wraps into a valid-looking count.
3673 if shi < slo {
3674 return;
3675 }
3676 let count = (shi - slo + 1) & 0xFFF;
3677 if count == 0 || count > LOAD_BLOCK_MAX_TEXELS {
3678 return; // over the limit: nothing written (§Load Block).
3679 }
3680 let tile = self.tiles[index];
3681 let Some(bpt) = bytes_per_texel(tile.size) else {
3682 return;
3683 };
3684 if tile.size == 3 {
3685 return; // 32-bit block load (split) deferred — R-7.
3686 }
3687 // A 4-bit (or unmapped) source size has no byte stride — bail rather than
3688 // fall back to the tile stride and read out of bounds (R-7).
3689 let Some(src_bpt) = bytes_per_texel(self.tex_image_size) else {
3690 return;
3691 };
3692 let tex_w = u32::from(self.tex_image_width);
3693 let src_base = self
3694 .tex_image_addr
3695 .wrapping_add((tex_w * tlo + slo) * src_bpt);
3696 let tmem_base = u32::from(tile.tmem_addr) * 8;
3697 for s in 0..count {
3698 let src = src_base.wrapping_add(s * src_bpt);
3699 let byte_off = s * bpt;
3700 // Line parity from the dxt counter over 64-bit TMEM words.
3701 let word = byte_off / 8;
3702 let line = (word * dxt) >> 11;
3703 let swap = (line & 1) << 2;
3704 let base = tmem_base + byte_off;
3705 for i in 0..bpt {
3706 // XOR the swap into each final byte address, not the base.
3707 self.tmem_write(
3708 ((base + i) ^ swap) as usize,
3709 bus.rdram_read(src.wrapping_add(i)),
3710 );
3711 }
3712 }
3713 }
3714
3715 /// Apply a `Load TLUT` (0x30): load a palette into TMEM. Each 16-bit entry
3716 /// from the (16-bit) texture image is **quadrupled** — written to four
3717 /// adjacent `u16` slots — so entry `i` occupies 8 bytes at TMEM byte
3718 /// `tmem_addr*8 + i*8`. The count is inclusive (`(SH>>2) − (SL>>2) + 1`).
3719 ///
3720 /// The destination is wherever the tile's `tmem_addr` points; a correct
3721 /// program sets it into the upper 2 KiB (byte >= 0x800), aligned to 128 bytes
3722 /// (N64brew *…/Commands* §Load TLUT). That is a **programmer requirement, not
3723 /// a hardware rejection** — the ParaLLEl-RDP reference writes to the addressed
3724 /// location and the sampler reads the palette from the upper half, so a
3725 /// misplaced TLUT is simply not found rather than refused. Enforcing a
3726 /// rejection here would invent behavior the hardware does not have.
3727 fn load_tlut<B: VideoBus>(&mut self, hi: u32, lo: u32, bus: &B) {
3728 let sl = ((hi >> 12) & 0xFFF) >> 2;
3729 let sh = ((lo >> 12) & 0xFFF) >> 2;
3730 let index = ((lo >> 24) & 0x7) as usize;
3731 if sh < sl {
3732 return;
3733 }
3734 let count = sh - sl + 1;
3735 let tmem_base = u32::from(self.tiles[index].tmem_addr) * 8;
3736 let src_base = self.tex_image_addr.wrapping_add(sl * 2); // 16-bit source
3737 for i in 0..count {
3738 let src = src_base.wrapping_add(i * 2);
3739 let hi_b = bus.rdram_read(src);
3740 let lo_b = bus.rdram_read(src.wrapping_add(1));
3741 let dst = tmem_base + i * 8;
3742 for k in 0..4u32 {
3743 let slot = (dst + k * 2) as usize;
3744 self.tmem_write(slot, hi_b);
3745 self.tmem_write(slot + 1, lo_b);
3746 }
3747 }
3748 // Load TLUT also updates the tile size (like the other loads).
3749 let tile = &mut self.tiles[index];
3750 tile.sl = ((hi >> 12) & 0xFFF) as u16;
3751 tile.tl = (hi & 0xFFF) as u16;
3752 tile.sh = ((lo >> 12) & 0xFFF) as u16;
3753 tile.th = (lo & 0xFFF) as u16;
3754 }
3755
3756 /// Sample one texel from `tile` at tile-relative integer coords `(s, t)`,
3757 /// returning RGBA8888. The fetch half of the texture pipeline; the
3758 /// clamp/mirror/mask/shift wrapper and the filter/combiner are T-32-004 /
3759 /// Sprint 3. Decodes every listed texel format (RGBA16/32, IA16/8/4, I8/4,
3760 /// CI8/4 via the TLUT), matched to the ParaLLEl-RDP read layout.
3761 ///
3762 /// TMEM is read as a natural big-endian byte array with the odd-row
3763 /// 32-bit-word swap `^= (t & 1) << 2` — the same convention the loads use, so
3764 /// the endian twiddles ParaLLEl-RDP applies to its host-word storage are
3765 /// intentionally absent here too. An unsupported format/size is transparent
3766 /// black.
3767 #[must_use]
3768 pub fn fetch_texel(&self, tile: &TileDescriptor, s: u32, t: u32) -> [u8; 4] {
3769 let swap = (t & 1) << 2;
3770 // Wrapping arithmetic end to end: an oversized/unclipped `s` or `t` must
3771 // not debug-panic on overflow before the TMEM mask applies — a panic would
3772 // break the determinism contract (ADR 0004). Every offset below is masked
3773 // into the 4 KiB space by `tmem_byte`. The per-column byte offsets for the
3774 // three texel widths (16/32-bit, 8-bit, 4-bit-nibble):
3775 let base = u32::from(tile.tmem_addr)
3776 .wrapping_mul(8)
3777 .wrapping_add(u32::from(tile.line).wrapping_mul(8).wrapping_mul(t));
3778 let off16 = base.wrapping_add(s.wrapping_mul(2));
3779 let off8 = base.wrapping_add(s);
3780 let off4 = base.wrapping_add(s >> 1);
3781 match (tile.format, tile.size) {
3782 (0, 2) => decode_rgba16(self.tmem_u16(off16 ^ swap)), // RGBA16
3783 (0, 3) => {
3784 // RGBA32 split: R,G low half; B,A high half.
3785 let bo = (off16 & 0x7FF) ^ swap;
3786 [
3787 self.tmem_byte(bo as usize),
3788 self.tmem_byte(bo.wrapping_add(1) as usize),
3789 self.tmem_byte(bo.wrapping_add(TMEM_HIGH) as usize),
3790 self.tmem_byte(bo.wrapping_add(TMEM_HIGH + 1) as usize),
3791 ]
3792 }
3793 (3, 2) => {
3794 // IA16: I high byte, A low byte.
3795 let w = self.tmem_u16(off16 ^ swap);
3796 let i = (w >> 8) as u8;
3797 [i, i, i, (w & 0xFF) as u8]
3798 }
3799 (3, 1) => {
3800 // IA8: I high nibble, A low nibble (each 4->8).
3801 let byte = self.tmem_byte((off8 ^ swap) as usize);
3802 let i = widen4(u32::from(byte) >> 4);
3803 [i, i, i, widen4(u32::from(byte) & 0xF)]
3804 }
3805 (3, 0) => {
3806 // IA4: I top 3 bits (3->8), A bottom bit.
3807 let nib = self.nibble_at((off4 ^ swap) as usize, s);
3808 let i = widen3(u32::from(nib) >> 1);
3809 [i, i, i, if nib & 1 != 0 { 0xFF } else { 0 }]
3810 }
3811 (4, 1) => {
3812 // I8: intensity in all channels, alpha = intensity.
3813 let v = self.tmem_byte((off8 ^ swap) as usize);
3814 [v, v, v, v]
3815 }
3816 (4, 0) => {
3817 // I4: 4-bit intensity (4->8), alpha = intensity.
3818 let v = widen4(u32::from(self.nibble_at((off4 ^ swap) as usize, s)));
3819 [v, v, v, v]
3820 }
3821 // The color-index formats resolve through the palette **only when
3822 // `Set Other Modes.tlut_en` is set** (bit 47) — the format field alone
3823 // does not enable it (N64brew *…/Commands* §0x2F). With the flag clear
3824 // the oracle renders a CI tile entirely black, which
3825 // `ci4_tlut_disabled_16` pins: it is byte-identical to
3826 // `tex_tri_ci4_tlut_16` apart from that one bit, and the two goldens are
3827 // the full palette versus all black.
3828 //
3829 // Zero is what the oracle produces, not a mechanism claim: what the
3830 // hardware *does* with un-TLUT'd index data is not documented in §0x2F,
3831 // so this reproduces the observed result rather than inventing a
3832 // reinterpretation of the index bits.
3833 (2, _) if !self.other_modes.tlut_en => [0, 0, 0, 0],
3834 (2, 1) => {
3835 // CI8: 8-bit index into the TLUT.
3836 let ci = self.tmem_byte(((off8 & 0x7FF) ^ swap) as usize);
3837 self.tlut_lookup(u32::from(ci))
3838 }
3839 (2, 0) => {
3840 // CI4: 4-bit index + tile.palette as the high nibble.
3841 let nib = self.nibble_at(((off4 & 0x7FF) ^ swap) as usize, s);
3842 // `palette` is already 4-bit from `set_tile` decode; mask defensively
3843 // so a directly-constructed descriptor cannot push `ci` out of range.
3844 let ci = u32::from(nib) | (u32::from(tile.palette & 0xF) << 4);
3845 self.tlut_lookup(ci)
3846 }
3847 _ => [0, 0, 0, 0],
3848 }
3849 }
3850
3851 /// Read a big-endian `u16` from TMEM at byte offset `b` (both bytes masked
3852 /// into the 4 KiB space; `b + 1` wraps rather than overflowing).
3853 fn tmem_u16(&self, b: u32) -> u32 {
3854 (u32::from(self.tmem_byte(b as usize)) << 8)
3855 | u32::from(self.tmem_byte(b.wrapping_add(1) as usize))
3856 }
3857
3858 /// Select the 4-bit nibble of the TMEM byte at `byte_off` for texel column
3859 /// `s`: the high nibble for even `s`, the low nibble for odd `s`.
3860 fn nibble_at(&self, byte_off: usize, s: u32) -> u8 {
3861 let byte = self.tmem_byte(byte_off);
3862 (byte >> ((!s & 1) * 4)) & 0xF
3863 }
3864
3865 /// Look up a TLUT entry by 8-bit index `ci` and decode it as RGBA5551.
3866 ///
3867 /// Entry `ci` is the quadrupled 16-bit word at TMEM byte `0x800 + ci*8` (the
3868 /// four copies are identical after `Load TLUT`, so the first is read). The
3869 /// `IA16` TLUT type (Other Modes `tlut_type = 1`) is deferred; RGBA16 is
3870 /// assumed.
3871 fn tlut_lookup(&self, ci: u32) -> [u8; 4] {
3872 decode_rgba16(self.tmem_u16(TMEM_HIGH.wrapping_add(ci.wrapping_mul(8))))
3873 }
3874
3875 /// Read one byte of TMEM.
3876 ///
3877 /// `offset` is a **byte** address (0..[`TMEM_SIZE`]), masked into the 4 KiB
3878 /// space — *not* the 64-bit-word address that `Set Tile`'s `tmem_addr` /
3879 /// `line` use; a word address must be multiplied by 8 first (word 0x100 =
3880 /// byte 0x800). An unwritten (lazily-unallocated) TMEM reads as zero.
3881 #[must_use]
3882 pub fn tmem_byte(&self, offset: usize) -> u8 {
3883 self.tmem
3884 .as_ref()
3885 .map_or(0, |t| t[offset & (TMEM_SIZE - 1)])
3886 }
3887}
3888
3889/// Returns the crate version string.
3890#[must_use]
3891pub const fn version() -> &'static str {
3892 env!("CARGO_PKG_VERSION")
3893}
3894
3895#[cfg(test)]
3896mod tests {
3897 use super::*;
3898 use alloc::vec::Vec;
3899
3900 /// Every early-out of [`Rdp::tick_without_bus`], exercised here rather than only
3901 /// through `Bus::rdp_tick` in another crate.
3902 ///
3903 /// Each branch is the reason a step can skip the 344-byte `core::mem::take`, so a
3904 /// branch that stopped firing would be a silent performance regression, and one
3905 /// that fired when it should not would be a **correctness** regression — a skipped
3906 /// command. Neither shows up in the conformance vectors as long as the totals
3907 /// happen to work out, which is why they are pinned individually.
3908 #[test]
3909 fn every_bus_free_early_out_fires_on_its_own_condition() {
3910 // Frozen: no bus needed, and the stall is *not* touched — a frozen pipeline
3911 // does not burn a GCLK.
3912 let mut rdp = Rdp::new();
3913 rdp.status |= DP_STATUS_FREEZE;
3914 rdp.stall = 5;
3915 rdp.cmd_current = 0;
3916 rdp.cmd_end = 0x100;
3917 assert!(rdp.tick_without_bus().is_none(), "frozen: no bus");
3918 assert_eq!(rdp.stall, 5, "a frozen pipeline does not count down");
3919
3920 // XBUS (DMEM-sourced, not yet wired) takes the same exit.
3921 let mut rdp = Rdp::new();
3922 rdp.status |= DP_STATUS_XBUS;
3923 rdp.cmd_end = 0x100;
3924 assert!(rdp.tick_without_bus().is_none(), "xbus: no bus");
3925
3926 // Stalling: no bus, and exactly one GCLK burned.
3927 let mut rdp = Rdp::new();
3928 rdp.stall = 2;
3929 rdp.cmd_end = 0x100;
3930 assert!(rdp.tick_without_bus().is_none(), "stalled: no bus");
3931 assert_eq!(rdp.stall, 1, "one GCLK per step");
3932 assert!(rdp.tick_without_bus().is_none(), "still stalled");
3933 assert_eq!(rdp.stall, 0, "and again");
3934
3935 // Empty FIFO: no bus, nothing mutated.
3936 let mut rdp = Rdp::new();
3937 rdp.cmd_current = 0x40;
3938 rdp.cmd_end = 0x40;
3939 assert!(rdp.tick_without_bus().is_none(), "empty FIFO: no bus");
3940
3941 // And the one case that DOES need the bus: unfrozen, unstalled, non-empty.
3942 let mut rdp = Rdp::new();
3943 rdp.cmd_current = 0;
3944 rdp.cmd_end = 0x100;
3945 assert!(
3946 rdp.tick_without_bus().is_some(),
3947 "a queued command needs RDRAM to decode its opcode"
3948 );
3949 }
3950
3951 struct NullBus;
3952 impl RdramBus for NullBus {
3953 fn rdram_read(&self, _addr: u32) -> u8 {
3954 0
3955 }
3956 fn rdram_write(&mut self, _addr: u32, _val: u8) {}
3957 }
3958 impl VideoBus for NullBus {}
3959
3960 #[test]
3961 fn empty_fifo_tick_is_noop() {
3962 let mut rdp = Rdp::new();
3963 let mut bus = NullBus;
3964 rdp.tick(&mut bus);
3965 assert_eq!(rdp.cmd_current, 0);
3966 }
3967
3968 #[test]
3969 fn version_is_non_empty() {
3970 assert!(!version().is_empty());
3971 }
3972
3973 /// **`DPC_STATUS` writes are set/clear commands.** `SET_FREEZE` (0x8) raises
3974 /// FREEZE; `CLEAR_FREEZE` (0x4) drops it. n64-systemtest's `RDP START & END
3975 /// REG` freezes the DP precisely so it can poke the registers.
3976 #[test]
3977 fn status_write_sets_and_clears_freeze() {
3978 let mut rdp = Rdp::new();
3979 rdp.dpc_write(3, 0x8); // SET_FREEZE
3980 assert_ne!(rdp.dpc_read(3) & DP_STATUS_FREEZE, 0, "freeze set");
3981 rdp.dpc_write(3, 0x4); // CLEAR_FREEZE
3982 assert_eq!(rdp.dpc_read(3) & DP_STATUS_FREEZE, 0, "freeze cleared");
3983 }
3984
3985 /// **`DPC_START`/`END` mask to a 24-bit, 8-aligned address**, and writing
3986 /// `END` copies the latched start into `CURRENT`.
3987 #[test]
3988 fn start_end_mask_and_current_follows_start() {
3989 let mut rdp = Rdp::new();
3990 rdp.dpc_write(3, 0x8); // freeze
3991 rdp.dpc_write(0, 0x12FF_FFFF); // START
3992 rdp.dpc_write(1, 0x12FF_FFFF); // END
3993 assert_eq!(rdp.dpc_read(0), 0x00FF_FFF8, "START masked");
3994 assert_eq!(rdp.dpc_read(1), 0x00FF_FFF8, "END masked");
3995 assert_eq!(rdp.dpc_read(2), 0x00FF_FFF8, "CURRENT = START after END");
3996 }
3997
3998 /// **The `START_VALID` double-latch.** Writing START sets `START_VALID`; a
3999 /// second write while valid is *ignored*; writing END consumes it (clears
4000 /// `START_VALID`, leaves `END_VALID` clear while frozen). This is the exact
4001 /// sequence `RSP STATUS: start-valid` walks.
4002 #[test]
4003 fn start_valid_latch_ignores_a_second_start_write() {
4004 let mut rdp = Rdp::new();
4005 rdp.dpc_write(3, 0x8); // freeze
4006 assert_eq!(rdp.dpc_read(3) & DP_STATUS_START_VALID, 0, "clear at entry");
4007
4008 rdp.dpc_write(0, 0x1238); // START
4009 assert_ne!(
4010 rdp.dpc_read(3) & DP_STATUS_START_VALID,
4011 0,
4012 "set after write"
4013 );
4014 assert_eq!(rdp.dpc_read(0), 0x1238);
4015
4016 rdp.dpc_write(0, 0x12_3450); // ignored while valid
4017 assert_eq!(rdp.dpc_read(0), 0x1238, "second START write ignored");
4018
4019 rdp.dpc_write(1, 0x1238); // END consumes the latch
4020 assert_eq!(rdp.dpc_read(3) & DP_STATUS_START_VALID, 0, "cleared by END");
4021 assert_eq!(
4022 rdp.dpc_read(3) & DP_STATUS_END_VALID,
4023 0,
4024 "END_VALID clear while frozen"
4025 );
4026 assert_eq!(rdp.dpc_read(2), 0x1238, "CURRENT = START");
4027 }
4028
4029 /// **An END-only write is an incremental transfer: `CURRENT` is not
4030 /// rewound.** With `START_VALID` clear (the first transfer already
4031 /// consumed), writing a new END extends the buffer from where the DMA
4032 /// stopped — reloading `CURRENT` from `START` would reprocess commands
4033 /// already transferred (N64brew *Interface*, `DPC_END`: "If `START_PENDING`
4034 /// is 0, the write is considered an incremental transfer").
4035 #[test]
4036 fn an_end_only_write_extends_without_rewinding_current() {
4037 let mut rdp = Rdp::new();
4038 rdp.dpc_write(3, 0x8); // freeze
4039 rdp.dpc_write(0, 0x1000); // START
4040 rdp.dpc_write(1, 0x1000); // END consumes START -> START_VALID clear
4041 rdp.cmd_current = 0x1000; // pretend the transfer reached the end
4042
4043 rdp.dpc_write(1, 0x1040); // incremental END, no new START
4044 assert_eq!(rdp.dpc_read(1), 0x1040, "END extended");
4045 assert_eq!(rdp.dpc_read(2), 0x1000, "CURRENT not rewound to START");
4046 }
4047
4048 /// **A frozen DP does not advance the FIFO**, so registers stay put even
4049 /// with `cmd_current < cmd_end`.
4050 #[test]
4051 fn a_frozen_dp_does_not_tick() {
4052 let mut rdp = Rdp::new();
4053 rdp.status = DP_STATUS_FREEZE;
4054 rdp.cmd_current = 0x10;
4055 rdp.cmd_end = 0x40;
4056 let mut bus = NullBus;
4057 rdp.tick(&mut bus);
4058 assert_eq!(rdp.cmd_current, 0x10, "frozen: CURRENT unchanged");
4059 }
4060
4061 /// A bus backed by a byte buffer, so the decoder can walk a real command
4062 /// list out of "RDRAM" and we can assert the pointer lands exactly on
4063 /// `DPC_END`.
4064 struct SliceBus {
4065 mem: Vec<u8>,
4066 dp_raised: bool,
4067 }
4068 impl RdramBus for SliceBus {
4069 fn rdram_read(&self, addr: u32) -> u8 {
4070 self.mem.get(addr as usize).copied().unwrap_or(0)
4071 }
4072 fn rdram_write(&mut self, addr: u32, val: u8) {
4073 if let Some(b) = self.mem.get_mut(addr as usize) {
4074 *b = val;
4075 }
4076 }
4077 }
4078 impl VideoBus for SliceBus {
4079 fn raise_dp_interrupt(&mut self) {
4080 self.dp_raised = true;
4081 }
4082 }
4083
4084 /// Append a command: its opcode in bits 61:56 of the first word, then
4085 /// `words` total 64-bit words with the remainder zero-filled. The word count
4086 /// is supplied **explicitly by the caller**, independent of the production
4087 /// decoder, so a walk over the buffer is a genuine check of
4088 /// `command_len_words` rather than a tautology built from it.
4089 fn push_cmd(buf: &mut Vec<u8>, opcode: u8, words: u32) {
4090 buf.extend_from_slice(&(u32::from(opcode) << 24).to_be_bytes());
4091 for _ in 4..words * 8 {
4092 buf.push(0);
4093 }
4094 }
4095
4096 /// **The decoder consumes every command whole and never desyncs.** A mixed
4097 /// list exercising all three length classes — a 1-word set-state, a 22-word
4098 /// shade+texture+z triangle, a no-op, a 2-word texture rectangle, and
4099 /// `Sync Full` — drains one command per tick and lands `DPC_CURRENT` exactly
4100 /// on `DPC_END`. The expected lengths are stated here from the N64brew
4101 /// command map, so a wrong decoder length overshoots or stops short.
4102 #[test]
4103 fn decoder_consumes_each_command_whole_without_desync() {
4104 // (opcode, documented 64-bit-word length) — independent of the decoder.
4105 let fixtures = [
4106 (0x3F_u8, 1), // Set Color Image
4107 (0x0F, 22), // Fill Triangle (STZ) = shade + texture + z
4108 (0x00, 1), // No Operation
4109 (0x24, 2), // Texture Rectangle
4110 (0x29, 1), // Sync Full
4111 ];
4112 let mut mem = Vec::new();
4113 for &(op, words) in &fixtures {
4114 push_cmd(&mut mem, op, words);
4115 }
4116 let total = u32::try_from(mem.len()).unwrap();
4117 let mut bus = SliceBus {
4118 mem,
4119 dp_raised: false,
4120 };
4121 let mut rdp = Rdp::new();
4122 rdp.cmd_end = total;
4123
4124 let mut ticks = 0u32;
4125 while rdp.cmd_current < rdp.cmd_end && ticks < 1000 {
4126 rdp.tick(&mut bus);
4127 ticks += 1;
4128 }
4129 assert_eq!(rdp.cmd_current, total, "consumed exactly to DPC_END");
4130 assert_eq!(ticks, 5, "one command retired per scheduler tick");
4131 assert_eq!(rdp.commands_processed, 5, "every command counted");
4132 }
4133
4134 /// **A multi-word primitive is consumed in a single tick**, by its full
4135 /// decoded length — an unimplemented command advances the FIFO past all its
4136 /// words rather than treating each word as a fresh command.
4137 #[test]
4138 fn a_multiword_command_is_consumed_in_one_tick() {
4139 let mut mem = Vec::new();
4140 push_cmd(&mut mem, 0x0E, 20); // Fill Triangle (ST) = shade + texture: 20 words
4141 let mut bus = SliceBus {
4142 mem,
4143 dp_raised: false,
4144 };
4145 let mut rdp = Rdp::new();
4146 rdp.cmd_end = 20 * 8;
4147 rdp.tick(&mut bus);
4148 assert_eq!(rdp.cmd_current, 20 * 8, "whole 20-word triangle at once");
4149 assert_eq!(rdp.commands_processed, 1);
4150 }
4151
4152 /// **A partially-written command is not consumed until it is complete.** If
4153 /// `DPC_END` lands mid-command — as it does while the `rdpq` microcode fills
4154 /// the buffer and advances `DPC_END` incrementally — the decoder stalls
4155 /// rather than executing against unwritten RDRAM, then consumes the command
4156 /// whole once the rest of its words arrive.
4157 #[test]
4158 fn a_partial_command_is_not_consumed_until_complete() {
4159 let mut mem = Vec::new();
4160 push_cmd(&mut mem, 0x0F, 22); // 22-word triangle
4161 let mut bus = SliceBus {
4162 mem,
4163 dp_raised: false,
4164 };
4165 let mut rdp = Rdp::new();
4166 rdp.cmd_end = 10 * 8; // DPC_END only reached word 10 of 22
4167 rdp.tick(&mut bus);
4168 assert_eq!(rdp.cmd_current, 0, "stalled: partial command not consumed");
4169 assert_eq!(rdp.commands_processed, 0);
4170
4171 rdp.cmd_end = 22 * 8; // the rest of the command arrives
4172 rdp.tick(&mut bus);
4173 assert_eq!(rdp.cmd_current, 22 * 8, "consumed whole once complete");
4174 assert_eq!(rdp.commands_processed, 1);
4175 }
4176
4177 /// **XBUS mode reads commands from DMEM, which is not yet wired**, so the
4178 /// decoder must not mis-read RDRAM as the command stream. With `XBUS` set it
4179 /// stalls, leaving `DPC_CURRENT` and the counter untouched.
4180 #[test]
4181 fn xbus_mode_does_not_decode_rdram() {
4182 let mut mem = Vec::new();
4183 push_cmd(&mut mem, 0x3F, 1);
4184 let mut bus = SliceBus {
4185 mem,
4186 dp_raised: false,
4187 };
4188 let mut rdp = Rdp::new();
4189 rdp.status = DP_STATUS_XBUS;
4190 rdp.cmd_end = 8;
4191 rdp.tick(&mut bus);
4192 assert_eq!(rdp.cmd_current, 0, "XBUS: RDRAM not decoded");
4193 assert_eq!(rdp.commands_processed, 0);
4194 }
4195
4196 /// Drive a single command through `tick` and return the resulting state.
4197 fn run_one(opcode: u8) -> (Rdp, SliceBus) {
4198 let mut mem = Vec::new();
4199 push_cmd(&mut mem, opcode, 1);
4200 let mut bus = SliceBus {
4201 mem,
4202 dp_raised: false,
4203 };
4204 let mut rdp = Rdp::new();
4205 rdp.cmd_end = 8;
4206 rdp.tick(&mut bus);
4207 (rdp, bus)
4208 }
4209
4210 /// **`Sync Full` (0x29) raises the DP interrupt.** The dispatcher calls
4211 /// `raise_dp_interrupt` on the bus, which the live `Bus` turns into
4212 /// `MI_INTR.dp`; here the test bus records the raise.
4213 #[test]
4214 fn sync_full_raises_the_dp_interrupt() {
4215 let (rdp, bus) = run_one(OP_SYNC_FULL);
4216 assert!(bus.dp_raised, "Sync Full raised the DP interrupt");
4217 assert_eq!(rdp.commands_processed, 1);
4218 assert_eq!(rdp.stall, 0, "Sync Full does not stall the pipeline itself");
4219 }
4220
4221 /// **The other sync commands do not raise an interrupt** — only `Sync Full`
4222 /// does. They each set the documented fixed pipeline stall instead.
4223 #[test]
4224 fn sync_load_pipe_tile_set_the_documented_stall() {
4225 for (opcode, expected) in [
4226 (OP_SYNC_LOAD, SYNC_LOAD_GCLK),
4227 (OP_SYNC_PIPE, SYNC_PIPE_GCLK),
4228 (OP_SYNC_TILE, SYNC_TILE_GCLK),
4229 ] {
4230 let (rdp, bus) = run_one(opcode);
4231 assert!(!bus.dp_raised, "opcode {opcode:#04x} raised no interrupt");
4232 assert_eq!(rdp.stall, expected, "opcode {opcode:#04x} stall cycles");
4233 }
4234 }
4235
4236 /// **A sync stall holds the FIFO for exactly its GCLK count.** After a
4237 /// `Sync Pipe` (50 GCLK) the next command is not consumed until 50 further
4238 /// ticks have elapsed — the pipeline is unavailable for exactly that long,
4239 /// as the command is an unconditional fixed-length stall.
4240 #[test]
4241 fn a_sync_pipe_stall_holds_the_fifo_for_50_gclk() {
4242 let mut mem = Vec::new();
4243 push_cmd(&mut mem, OP_SYNC_PIPE, 1); // sets stall = 50
4244 push_cmd(&mut mem, 0x00, 1); // a following no-op
4245 let mut bus = SliceBus {
4246 mem,
4247 dp_raised: false,
4248 };
4249 let mut rdp = Rdp::new();
4250 rdp.cmd_end = 16;
4251
4252 rdp.tick(&mut bus); // consumes Sync Pipe, sets stall = 50
4253 assert_eq!(rdp.commands_processed, 1);
4254 assert_eq!(rdp.stall, SYNC_PIPE_GCLK);
4255
4256 // The next 50 ticks burn the stall and do not advance the FIFO.
4257 for i in 0..SYNC_PIPE_GCLK {
4258 rdp.tick(&mut bus);
4259 assert_eq!(rdp.commands_processed, 1, "still stalled at tick {i}");
4260 assert_eq!(rdp.stall, SYNC_PIPE_GCLK - 1 - i);
4261 }
4262 // Stall expired: the following command is consumed on the next tick.
4263 rdp.tick(&mut bus);
4264 assert_eq!(rdp.commands_processed, 2, "FIFO resumes after the stall");
4265 }
4266
4267 /// **A frozen DP does not burn stall cycles.** The freeze guard is checked
4268 /// before the stall countdown, so a non-zero `stall` is held — not
4269 /// decremented — while frozen, and resumes counting down only once the DP is
4270 /// unfrozen. The plain `a_frozen_dp_does_not_tick` test leaves `stall` at
4271 /// zero and so cannot catch a regression that decremented it under freeze.
4272 #[test]
4273 fn a_frozen_dp_holds_its_stall_countdown() {
4274 let mut rdp = Rdp::new();
4275 let mut bus = NullBus;
4276 rdp.stall = 10;
4277 rdp.status = DP_STATUS_FREEZE;
4278 rdp.tick(&mut bus);
4279 assert_eq!(rdp.stall, 10, "frozen: stall countdown held, not burned");
4280
4281 rdp.status = 0; // unfreeze
4282 rdp.tick(&mut bus);
4283 assert_eq!(rdp.stall, 9, "unfrozen: countdown resumes");
4284 }
4285
4286 /// **A preceding stall delays the `Sync Full` interrupt.** With `Sync Pipe`
4287 /// (50 GCLK) queued before `Sync Full`, the DP interrupt stays low for all
4288 /// 50 stall ticks and rises only once the stall drains and `Sync Full` is
4289 /// dispatched — the stall-before-interrupt ordering the dispatch doc claims.
4290 /// (Were the stall gate absent, `Sync Full` would dispatch on the very next
4291 /// tick and the interrupt would rise during the loop.)
4292 #[test]
4293 fn a_preceding_stall_delays_the_sync_full_interrupt() {
4294 let mut mem = Vec::new();
4295 push_cmd(&mut mem, OP_SYNC_PIPE, 1);
4296 push_cmd(&mut mem, OP_SYNC_FULL, 1);
4297 let mut bus = SliceBus {
4298 mem,
4299 dp_raised: false,
4300 };
4301 let mut rdp = Rdp::new();
4302 rdp.cmd_end = 16;
4303
4304 rdp.tick(&mut bus); // consume Sync Pipe -> stall = 50
4305 assert_eq!(rdp.stall, SYNC_PIPE_GCLK);
4306 assert!(!bus.dp_raised, "no interrupt while the stall is set");
4307
4308 for i in 0..SYNC_PIPE_GCLK {
4309 rdp.tick(&mut bus);
4310 assert!(!bus.dp_raised, "interrupt still low during stall tick {i}");
4311 }
4312 // Stall drained: the next tick dispatches Sync Full and raises.
4313 rdp.tick(&mut bus);
4314 assert!(
4315 bus.dp_raised,
4316 "interrupt raised only after the stall drains"
4317 );
4318 assert_eq!(rdp.commands_processed, 2);
4319 }
4320
4321 // --- The FILL pipeline (T-31-003) ---
4322
4323 // The command list lives here; the color image is based at RDRAM 0, well
4324 // below it, so the two never overlap in the shared test buffer.
4325 const CMD_BASE: u32 = 0x4000;
4326
4327 fn push_word(buf: &mut Vec<u8>, hi: u32, lo: u32) {
4328 buf.extend_from_slice(&hi.to_be_bytes());
4329 buf.extend_from_slice(&lo.to_be_bytes());
4330 }
4331
4332 // Command builders. Screen coordinates are given in whole pixels; the wire
4333 // format is u10.2, so each is shifted left by two.
4334 fn set_color_image(format: u32, size: u32, width: u32, addr: u32) -> (u32, u32) {
4335 let hi =
4336 (u32::from(OP_SET_COLOR_IMAGE) << 24) | (format << 21) | (size << 19) | (width - 1);
4337 (hi, addr)
4338 }
4339 fn set_fill_color(color: u32) -> (u32, u32) {
4340 (u32::from(OP_SET_FILL_COLOR) << 24, color)
4341 }
4342 fn set_scissor(ulx: u32, uly: u32, lrx: u32, lry: u32) -> (u32, u32) {
4343 let hi = (u32::from(OP_SET_SCISSOR) << 24) | (ulx << 14) | (uly << 2);
4344 let lo = (lrx << 14) | (lry << 2);
4345 (hi, lo)
4346 }
4347 fn fill_rect(ulx: u32, uly: u32, lrx: u32, lry: u32) -> (u32, u32) {
4348 let hi = (u32::from(OP_FILL_RECTANGLE) << 24) | (lrx << 14) | (lry << 2);
4349 let lo = (ulx << 14) | (uly << 2);
4350 (hi, lo)
4351 }
4352 /// `Set Other Modes` selecting **FILL** cycle type (`cycle_type` is command
4353 /// bits 53:52 = word-0 bits 21:20). Required by every fill-register test: in
4354 /// 1-/2-cycle mode a rectangle goes through the combiner instead and never
4355 /// reads the fill register at all (ledger R-21).
4356 fn set_cycle_fill() -> (u32, u32) {
4357 (
4358 (u32::from(OP_SET_OTHER_MODES) << 24) | (u32::from(CYCLE_TYPE_FILL) << 20),
4359 0,
4360 )
4361 }
4362
4363 /// Run a command list through the FIFO (color image at RDRAM 0, commands at
4364 /// `CMD_BASE`) and return the RDP plus the memory the fill wrote into.
4365 fn run_commands(words: &[(u32, u32)]) -> (Rdp, SliceBus) {
4366 let mut mem = alloc::vec![0u8; CMD_BASE as usize + words.len() * 8];
4367 let mut list = Vec::new();
4368 for &(hi, lo) in words {
4369 push_word(&mut list, hi, lo);
4370 }
4371 mem[CMD_BASE as usize..CMD_BASE as usize + list.len()].copy_from_slice(&list);
4372 let mut bus = SliceBus {
4373 mem,
4374 dp_raised: false,
4375 };
4376 let mut rdp = Rdp::new();
4377 rdp.cmd_current = CMD_BASE;
4378 rdp.cmd_end = CMD_BASE + u32::try_from(list.len()).unwrap();
4379 let mut guard = 0;
4380 while rdp.cmd_current < rdp.cmd_end && guard < 10_000 {
4381 rdp.tick(&mut bus);
4382 guard += 1;
4383 }
4384 (rdp, bus)
4385 }
4386
4387 /// **`Set Color Image` parses format, size, width, and address.** Width is
4388 /// the encoded field plus one; the address is masked to 24 bits.
4389 #[test]
4390 fn set_color_image_parses_its_fields() {
4391 let (rdp, _) = run_commands(&[set_color_image(0, 3, 320, 0x0010_0000)]);
4392 assert_eq!(rdp.color_image_format, 0);
4393 assert_eq!(rdp.color_image_size, 3);
4394 assert_eq!(rdp.color_image_width, 320);
4395 assert_eq!(rdp.color_image, 0x0010_0000);
4396 }
4397
4398 /// **`Set Key GB`/`Set Key R` decode the chroma-key center/scale/width per channel
4399 /// (R-10).** Pins the bit-layout ported from Angrylion `rdp_set_key_gb`/`_r`: GB
4400 /// word-0 is `width_g[23:12] width_b[11:0]`, word-1 `center_g[31:24] scale_g[23:16]
4401 /// center_b[15:8] scale_b[7:0]`; R word-1 is `width_r[27:16] center_r[15:8]
4402 /// scale_r[7:0]`. Distinct per-channel values (incl. distinct 12-bit widths) so a
4403 /// field-swap or wrong extraction in the decode is caught.
4404 #[test]
4405 fn set_key_decodes_center_scale_and_width_per_channel() {
4406 let (rdp, _) = run_commands(&[
4407 // Set Key GB: wg=0x111 wb=0x222; cg=0x40 sg=0x80 cb=0x60 sb=0xC0.
4408 (0x2A11_1222, 0x4080_60C0),
4409 // Set Key R: wr=0x333; cr=0x20 sr=0x40.
4410 (0x2B00_0000, 0x0333_2040),
4411 ]);
4412 assert_eq!(rdp.key_center, [0x20, 0x40, 0x60], "center [r, g, b]");
4413 assert_eq!(rdp.key_scale, [0x40, 0x80, 0xC0], "scale [r, g, b]");
4414 assert_eq!(rdp.key_width, [0x333, 0x111, 0x222], "width [r, g, b]");
4415 }
4416
4417 /// **`Set Fill Color` and `Set Scissor` store their values.**
4418 #[test]
4419 fn set_fill_color_and_scissor_store_state() {
4420 let (rdp, _) = run_commands(&[set_fill_color(0xDEAD_BEEF), set_scissor(2, 3, 6, 7)]);
4421 assert_eq!(rdp.fill_color, 0xDEAD_BEEF);
4422 assert_eq!(rdp.scissor_ulx, 2 << 2);
4423 assert_eq!(rdp.scissor_uly, 3 << 2);
4424 assert_eq!(rdp.scissor_lrx, 6 << 2);
4425 assert_eq!(rdp.scissor_lry, 7 << 2);
4426 }
4427
4428 /// **A 32-bit FILL writes the whole color to every pixel**, four bytes
4429 /// each, big-endian — the memory is the fill value repeated verbatim.
4430 #[test]
4431 fn fill_rectangle_32bpp_writes_the_color_verbatim() {
4432 let (_, bus) = run_commands(&[
4433 set_cycle_fill(),
4434 set_color_image(0, 3, 4, 0), // 32-bit, width 4, base 0
4435 set_fill_color(0xAABB_CCDD),
4436 set_scissor(0, 0, 4, 2),
4437 fill_rect(0, 0, 4, 2),
4438 ]);
4439 // 4 px * 2 rows * 4 bytes = 32 bytes, all AA BB CC DD.
4440 for chunk in bus.mem[0..32].chunks_exact(4) {
4441 assert_eq!(chunk, [0xAA, 0xBB, 0xCC, 0xDD]);
4442 }
4443 assert_eq!(bus.mem[32], 0, "nothing written past the rectangle");
4444 }
4445
4446 /// **A 16-bit FILL alternates the color's halves per pixel** — even pixels
4447 /// take the upper 16 bits, odd pixels the lower — so memory is still the
4448 /// 32-bit value repeated.
4449 #[test]
4450 fn fill_rectangle_16bpp_alternates_halves() {
4451 let (_, bus) = run_commands(&[
4452 set_cycle_fill(),
4453 set_color_image(0, 2, 4, 0), // 16-bit, width 4
4454 set_fill_color(0xAABB_CCDD),
4455 set_scissor(0, 0, 4, 1),
4456 fill_rect(0, 0, 4, 1),
4457 ]);
4458 // px0 even -> AABB, px1 odd -> CCDD, px2 -> AABB, px3 -> CCDD.
4459 assert_eq!(
4460 bus.mem[0..8],
4461 [0xAA, 0xBB, 0xCC, 0xDD, 0xAA, 0xBB, 0xCC, 0xDD]
4462 );
4463 }
4464
4465 /// **An 8-bit FILL writes one of the four color bytes per pixel**, cycling
4466 /// every four pixels.
4467 #[test]
4468 fn fill_rectangle_8bpp_cycles_four_bytes() {
4469 let (_, bus) = run_commands(&[
4470 set_cycle_fill(),
4471 set_color_image(4, 1, 4, 0), // 8-bit (I8), width 4
4472 set_fill_color(0xAABB_CCDD),
4473 set_scissor(0, 0, 4, 1),
4474 fill_rect(0, 0, 4, 1),
4475 ]);
4476 assert_eq!(bus.mem[0..4], [0xAA, 0xBB, 0xCC, 0xDD]);
4477 }
4478
4479 /// **The scissor clips the fill, with an asymmetric lower-right** (ledger R-15,
4480 /// Angrylion-oracle-confirmed). A rectangle larger than the scissor writes only
4481 /// the scissored region: the **X** lower-right is **inclusive** of its boundary
4482 /// pixel (column 6 for `xl = 6.0`), while the **Y** lower-right is **exclusive**
4483 /// (row 3 for `yl = 3.0` stays clear). The pixel one column past the inclusive X
4484 /// edge (column 7) stays clear. (An earlier version of this test asserted an
4485 /// *exclusive* X edge — that was self-authored and never oracle-checked; the
4486 /// scissor-clip fuzz corpus corrected it.)
4487 #[test]
4488 fn fill_rectangle_is_clipped_to_the_scissor() {
4489 // 32-bit, width 8. Scissor keeps x in [2,6] (inclusive), y in [1,3) (excl).
4490 let (_, bus) = run_commands(&[
4491 set_cycle_fill(),
4492 set_color_image(0, 3, 8, 0),
4493 set_fill_color(0x1122_3344),
4494 set_scissor(2, 1, 6, 3),
4495 fill_rect(0, 0, 8, 4), // larger than the scissor on every side
4496 ]);
4497 let px = |x: u32, y: u32| {
4498 let a = (y * 8 + x) as usize * 4;
4499 &bus.mem[a..a + 4]
4500 };
4501 // Inside the scissor: written.
4502 assert_eq!(px(2, 1), [0x11, 0x22, 0x33, 0x44], "inside top-left");
4503 assert_eq!(px(5, 2), [0x11, 0x22, 0x33, 0x44], "inside");
4504 // The X lower-right boundary pixel (column 6) IS drawn (inclusive).
4505 assert_eq!(px(6, 1), [0x11, 0x22, 0x33, 0x44], "right edge inclusive");
4506 assert_eq!(px(6, 2), [0x11, 0x22, 0x33, 0x44], "right edge inclusive");
4507 // Outside each edge: clear.
4508 assert_eq!(px(1, 1), [0, 0, 0, 0], "left of scissor");
4509 assert_eq!(
4510 px(7, 1),
4511 [0, 0, 0, 0],
4512 "one column past the inclusive X edge"
4513 );
4514 assert_eq!(px(2, 0), [0, 0, 0, 0], "above scissor");
4515 assert_eq!(px(2, 3), [0, 0, 0, 0], "lower edge exclusive");
4516 }
4517
4518 /// **The rectangle's own lower-right edge is inclusive** (ledger R-3, oracle
4519 /// R). With a scissor larger than the rectangle so the scissor does not clip,
4520 /// a `Fill Rectangle` from `(1,1)` to `(3,4)` must draw the pixel *at* `(3,4)`
4521 /// — the pixel containing the lower-right coordinate — and leave the pixels one
4522 /// step past it (`(4,·)` on X, `(·,5)` on Y) clear. This is the Angrylion
4523 /// convention the fuzz corpus pinned: the pre-fix `(coord + 3) >> 2` half-open
4524 /// span dropped the boundary row and column, so reverting the fix makes the
4525 /// `(3,4)` assertion fail (mutation-checked).
4526 #[test]
4527 fn fill_rectangle_lower_right_edge_is_inclusive() {
4528 let (_, bus) = run_commands(&[
4529 set_cycle_fill(),
4530 set_color_image(0, 3, 8, 0), // 32-bit, width 8
4531 set_fill_color(0x1122_3344),
4532 set_scissor(0, 0, 8, 8), // larger than the rect: does not clip it
4533 fill_rect(1, 1, 3, 4),
4534 ]);
4535 let px = |x: u32, y: u32| {
4536 let a = (y * 8 + x) as usize * 4;
4537 &bus.mem[a..a + 4]
4538 };
4539 // The inclusive corners are drawn.
4540 assert_eq!(px(1, 1), [0x11, 0x22, 0x33, 0x44], "upper-left drawn");
4541 assert_eq!(
4542 px(3, 4),
4543 [0x11, 0x22, 0x33, 0x44],
4544 "lower-right pixel drawn"
4545 );
4546 // One step past the inclusive edge is clear.
4547 assert_eq!(px(4, 4), [0, 0, 0, 0], "one column past the right edge");
4548 assert_eq!(px(3, 5), [0, 0, 0, 0], "one row past the lower edge");
4549 // One step before the upper-left is clear.
4550 assert_eq!(px(0, 1), [0, 0, 0, 0], "one column before the left edge");
4551 assert_eq!(px(1, 0), [0, 0, 0, 0], "one row above the top edge");
4552 }
4553
4554 /// **A 4-bit color image is not a FILL target** — the real RDP crashes, so
4555 /// the fill is skipped and no memory is written.
4556 #[test]
4557 fn fill_rectangle_4bit_target_writes_nothing() {
4558 let (_, bus) = run_commands(&[
4559 set_color_image(0, 0, 4, 0), // 4-bit
4560 set_fill_color(0xFFFF_FFFF),
4561 set_scissor(0, 0, 4, 2),
4562 fill_rect(0, 0, 4, 2),
4563 ]);
4564 assert!(bus.mem[0..16].iter().all(|&b| b == 0), "no fill at 4-bit");
4565 }
4566
4567 /// **Degenerate and empty rectangles write nothing.** An inverted rectangle
4568 /// (`ulx > lrx`), an inverted scissor (`ul* > lr*`), and a rectangle disjoint
4569 /// from the scissor each yield an empty pixel span, so no pixel is written.
4570 /// The span emptiness — not the early-return, which is a redundant fast path
4571 /// over Rust's empty `for` ranges — is what these assert.
4572 #[test]
4573 fn fill_rectangle_degenerate_bounds_write_nothing() {
4574 // Inverted rectangle: upper-left past lower-right.
4575 let (_, bus) = run_commands(&[
4576 set_color_image(0, 3, 8, 0),
4577 set_fill_color(0xFFFF_FFFF),
4578 set_scissor(0, 0, 8, 4),
4579 fill_rect(6, 3, 2, 1), // ulx>lrx, uly>lry
4580 ]);
4581 assert!(bus.mem[0..128].iter().all(|&b| b == 0), "inverted rect");
4582
4583 // Inverted scissor: upper-left past lower-right.
4584 let (_, bus) = run_commands(&[
4585 set_color_image(0, 3, 8, 0),
4586 set_fill_color(0xFFFF_FFFF),
4587 set_scissor(6, 3, 2, 1), // sulx>slrx, suly>slry
4588 fill_rect(0, 0, 8, 4),
4589 ]);
4590 assert!(bus.mem[0..128].iter().all(|&b| b == 0), "inverted scissor");
4591
4592 // Rectangle entirely to the left of the scissor: empty intersection.
4593 let (_, bus) = run_commands(&[
4594 set_color_image(0, 3, 8, 0),
4595 set_fill_color(0xFFFF_FFFF),
4596 set_scissor(4, 0, 8, 4),
4597 fill_rect(0, 0, 3, 4), // rx1 = 3 <= scissor sx0 = 4
4598 ]);
4599 assert!(bus.mem[0..128].iter().all(|&b| b == 0), "disjoint rect");
4600 }
4601
4602 /// **A fill with no configured width writes nothing.** `color_image_width`
4603 /// is 0 only before `Set Color Image` (which always yields field + 1 ≥ 1);
4604 /// with a valid pixel size but zero width the guard skips the fill rather
4605 /// than smearing every row onto offset 0 with a zero stride. Reached here by
4606 /// setting the state directly, since the command stream cannot produce it.
4607 #[test]
4608 fn fill_rectangle_without_a_valid_width_writes_nothing() {
4609 let mut mem = alloc::vec![0u8; CMD_BASE as usize + 8];
4610 let (hi, lo) = fill_rect(0, 0, 4, 4);
4611 mem[CMD_BASE as usize..CMD_BASE as usize + 4].copy_from_slice(&hi.to_be_bytes());
4612 mem[CMD_BASE as usize + 4..CMD_BASE as usize + 8].copy_from_slice(&lo.to_be_bytes());
4613 let mut bus = SliceBus {
4614 mem,
4615 dp_raised: false,
4616 };
4617 let mut rdp = Rdp::new();
4618 rdp.color_image_size = 3; // valid 32-bit size, but width left at 0
4619 rdp.fill_color = 0xFFFF_FFFF;
4620 rdp.scissor_lrx = 4 << 2;
4621 rdp.scissor_lry = 4 << 2;
4622 rdp.cmd_current = CMD_BASE;
4623 rdp.cmd_end = CMD_BASE + 8;
4624 rdp.tick(&mut bus);
4625 assert!(bus.mem[0..64].iter().all(|&b| b == 0), "width 0: no write");
4626 }
4627
4628 // ---- T-32-001: texture state (TMEM, tile descriptors, state commands) ----
4629
4630 /// **TMEM is zero at power-on and unallocated.** The lazy `Option<Box<..>>`
4631 /// reads as all-zero while `None`, so a fresh RDP sees a blank TMEM without
4632 /// having paid a 4 KiB allocation.
4633 #[test]
4634 fn tmem_is_zero_and_unallocated_at_power_on() {
4635 let rdp = Rdp::new();
4636 assert!(rdp.tmem.is_none(), "no TMEM box allocated at power-on");
4637 assert_eq!(rdp.tmem_byte(0), 0);
4638 assert_eq!(rdp.tmem_byte(0x800), 0, "high half zero too");
4639 assert_eq!(rdp.tmem_byte(TMEM_SIZE - 1), 0, "last byte zero");
4640 }
4641
4642 /// **`Set Tile` (0x35) decodes every field into the addressed descriptor.**
4643 /// Each field is seeded with a distinct value so a swapped bit range shows
4644 /// up as a wrong field, not a coincidental match. Word built here from the
4645 /// N64brew field table, independent of the decoder.
4646 #[test]
4647 fn set_tile_decodes_all_fields() {
4648 // format=3 size=2 line=0x1F addr=0x100 | index=5 palette=0xA
4649 // clamp_t=1 mirror_t=0 mask_t=7 shift_t=3 | clamp_s=0 mirror_s=1
4650 // mask_s=5 shift_s=9
4651 let mut rdp = Rdp::new();
4652 rdp.set_tile(0x3570_3F00, 0x05A9_CD59);
4653 let expected = TileDescriptor {
4654 format: 3,
4655 size: 2,
4656 line: 0x1F,
4657 tmem_addr: 0x100,
4658 palette: 0xA,
4659 clamp_t: true,
4660 mirror_t: false,
4661 mask_t: 7,
4662 shift_t: 3,
4663 clamp_s: false,
4664 mirror_s: true,
4665 mask_s: 5,
4666 shift_s: 9,
4667 sl: 0,
4668 tl: 0,
4669 sh: 0,
4670 th: 0,
4671 };
4672 assert_eq!(rdp.tiles[5], expected, "descriptor 5 fully decoded");
4673 // Only the addressed descriptor is touched.
4674 for (i, t) in rdp.tiles.iter().enumerate() {
4675 if i != 5 {
4676 assert_eq!(*t, TileDescriptor::default(), "tile {i} untouched");
4677 }
4678 }
4679 }
4680
4681 /// **`Set Tile Size` (0x32) decodes SL/TL/SH/TH for the addressed
4682 /// descriptor** and leaves the format/addressing fields alone.
4683 #[test]
4684 fn set_tile_size_decodes_coords() {
4685 let mut rdp = Rdp::new();
4686 // Seed descriptor 2's addressing fields to non-zero first, so the
4687 // preservation check is real: if Set Tile Size wrongly cleared them this
4688 // catches it, whereas asserting `== 0` on a fresh descriptor could not.
4689 rdp.set_tile(0x3570_3F00, 0x02A9_CD59); // index 2: format=3 size=2 line=0x1F addr=0x100
4690 // SL=0x123 TL=0x045 | index=2 SH=0x678 TH=0x0AB
4691 rdp.set_tile_size(0x3212_3045, 0x0267_80AB);
4692 let t = rdp.tiles[2];
4693 assert_eq!(
4694 (t.sl, t.tl, t.sh, t.th),
4695 (0x123, 0x045, 0x678, 0x0AB),
4696 "coords updated"
4697 );
4698 assert_eq!(t.format, 3, "Set Tile Size preserves format");
4699 assert_eq!(t.size, 2, "preserves size");
4700 assert_eq!(t.line, 0x1F, "preserves line");
4701 assert_eq!(t.tmem_addr, 0x100, "preserves tmem_addr");
4702 }
4703
4704 /// **`Set Tile` preserves the tile-size coords** — the two commands write
4705 /// disjoint parts of the same descriptor, so a `Set Tile` after a `Set Tile
4706 /// Size` must not clear SL/TL/SH/TH (the `..self.tiles[index]` spread).
4707 #[test]
4708 fn set_tile_preserves_tile_size_coords() {
4709 let mut rdp = Rdp::new();
4710 rdp.set_tile_size(0x3212_3045, 0x0267_80AB); // seeds tiles[2] coords
4711 rdp.set_tile(0x3570_3F00, 0x02A9_CD59); // index bits -> descriptor 2
4712 let t = rdp.tiles[2];
4713 assert_eq!(t.format, 3, "Set Tile applied");
4714 assert_eq!(
4715 (t.sl, t.tl, t.sh, t.th),
4716 (0x123, 0x045, 0x678, 0x0AB),
4717 "tile-size coords survive a later Set Tile"
4718 );
4719 }
4720
4721 /// **`Set Texture Image` (0x3D) decodes format/size/width/addr.** Same field
4722 /// layout as Set Color Image; width is a field+1 pixel count.
4723 #[test]
4724 fn set_texture_image_decodes_fields() {
4725 // format=4 size=1 width_field=0x13F (-> 0x140) addr=0x654321
4726 let mut rdp = Rdp::new();
4727 let mut bus = NullBus;
4728 rdp.dispatch(OP_SET_TEXTURE_IMAGE, 0x3D88_013F, 0x0065_4321, 0, &mut bus);
4729 assert_eq!(rdp.tex_image_format, 4);
4730 assert_eq!(rdp.tex_image_size, 1);
4731 assert_eq!(rdp.tex_image_width, 0x140, "width is field + 1");
4732 assert_eq!(rdp.tex_image_addr, 0x0065_4321);
4733 }
4734
4735 /// **The dispatcher routes 0x35 and 0x32 to the right handlers.** The
4736 /// field-level tests call `set_tile` / `set_tile_size` directly; this drives
4737 /// the actual `dispatch` entry point, so a mis-wired opcode arm (0x35 sent to
4738 /// the size handler, or 0x32 to the tile handler) is caught, not only a
4739 /// decode bug. The inputs distinguish the two: `Set Tile` sets `format = 3`
4740 /// which `Set Tile Size` must leave alone, and `Set Tile Size` sets coords
4741 /// that `Set Tile` does not.
4742 #[test]
4743 fn dispatch_routes_set_tile_and_set_tile_size() {
4744 let mut rdp = Rdp::new();
4745 let mut bus = NullBus;
4746 rdp.dispatch(OP_SET_TILE, 0x3570_3F00, 0x02A9_CD59, 0, &mut bus); // index 2
4747 assert_eq!(rdp.tiles[2].format, 3, "0x35 routed to set_tile");
4748 assert_eq!(rdp.tiles[2].line, 0x1F, "and decoded its fields");
4749 rdp.dispatch(OP_SET_TILE_SIZE, 0x3212_3045, 0x0267_80AB, 0, &mut bus); // index 2
4750 assert_eq!(
4751 (rdp.tiles[2].sl, rdp.tiles[2].th),
4752 (0x123, 0x0AB),
4753 "0x32 routed to set_tile_size"
4754 );
4755 assert_eq!(
4756 rdp.tiles[2].format, 3,
4757 "set_tile_size did not clobber the tile format (distinct routing)"
4758 );
4759 }
4760
4761 // ---- T-32-002: TMEM loads (Load Tile 0x34, Load Block 0x33) ----
4762
4763 /// **`Load Tile` copies a 16-bit row and latches the tile size.** Row 0 is
4764 /// even, so no odd-row swap applies; the four texels land verbatim, and the
4765 /// descriptor's `SL/TL/SH/TH` are updated for rendering.
4766 #[test]
4767 fn load_tile_16bit_copies_a_row_and_sets_size() {
4768 let mut mem = alloc::vec![0u8; 0x200];
4769 let src = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88];
4770 mem[0x100..0x108].copy_from_slice(&src);
4771 let bus = SliceBus {
4772 mem,
4773 dp_raised: false,
4774 };
4775 let mut rdp = Rdp::new();
4776 rdp.tiles[0].size = 2; // 16-bit
4777 rdp.tiles[0].line = 1; // 8 bytes/row
4778 rdp.tex_image_size = 2;
4779 rdp.tex_image_width = 4;
4780 rdp.tex_image_addr = 0x100;
4781 // SL=0 TL=0 index=0 SH=3 (field 0xC) TH=0.
4782 rdp.load_tile(0x0000_0000, 0x0000_C000, &bus);
4783 for (i, &b) in src.iter().enumerate() {
4784 assert_eq!(rdp.tmem_byte(i), b, "tmem[{i}]");
4785 }
4786 assert_eq!(
4787 (rdp.tiles[0].sl, rdp.tiles[0].sh),
4788 (0, 0xC),
4789 "Load Tile latches the tile size"
4790 );
4791 }
4792
4793 /// **`Load Tile` applies the odd-row 32-bit-word swap.** Row 1 is odd, so each
4794 /// texel's TMEM byte address gains bit 2 (a `^ 4`), swapping the two 32-bit
4795 /// halves of the 64-bit word, while row 0 is unswapped.
4796 #[test]
4797 fn load_tile_swaps_odd_rows() {
4798 let mut mem = alloc::vec![0u8; 0x200];
4799 let row0 = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7];
4800 let row1 = [0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7];
4801 mem[0x100..0x108].copy_from_slice(&row0);
4802 mem[0x108..0x110].copy_from_slice(&row1);
4803 let bus = SliceBus {
4804 mem,
4805 dp_raised: false,
4806 };
4807 let mut rdp = Rdp::new();
4808 rdp.tiles[0].size = 2;
4809 rdp.tiles[0].line = 1;
4810 rdp.tex_image_size = 2;
4811 rdp.tex_image_width = 4;
4812 rdp.tex_image_addr = 0x100;
4813 // SL=0 TL=0 SH=3 TH=1 (field 4) -> 4x2.
4814 rdp.load_tile(0x0000_0000, 0x0000_C004, &bus);
4815 for (i, &b) in row0.iter().enumerate() {
4816 assert_eq!(rdp.tmem_byte(i), b, "row 0 verbatim [{i}]");
4817 }
4818 // Row 1 base = line*8 = 8; texel s at (8 + s*2) ^ 4.
4819 assert_eq!((rdp.tmem_byte(0xC), rdp.tmem_byte(0xD)), (0xB0, 0xB1));
4820 assert_eq!((rdp.tmem_byte(0xE), rdp.tmem_byte(0xF)), (0xB2, 0xB3));
4821 assert_eq!((rdp.tmem_byte(0x8), rdp.tmem_byte(0x9)), (0xB4, 0xB5));
4822 assert_eq!((rdp.tmem_byte(0xA), rdp.tmem_byte(0xB)), (0xB6, 0xB7));
4823 }
4824
4825 /// **`Load Tile` splits a 32-bit texel across TMEM.** R,G go to the low half
4826 /// and B,A to the high half (offset 0x800) — the documented 32-bit layout.
4827 #[test]
4828 fn load_tile_32bit_splits_rg_low_ba_high() {
4829 let mut mem = alloc::vec![0u8; 0x200];
4830 mem[0x100..0x104].copy_from_slice(&[0x11, 0x22, 0x33, 0x44]); // R G B A
4831 let bus = SliceBus {
4832 mem,
4833 dp_raised: false,
4834 };
4835 let mut rdp = Rdp::new();
4836 rdp.tiles[0].size = 3; // 32-bit RGBA
4837 rdp.tiles[0].line = 1;
4838 rdp.tex_image_size = 3;
4839 rdp.tex_image_width = 1;
4840 rdp.tex_image_addr = 0x100;
4841 rdp.load_tile(0x0000_0000, 0x0000_0000, &bus); // 1x1
4842 assert_eq!(rdp.tmem_byte(0), 0x11, "R low half");
4843 assert_eq!(rdp.tmem_byte(1), 0x22, "G low half");
4844 assert_eq!(rdp.tmem_byte(0x800), 0x33, "B high half");
4845 assert_eq!(rdp.tmem_byte(0x801), 0x44, "A high half");
4846 }
4847
4848 /// **`Load Block` streams texels and pins both sides of the 2048 limit.** A
4849 /// small 8-bit block copies verbatim (dxt 0 → one line, no swap); a 2049-texel
4850 /// load writes nothing, while exactly 2048 loads.
4851 #[test]
4852 fn load_block_streams_and_enforces_the_limit() {
4853 let mut mem = alloc::vec![0u8; 0x200];
4854 mem[0x100..0x104].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
4855 let bus = SliceBus {
4856 mem,
4857 dp_raised: false,
4858 };
4859 let mut rdp = Rdp::new();
4860 rdp.tiles[0].size = 1; // 8-bit
4861 rdp.tex_image_size = 1;
4862 rdp.tex_image_width = 4;
4863 rdp.tex_image_addr = 0x100;
4864 // SL=0 index=0 SH=3 dxt=0.
4865 rdp.load_block(0x0000_0000, 0x0000_3000, &bus);
4866 for (i, &b) in [0xDE, 0xAD, 0xBE, 0xEF].iter().enumerate() {
4867 assert_eq!(rdp.tmem_byte(i), b, "block[{i}]");
4868 }
4869
4870 // Over the limit (count = 2049): nothing written / allocated.
4871 let mut over = Rdp::new();
4872 over.tiles[0].size = 1;
4873 over.tex_image_size = 1;
4874 over.tex_image_width = 4096;
4875 over.tex_image_addr = 0x100;
4876 over.load_block(0x0000_0000, 0x0080_0000, &bus); // SH field 2048
4877 assert!(
4878 over.tmem.is_none(),
4879 "a 2049-texel load writes nothing (over the 2048 limit)"
4880 );
4881
4882 // Exactly at the limit (count = 2048): loads.
4883 let mut edge = Rdp::new();
4884 edge.tiles[0].size = 1;
4885 edge.tex_image_size = 1;
4886 edge.tex_image_width = 4096;
4887 edge.tex_image_addr = 0x100;
4888 edge.load_block(0x0000_0000, 0x007F_F000, &bus); // SH field 2047 -> 2048 texels
4889 assert!(edge.tmem.is_some(), "exactly 2048 texels loads");
4890 assert_eq!(edge.tmem_byte(0), 0xDE, "and writes the first texel");
4891 }
4892
4893 /// **`Load Block` uses dxt to swap odd lines.** With `dxt = 0x800` the line
4894 /// index `(word * dxt) >> 11` is odd for odd 64-bit words, so the second group
4895 /// of four 16-bit texels (word 1) is swapped while the first (word 0) is not.
4896 #[test]
4897 fn load_block_dxt_swaps_odd_lines() {
4898 let mut mem = alloc::vec![0u8; 0x200];
4899 // 8 x 16-bit texels = 16 bytes: word 0 = texels 0..4, word 1 = texels 4..8.
4900 let data: [u8; 16] = [
4901 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45,
4902 0x46, 0x47,
4903 ];
4904 mem[0x100..0x110].copy_from_slice(&data);
4905 let bus = SliceBus {
4906 mem,
4907 dp_raised: false,
4908 };
4909 let mut rdp = Rdp::new();
4910 rdp.tiles[0].size = 2; // 16-bit
4911 rdp.tex_image_size = 2;
4912 rdp.tex_image_width = 8;
4913 rdp.tex_image_addr = 0x100;
4914 // SL=0 index=0 SH=7 (field 7<<... u12.0 so field=7) dxt=0x800.
4915 rdp.load_block(0x0000_0000, 0x0000_7800, &bus);
4916 // Word 0 (texels 0..4) unswapped at bytes 0..8.
4917 for (i, &b) in data[..8].iter().enumerate() {
4918 assert_eq!(rdp.tmem_byte(i), b, "word0[{i}] unswapped");
4919 }
4920 // Word 1 (texels 4..8) swapped: byte_off (8..16) ^ 4.
4921 for (i, &b) in data[8..].iter().enumerate() {
4922 assert_eq!(rdp.tmem_byte((8 + i) ^ 4), b, "word1 byte {i} swapped");
4923 }
4924 }
4925
4926 /// **An unsupported (4-bit) *tile* size writes nothing.** Isolates the
4927 /// destination-size guard: the source is a supported image, so only the
4928 /// tile-size (`dst_bpt`) guard stops the load. A silent no-op is invisible to a
4929 /// "does not panic" test, so this asserts the *effect*: TMEM is never allocated.
4930 #[test]
4931 fn load_with_unsupported_tile_size_writes_nothing() {
4932 let mut mem = alloc::vec![0u8; 0x200];
4933 mem[0x100..0x108].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
4934 let bus = SliceBus {
4935 mem,
4936 dp_raised: false,
4937 };
4938 // 4-bit tile (size 0), supported source: Load Tile writes nothing.
4939 let mut a = Rdp::new();
4940 a.tiles[0].size = 0;
4941 a.tex_image_size = 2;
4942 a.tex_image_width = 4;
4943 a.tex_image_addr = 0x100;
4944 a.load_tile(0x0000_0000, 0x0000_C000, &bus);
4945 assert!(a.tmem.is_none(), "4-bit tile: Load Tile writes nothing");
4946 // 4-bit tile: Load Block writes nothing.
4947 let mut b = Rdp::new();
4948 b.tiles[0].size = 0;
4949 b.tex_image_size = 1;
4950 b.tex_image_width = 4;
4951 b.tex_image_addr = 0x100;
4952 b.load_block(0x0000_0000, 0x0000_3000, &bus);
4953 assert!(b.tmem.is_none(), "4-bit tile: Load Block writes nothing");
4954 }
4955
4956 /// **An unsupported (4-bit) *source* size writes nothing — independently.** The
4957 /// tile size is supported, so only the source-size (`src_bpt`) guard stops the
4958 /// load; deleting that guard would read RDRAM at the wrong stride and allocate
4959 /// TMEM, failing this test.
4960 #[test]
4961 fn load_with_unsupported_source_size_writes_nothing() {
4962 let mut mem = alloc::vec![0u8; 0x200];
4963 mem[0x100..0x108].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
4964 let bus = SliceBus {
4965 mem,
4966 dp_raised: false,
4967 };
4968 let mut a = Rdp::new();
4969 a.tiles[0].size = 2; // supported destination
4970 a.tiles[0].line = 1;
4971 a.tex_image_size = 0; // 4-bit source (unsupported)
4972 a.tex_image_width = 4;
4973 a.tex_image_addr = 0x100;
4974 a.load_tile(0x0000_0000, 0x0000_C000, &bus);
4975 assert!(a.tmem.is_none(), "4-bit source: Load Tile writes nothing");
4976 let mut b = Rdp::new();
4977 b.tiles[0].size = 1; // supported destination
4978 b.tex_image_size = 0; // 4-bit source
4979 b.tex_image_width = 4;
4980 b.tex_image_addr = 0x100;
4981 b.load_block(0x0000_0000, 0x0000_3000, &bus);
4982 assert!(b.tmem.is_none(), "4-bit source: Load Block writes nothing");
4983 }
4984
4985 /// **`Load Block` rejects an inverted `SH < SL` range** (like `Load Tile`),
4986 /// with valid sizes so only the inverted-range guard prevents the load.
4987 #[test]
4988 fn load_block_rejects_inverted_range() {
4989 let mut mem = alloc::vec![0u8; 0x200];
4990 mem[0x100..0x110].copy_from_slice(&[0xFF; 16]);
4991 let bus = SliceBus {
4992 mem,
4993 dp_raised: false,
4994 };
4995 let mut rdp = Rdp::new();
4996 rdp.tiles[0].size = 1;
4997 rdp.tex_image_size = 1;
4998 rdp.tex_image_width = 8;
4999 rdp.tex_image_addr = 0x100;
5000 // SL=10 (field 0xA000) SH=2 (field 0x2000), u12.0: shi < slo.
5001 rdp.load_block(0x0000_A000, 0x0000_2000, &bus);
5002 assert!(rdp.tmem.is_none(), "shi < slo writes nothing");
5003 }
5004
5005 /// **The dispatcher routes 0x34 and 0x33 to the load handlers.** The other
5006 /// load tests call `load_tile` / `load_block` directly; this drives the actual
5007 /// `dispatch` entry so a removed or mis-wired opcode arm is caught by an
5008 /// observable TMEM write, not only a decode bug.
5009 #[test]
5010 fn dispatch_routes_load_tile_and_load_block() {
5011 let mut mem = alloc::vec![0u8; 0x200];
5012 mem[0x100..0x104].copy_from_slice(&[0x11, 0x22, 0x33, 0x44]);
5013 let mut bus = SliceBus {
5014 mem,
5015 dp_raised: false,
5016 };
5017 let mut a = Rdp::new();
5018 a.tiles[0].size = 2;
5019 a.tiles[0].line = 1;
5020 a.tex_image_size = 2;
5021 a.tex_image_width = 2;
5022 a.tex_image_addr = 0x100;
5023 a.dispatch(OP_LOAD_TILE, 0x0000_0000, 0x0000_4000, 0, &mut bus); // SH=1 -> 2 texels
5024 assert_eq!(
5025 (a.tmem_byte(0), a.tmem_byte(1)),
5026 (0x11, 0x22),
5027 "0x34 routed to load_tile"
5028 );
5029 let mut b = Rdp::new();
5030 b.tiles[0].size = 1;
5031 b.tex_image_size = 1;
5032 b.tex_image_width = 4;
5033 b.tex_image_addr = 0x100;
5034 b.dispatch(OP_LOAD_BLOCK, 0x0000_0000, 0x0000_3000, 0, &mut bus); // SH=3 -> 4 texels
5035 assert_eq!(b.tmem_byte(0), 0x11, "0x33 routed to load_block");
5036 }
5037
5038 /// **An inverted range writes nothing.** `SH < SL` (like `TH < TL`) is a
5039 /// degenerate command; without the guard the `& 0xFFF` wrap would iterate a
5040 /// large bogus width. Asserts the effect: TMEM stays unallocated.
5041 #[test]
5042 fn load_tile_rejects_inverted_range() {
5043 let mut mem = alloc::vec![0u8; 0x200];
5044 mem[0x100..0x110].copy_from_slice(&[0xFF; 16]);
5045 let bus = SliceBus {
5046 mem,
5047 dp_raised: false,
5048 };
5049 let mut rdp = Rdp::new();
5050 rdp.tiles[0].size = 2;
5051 rdp.tiles[0].line = 1;
5052 rdp.tex_image_size = 2;
5053 rdp.tex_image_width = 8;
5054 rdp.tex_image_addr = 0x100;
5055 // SL=10 (field 10<<2=0x28) SH=2 (field 2<<2=8): sh < sl.
5056 rdp.load_tile(0x0002_8000, 0x0000_8000, &bus);
5057 assert!(
5058 rdp.tmem.is_none(),
5059 "sh < sl writes nothing rather than a wrapped-width rectangle"
5060 );
5061 }
5062
5063 // ---- T-32-003: Load TLUT (0x30) and the texel-format decoders ----
5064
5065 /// Seed a tile descriptor's format/size (and optional palette) for a fetch test.
5066 fn tile_fmt(format: u8, size: u8) -> TileDescriptor {
5067 TileDescriptor {
5068 format,
5069 size,
5070 line: 1,
5071 ..TileDescriptor::default()
5072 }
5073 }
5074
5075 /// **`fetch_texel` decodes every supported format.** Each case seeds TMEM
5076 /// directly (the load path is tested separately) and asserts the RGBA8888.
5077 #[test]
5078 fn fetch_texel_decodes_each_format() {
5079 let mut rdp = Rdp::new();
5080 // RGBA16 (5551): 0xF801 = R=31, G=0, B=0, A=1 -> opaque red.
5081 rdp.tmem_write(0, 0xF8);
5082 rdp.tmem_write(1, 0x01);
5083 assert_eq!(
5084 rdp.fetch_texel(&tile_fmt(0, 2), 0, 0),
5085 [0xFF, 0, 0, 0xFF],
5086 "RGBA16"
5087 );
5088 // IA16: I=0x80 A=0xFF.
5089 assert_eq!(
5090 rdp.fetch_texel(&tile_fmt(3, 2), 0, 0),
5091 [0xF8, 0xF8, 0xF8, 0x01],
5092 "IA16 reads the same two bytes as I/A"
5093 );
5094
5095 // IA8: byte 0x5A -> I=widen4(5)=0x55, A=widen4(0xA)=0xAA.
5096 let mut r = Rdp::new();
5097 r.tmem_write(0, 0x5A);
5098 assert_eq!(
5099 r.fetch_texel(&tile_fmt(3, 1), 0, 0),
5100 [0x55, 0x55, 0x55, 0xAA],
5101 "IA8"
5102 );
5103 // I8: 0x5A in all channels.
5104 assert_eq!(
5105 r.fetch_texel(&tile_fmt(4, 1), 0, 0),
5106 [0x5A, 0x5A, 0x5A, 0x5A],
5107 "I8 alpha = intensity"
5108 );
5109
5110 // IA4: high nibble 0xE (i3=7 -> 0xFF, A = bit0 = 0).
5111 let mut n = Rdp::new();
5112 n.tmem_write(0, 0xE5);
5113 assert_eq!(
5114 n.fetch_texel(&tile_fmt(3, 0), 0, 0),
5115 [0xFF, 0xFF, 0xFF, 0],
5116 "IA4 (high nibble, even s)"
5117 );
5118 // I4: high nibble 0xE -> widen4(0xE)=0xEE in all channels.
5119 assert_eq!(
5120 n.fetch_texel(&tile_fmt(4, 0), 0, 0),
5121 [0xEE, 0xEE, 0xEE, 0xEE],
5122 "I4 alpha = intensity"
5123 );
5124 // Odd s selects the low nibble (0x5): I4 -> widen4(5)=0x55.
5125 assert_eq!(
5126 n.fetch_texel(&tile_fmt(4, 0), 1, 0),
5127 [0x55, 0x55, 0x55, 0x55],
5128 "I4 odd s -> low nibble"
5129 );
5130 }
5131
5132 /// **`fetch_texel` decodes RGBA32 from the split TMEM.** R,G come from the low
5133 /// half and B,A from the high half (0x800).
5134 #[test]
5135 fn fetch_texel_rgba32_reads_the_split() {
5136 let mut rdp = Rdp::new();
5137 rdp.tmem_write(0, 0x11); // R
5138 rdp.tmem_write(1, 0x22); // G
5139 rdp.tmem_write(0x800, 0x33); // B
5140 rdp.tmem_write(0x801, 0x44); // A
5141 assert_eq!(
5142 rdp.fetch_texel(&tile_fmt(0, 3), 0, 0),
5143 [0x11, 0x22, 0x33, 0x44]
5144 );
5145 }
5146
5147 /// **`fetch_texel` resolves CI8 and CI4 through the TLUT.** A CI index selects
5148 /// a quadrupled RGBA16 entry in the high TMEM half; CI4 folds in the tile
5149 /// palette as the high nibble.
5150 ///
5151 /// `tlut_en` must be set: the lookup is gated on `Set Other Modes` bit 47, not
5152 /// on the tile format (N64brew *…/Commands* §0x2F). This test previously left
5153 /// it clear and passed only because the gate did not exist — it was asserting
5154 /// the palette path while describing a machine that had not asked for it.
5155 #[test]
5156 fn fetch_texel_ci_through_the_tlut() {
5157 // CI8: index 5 -> TLUT entry at 0x800 + 5*8 = 0x828 = 0xF801 (red).
5158 let mut rdp = Rdp::new();
5159 rdp.other_modes.tlut_en = true;
5160 rdp.tmem_write(0, 5); // the index texel
5161 rdp.tmem_write(0x828, 0xF8);
5162 rdp.tmem_write(0x829, 0x01);
5163 assert_eq!(
5164 rdp.fetch_texel(&tile_fmt(2, 1), 0, 0),
5165 [0xFF, 0, 0, 0xFF],
5166 "CI8 -> TLUT red"
5167 );
5168
5169 // CI4: nibble 5 (high, even s) + palette 3 -> index 0x35 -> entry at
5170 // 0x800 + 0x35*8 = 0x9A8 = 0x07C1 (green).
5171 let mut c = Rdp::new();
5172 c.other_modes.tlut_en = true;
5173 c.tmem_write(0, 0x50); // high nibble 5
5174 c.tmem_write(0x9A8, 0x07);
5175 c.tmem_write(0x9A9, 0xC1);
5176 let mut tile = tile_fmt(2, 0);
5177 tile.palette = 3;
5178 assert_eq!(
5179 c.fetch_texel(&tile, 0, 0),
5180 [0, 0xFF, 0, 0xFF],
5181 "CI4 index = nibble | palette<<4"
5182 );
5183 }
5184
5185 /// **`fetch_texel` applies the odd-row swap.** Row 1 reads through the
5186 /// `^= (t & 1) << 2` twiddle, so the same TMEM contents sample differently on
5187 /// even vs odd rows — matching how the loads wrote them.
5188 #[test]
5189 fn fetch_texel_odd_row_swap() {
5190 let mut rdp = Rdp::new();
5191 // I8 tile, line 1 (8 bytes/row). Row 1 texel 0 reads byte (8) ^ 4 = 0xC.
5192 rdp.tmem_write(0xC, 0x99);
5193 assert_eq!(
5194 rdp.fetch_texel(&tile_fmt(4, 1), 0, 1),
5195 [0x99, 0x99, 0x99, 0x99],
5196 "odd row samples the swapped byte"
5197 );
5198 }
5199
5200 /// **`Load TLUT` quadruples each entry into the addressed TMEM region** and
5201 /// updates the tile size. Two 16-bit entries land as four adjacent copies each.
5202 #[test]
5203 fn load_tlut_quadruples_entries() {
5204 let mut mem = alloc::vec![0u8; 0x200];
5205 mem[0x100..0x104].copy_from_slice(&[0xF8, 0x01, 0x07, 0xC1]); // entry0, entry1
5206 let bus = SliceBus {
5207 mem,
5208 dp_raised: false,
5209 };
5210 let mut rdp = Rdp::new();
5211 rdp.tiles[0].tmem_addr = 0x100; // -> byte 0x800 (upper half)
5212 rdp.tex_image_addr = 0x100;
5213 // SL=0 SH=1 (field 1<<2=4) -> 2 entries.
5214 rdp.load_tlut(0x0000_0000, 0x0000_4000, &bus);
5215 // Entry 0 quadrupled at 0x800..0x808.
5216 for k in 0..4 {
5217 assert_eq!(rdp.tmem_byte(0x800 + k * 2), 0xF8, "entry0 copy {k} hi");
5218 assert_eq!(rdp.tmem_byte(0x801 + k * 2), 0x01, "entry0 copy {k} lo");
5219 }
5220 // Entry 1 quadrupled at 0x808..0x810.
5221 assert_eq!(rdp.tmem_byte(0x808), 0x07);
5222 assert_eq!(rdp.tmem_byte(0x809), 0xC1);
5223 assert_eq!(rdp.tmem_byte(0x80E), 0x07, "entry1 4th copy");
5224 assert_eq!(rdp.tiles[0].sh, 4, "Load TLUT latches the tile size");
5225 }
5226
5227 /// **`fetch_texel` never debug-panics on oversized coordinates.** An
5228 /// unclipped `s`/`t` must wrap into the 4 KiB TMEM space rather than
5229 /// overflowing (ADR 0004 determinism). In a debug build, non-wrapping
5230 /// arithmetic here would panic; this exercises every format at `u32::MAX`
5231 /// coordinates and asserts a deterministic (repeatable) result.
5232 #[test]
5233 fn fetch_texel_oversized_coords_wrap_deterministically() {
5234 let rdp = Rdp::new();
5235 for &(fmt, size) in &[
5236 (0, 2),
5237 (0, 3),
5238 (3, 2),
5239 (3, 1),
5240 (3, 0),
5241 (4, 1),
5242 (4, 0),
5243 (2, 1),
5244 (2, 0),
5245 ] {
5246 let mut tile = tile_fmt(fmt, size);
5247 tile.line = 0x1FF;
5248 tile.tmem_addr = 0x1FF;
5249 tile.palette = 0xF;
5250 // Must not panic, and must be deterministic for identical inputs.
5251 let a = rdp.fetch_texel(&tile, u32::MAX, u32::MAX);
5252 let b = rdp.fetch_texel(&tile, u32::MAX, u32::MAX);
5253 assert_eq!(a, b, "fmt {fmt} size {size} is deterministic");
5254 }
5255 }
5256
5257 /// **The dispatcher routes 0x30 to `load_tlut`.** Drives the FIFO dispatch
5258 /// entry so a removed/misrouted arm is caught by an observable TMEM write.
5259 #[test]
5260 fn dispatch_routes_load_tlut() {
5261 let mut mem = alloc::vec![0u8; 0x200];
5262 mem[0x100..0x102].copy_from_slice(&[0xAB, 0xCD]);
5263 let mut bus = SliceBus {
5264 mem,
5265 dp_raised: false,
5266 };
5267 let mut rdp = Rdp::new();
5268 rdp.tiles[0].tmem_addr = 0x100;
5269 rdp.tex_image_addr = 0x100;
5270 rdp.dispatch(OP_LOAD_TLUT, 0x0000_0000, 0x0000_0000, 0, &mut bus); // 1 entry
5271 assert_eq!(rdp.tmem_byte(0x800), 0xAB, "0x30 routed to load_tlut");
5272 assert_eq!(rdp.tmem_byte(0x801), 0xCD);
5273 }
5274
5275 // ---- T-32-004: coordinate wrap + copy-mode Texture Rectangle ----
5276
5277 /// **`wrap_coord` applies shift, tile-origin subtraction, mirror, and mask.**
5278 /// The coordinate is `s10.5`; a texel index of `n` is `n << 5`.
5279 #[test]
5280 fn wrap_coord_shift_subtract_mirror_mask() {
5281 // Plain: texel 3 (= 3<<5 = 96), no shift/mask/mirror, SL 0 -> 3.
5282 assert_eq!(wrap_coord(96, 0, 0, false, 0), 3);
5283 // Subtract SL: SL=1 (u10.2) -> shifts the origin left by one texel.
5284 assert_eq!(wrap_coord(96, 0, 0, false, 4), 2, "SL=1 texel subtracted");
5285 // Mask to 2 bits (mask_s=2 -> wrap every 4 texels): texel 5 -> 1.
5286 assert_eq!(wrap_coord(5 << 5, 0, 2, false, 0), 1, "masked to 2 bits");
5287 // Mirror with mask 2: texel 5 is in the odd span [4,7] -> reflects to 2.
5288 assert_eq!(wrap_coord(5 << 5, 0, 2, true, 0), 2, "mirrored");
5289 // Right shift (code 1) halves the coordinate before the texel divide.
5290 assert_eq!(wrap_coord(4 << 5, 1, 0, false, 0), 2, "shift code 1 = >>1");
5291 // Left shift (code 12 = left by 4): coord 0x10 -> 0x100 -> texel 8.
5292 assert_eq!(wrap_coord(0x10, 12, 0, false, 0), 8, "shift code 12 = <<4");
5293 // A negative coordinate stays negative through the left shift.
5294 assert_eq!(
5295 wrap_coord(-0x10, 12, 0, false, 0),
5296 -8,
5297 "left shift preserves sign"
5298 );
5299 }
5300
5301 /// **`sample_coord` applies shift, tile-origin subtraction, clamp, then mask
5302 /// (R-13).** The sampler order differs from `wrap_coord` only by the clamp,
5303 /// which sits between the subtraction and the mask. Every case is hand-derived
5304 /// from the ParaLLEl-RDP algorithm; a mutation to any step (drop the clamp,
5305 /// mask before clamp, clamp against raw `SH` vs `SH-SL`) fails a row here.
5306 #[test]
5307 fn sample_coord_shifts_subtracts_clamps_then_masks() {
5308 // s.5 coordinate: texel n = n << 5. Tile SH = 3 texels (0xC in u10.2).
5309 let sh = 3u16 << 2;
5310 // In-bounds, clamp forced by mask==0: texels pass straight through.
5311 assert_eq!(sample_coord(1 << 5, 0, 0, false, false, 0, sh), 1);
5312 assert_eq!(
5313 sample_coord(3 << 5, 0, 0, false, false, 0, sh),
5314 3,
5315 "SH itself"
5316 );
5317 // Past SH with clamp on: clamps to (SH>>2)-(SL>>2) = 3 texels.
5318 assert_eq!(
5319 sample_coord(5 << 5, 0, 0, false, true, 0, sh),
5320 3,
5321 "over-SH clamps"
5322 );
5323 // Negative coordinate with clamp on: clamps low to 0.
5324 assert_eq!(
5325 sample_coord(-(1 << 5), 0, 0, false, true, 0, sh),
5326 0,
5327 "below origin clamps to 0"
5328 );
5329 // Wrap (mask=2, no clamp): texel 5 wraps mod 4 -> 1.
5330 assert_eq!(
5331 sample_coord(5 << 5, 0, 2, false, false, 0, sh),
5332 1,
5333 "wraps mod 4"
5334 );
5335 // Mirror (mask=2): texel 5 (0b101) reflects on the alternate span -> 2.
5336 assert_eq!(sample_coord(5 << 5, 0, 2, true, false, 0, sh), 2, "mirrors");
5337 // Tile-origin subtraction: SL = 1 texel (0x4 in u10.2) shifts the index down
5338 // (texel 2 in-bounds, so this tests the subtraction, not the clamp).
5339 assert_eq!(
5340 sample_coord(2 << 5, 0, 0, false, false, 1 << 2, sh),
5341 1,
5342 "SL=1 subtracted"
5343 );
5344 // Shift code 1 = >>1 on the s.5 coord: texel 4 -> 2.
5345 assert_eq!(
5346 sample_coord(4 << 5, 1, 0, false, false, 0, sh),
5347 2,
5348 "shift >>1"
5349 );
5350 // Left-shift codes (11-15): code 12 = <<4. Mask off (mask=5) isolates the
5351 // shift. `0x10 << 4 = 0x100`, `>> 5 = 8`; matches the tested `wrap_coord`.
5352 assert_eq!(
5353 sample_coord(0x10, 12, 5, false, false, 0, sh),
5354 8,
5355 "shift code 12 = <<4"
5356 );
5357 // The left shift wraps in i16 (SIGN16), matching `tcshift_cycle`: `1024 << 5`
5358 // overflows to -32768 (not +32768), so this is NOT clamped/zeroed as a
5359 // positive would be. With mask 10 it lands at 0 — the point is that the code
5360 // reproduces the hardware wrap rather than widening to i32 first.
5361 assert_eq!(
5362 sample_coord(1024, 11, 10, false, false, 0, sh),
5363 0,
5364 "left shift wraps in 16-bit space (SIGN16)"
5365 );
5366 // CLAMP PRECEDES MASK: with both clamp on AND a non-zero mask, texel 5 past
5367 // SH=3 must clamp to 3 (then `3 & 3 = 3`), NOT wrap to `5 & 3 = 1`. Masking
5368 // first would give 1 — this row is the one that fails under that mutation.
5369 assert_eq!(
5370 sample_coord(5 << 5, 0, 2, false, true, 0, sh),
5371 3,
5372 "clamp precedes mask"
5373 );
5374 }
5375
5376 /// **`sample_axis` captures the sub-texel fraction and zeroes it on clamp
5377 /// (R-13 bilinear).** The base agrees with `sample_coord` (point); the extra
5378 /// outputs are the 5-bit `frac` the 3-point filter weights by and the `diff` to
5379 /// the neighbor texel (here always `+1`, in-range with no seam).
5380 #[test]
5381 fn sample_axis_returns_fraction_zeroed_on_clamp() {
5382 let sh = 7u16 << 2; // 8-texel tile
5383 // 1.5 texels, no clamp (mask=3): base 1, frac 0x10 (half a texel), diff +1.
5384 assert_eq!(
5385 sample_axis(0x30, 0, 3, false, false, 0, sh, false),
5386 (1, 0x10, 1)
5387 );
5388 // Whole texel: frac 0.
5389 assert_eq!(
5390 sample_axis(2 << 5, 0, 3, false, false, 0, sh, false),
5391 (2, 0, 1)
5392 );
5393 // Past SH with clamp forced (mask==0): base clamps to 7, frac zeroed, diff +1.
5394 assert_eq!(
5395 sample_axis(0xF0, 0, 0, false, false, 0, sh, false),
5396 (7, 0, 1)
5397 );
5398 // Below the origin with clamp: base 0, frac zeroed.
5399 assert_eq!(
5400 sample_axis(-0x10, 0, 0, false, false, 0, sh, false),
5401 (0, 0, 1)
5402 );
5403 }
5404
5405 /// **`mask_coupled` derives the neighbor `sdiff`/`tdiff` (R-13 seam).** `+1`
5406 /// normally; `0` at a wrap seam (duplicate); `-base` at a mirror-off period end
5407 /// (wrap the neighbor to 0); `-1` in a mirrored half. A mutation to any case
5408 /// (drop the seam, wrong mirror sign) fails a row.
5409 #[test]
5410 fn mask_coupled_derives_the_neighbor_diff() {
5411 // mask == 0: identity base, +1.
5412 assert_eq!(mask_coupled(5, 0, false, false), (5, 1));
5413 // Mirror off, mid-period (mask=2 → 4 texels): base 1, +1.
5414 assert_eq!(mask_coupled(1, 2, false, false), (1, 1));
5415 // Mirror off, period END (base == maskbits = 3): neighbor wraps to 0 (-3).
5416 assert_eq!(mask_coupled(3, 2, false, false), (3, -3));
5417 // Mirror off, T period end uses -(base & 0xff) — same as -base for base 3.
5418 assert_eq!(mask_coupled(3, 2, false, true), (3, -3));
5419 // T-only `& 0xff`: mask 10, base 0x3ff (== maskbits) → -(0x3ff & 0xff) = -0xff,
5420 // NOT -0x3ff. This is the row where the T branch differs from the S branch.
5421 assert_eq!(mask_coupled(0x3ff, 10, false, true), (0x3ff, -0xff));
5422 assert_eq!(
5423 mask_coupled(0x3ff, 10, false, false),
5424 (0x3ff, -0x3ff),
5425 "S uses -base"
5426 );
5427 // Mirror ON, forward half (base 1 → wrap bit clear, masked 1): +1.
5428 assert_eq!(mask_coupled(1, 2, true, false), (1, 1));
5429 // Mirror ON, mirrored half (base 6 → wrap bit set, inverted+masked 1): -1.
5430 assert_eq!(mask_coupled(6, 2, true, false), (1, -1));
5431 // Mirror ON seam (base 3 = top of forward half): duplicate, diff 0.
5432 assert_eq!(mask_coupled(3, 2, true, false), (3, 0));
5433 // NEGATIVE base (clamp off, coord below SL) is handled bit-correctly, as in
5434 // Angrylion: `(s >> mask) & 1` is bit `mask` of `s` for ANY sign (arithmetic
5435 // shift preserves the low bits), NOT "1 for all negatives". base -3, mask 1:
5436 // wrap = (-3 >> 1) & 1 = bit 1 of -3 = 0; masked -3 & 1 = 1; (1-0)&1 = seam → 0.
5437 assert_eq!(mask_coupled(-3, 1, true, false), (1, 0));
5438 }
5439
5440 /// **`bilinear_3point` blends the four texels by the two triangle cases
5441 /// (R-13).** Hand-computed from the ParaLLEl-RDP formula: a mutation to the
5442 /// upper/lower selector, the delta pairing, or the `+0x10 >> 5` round fails a
5443 /// row. `t0=(s,t)`, `t1=(s+1,t)`, `t2=(s,t+1)`, `t3=(s+1,t+1)`.
5444 #[test]
5445 fn bilinear_3point_blends_both_triangles() {
5446 let (t0, t1, t2, t3) = ([0, 0, 0, 0], [32, 0, 0, 0], [0, 32, 0, 0], [32, 32, 0, 0]);
5447 // Lower triangle: sfrac=0x10 (½), tfrac=0. R = 0 + (16·32 + 0 + 16)>>5 = 16.
5448 assert_eq!(
5449 bilinear_3point(t0, t1, t2, t3, 0x10, 0, false)[0],
5450 16,
5451 "lower R"
5452 );
5453 assert_eq!(
5454 bilinear_3point(t0, t1, t2, t3, 0x10, 0, false)[1],
5455 0,
5456 "lower G"
5457 );
5458 // Upper triangle: sfrac=tfrac=0x18 (¾, sum 0x30 ≥ 0x20). Weights t3 ½, t2 ¼,
5459 // t1 ¼ → R = 32·½ + 0·¼ + 32·¼ = 24; G = 32·½ + 32·¼ + 0·¼ = 24.
5460 assert_eq!(
5461 bilinear_3point(t0, t1, t2, t3, 0x18, 0x18, false)[0],
5462 24,
5463 "upper R"
5464 );
5465 assert_eq!(
5466 bilinear_3point(t0, t1, t2, t3, 0x18, 0x18, false)[1],
5467 24,
5468 "upper G"
5469 );
5470 // Zero fraction is the exact base texel (no blend).
5471 assert_eq!(
5472 bilinear_3point(t0, t1, t2, t3, 0, 0, false),
5473 t0,
5474 "frac 0 = base texel"
5475 );
5476 }
5477
5478 /// **`mid_texel` averages all four texels at the exact center (R-13).** At
5479 /// `sfrac == tfrac == 0x10` the 3-point filter picks the UPPER triangle and
5480 /// ignores `t0`; `mid_texel` instead averages all four neighbors (Angrylion
5481 /// `tex.c` `center` case). `t0` is placed off the gradient plane so the two
5482 /// disagree — a mutation that drops the center branch returns the 3-point value.
5483 #[test]
5484 fn bilinear_mid_texel_averages_the_four_texels_at_the_center() {
5485 let (t0, t1, t2, t3) = ([200, 0, 0, 0], [32, 0, 0, 0], [64, 0, 0, 0], [96, 0, 0, 0]);
5486 // 3-point UPPER ignores t0: 96 + ((16·(64−96) + 16·(32−96) + 16) >> 5) = 48.
5487 assert_eq!(
5488 bilinear_3point(t0, t1, t2, t3, 0x10, 0x10, false)[0],
5489 48,
5490 "3-point ignores t0"
5491 );
5492 // Four-texel average uses t0: 96 + (((96<<6) − (96<<7) + ((!96+200)<<6) + 0xc0) >> 8)
5493 // = 96 + (640 >> 8) = 96 + 2 = 98.
5494 assert_eq!(
5495 bilinear_3point(t0, t1, t2, t3, 0x10, 0x10, true)[0],
5496 98,
5497 "mid_texel averages all four"
5498 );
5499 // Off-center, `mid_texel` has no effect (the center condition is false).
5500 assert_eq!(
5501 bilinear_3point(t0, t1, t2, t3, 0x10, 0, true),
5502 bilinear_3point(t0, t1, t2, t3, 0x10, 0, false),
5503 "mid_texel only fires at the exact center"
5504 );
5505 }
5506
5507 /// **`lod_delta` takes the largest folded 17-bit delta (R-13).** Hand-computed
5508 /// from Angrylion `tclod_4x17_to_15`: each axis' difference is folded to its
5509 /// magnitude, the result is the max over S, T and `previous`, and bit 14 is the
5510 /// "too large" marker.
5511 #[test]
5512 fn lod_delta_folds_and_takes_the_maximum() {
5513 // S steps 48, T steps 16 -> 48 wins.
5514 assert_eq!(lod_delta(0, 48, 0, 16, 0), 48, "max over the two axes");
5515 // `previous` participates in the same max.
5516 assert_eq!(lod_delta(0, 48, 0, 16, 112), 112, "previous participates");
5517 // A NEGATIVE step folds to a magnitude (`~d & 0x1ffff`), not a signed value:
5518 // going from 48 down to 0 is a delta of 48 - 1 = 47 after the fold.
5519 assert_eq!(lod_delta(48, 0, 0, 0, 0), 47, "negative delta folds");
5520 // A delta big enough to reach bits 16:14 sets the 0x4000 saturation marker.
5521 assert!(
5522 lod_delta(0, 0x1_0000, 0, 0, 0) & 0x4000 != 0,
5523 "0x4000 marks a too-large LOD"
5524 );
5525 }
5526
5527 /// **`lod_signals` reproduces the Angrylion `lf` cases (R-13).** Hand-computed
5528 /// per branch; each row fails if the branch order, the `<< 3`, the `l_tile`
5529 /// shift, or the `max_level` "distant" test is wrong.
5530 #[test]
5531 fn lod_signals_covers_each_branch() {
5532 let frac =
5533 |clamp, lod, min, max, sharp, det| lod_signals(clamp, lod, min, max, sharp, det).frac;
5534 // The oracle vector's own case: lod 112, max_level 2 -> l_tile = log2(3) = 1,
5535 // not distant, so lf = ((112 << 3) >> 1) & 0xff = 0xc0.
5536 assert_eq!(frac(false, 112, 0, 2, false, false), 0xC0, "in-range");
5537 // Same LOD with NO mip chain (max_level 0) is "distant" -> saturates.
5538 assert_eq!(frac(false, 112, 0, 0, false, false), 0xFF, "distant");
5539 // A clamped coordinate saturates regardless of the LOD.
5540 assert_eq!(frac(true, 112, 0, 2, false, false), 0xFF, "lodclamp");
5541 // Magnifying (lod < 32) with a mip chain and no sharpen/detail -> 0.
5542 assert_eq!(frac(false, 16, 0, 2, false, false), 0, "magnify");
5543 // Magnifying with SHARPEN keeps the fraction live and sets bit 8.
5544 assert_eq!(
5545 frac(false, 16, 0, 2, true, false),
5546 (16 << 3) | 0x100,
5547 "sharpen keeps the fraction and sets 0x100"
5548 );
5549 // `min_level` is the floor the magnify branch uses instead of the LOD.
5550 assert_eq!(
5551 frac(false, 4, 8, 2, false, true),
5552 8 << 3,
5553 "detail uses min_level as the floor"
5554 );
5555 // The mip signals that drive tile selection.
5556 let s = lod_signals(false, 112, 0, 2, false, false);
5557 assert_eq!(
5558 (s.l_tile, s.magnify, s.distant),
5559 (1, false, false),
5560 "level 1"
5561 );
5562 let far = lod_signals(false, 112, 0, 1, false, false);
5563 assert!(far.distant, "l_tile >= max_level is distant");
5564 let mag = lod_signals(false, 16, 0, 2, false, false);
5565 assert!(mag.magnify && mag.l_tile == 0, "magnify pins level 0");
5566 }
5567
5568 /// **`lod_mip_tiles` picks the mip pair (R-13).** Hand-computed from the
5569 /// tile-selection tail of Angrylion `tclod_2cycle`: the pair straddles the mip
5570 /// boundary, collapses where there is nothing to blend toward, and wraps into
5571 /// the 8 descriptors.
5572 #[test]
5573 fn lod_mip_tiles_selects_the_pair() {
5574 let sig = |l_tile, magnify, distant| LodSignals {
5575 frac: 0,
5576 l_tile,
5577 magnify,
5578 distant,
5579 };
5580 // In-range level 1 from base 0 -> sample tiles 1 and 2 (the oracle vector).
5581 assert_eq!(
5582 lod_mip_tiles(0, sig(1, false, false), 2, false, false),
5583 (1, 2),
5584 "straddles the mip boundary"
5585 );
5586 // Distant pins the level to `max_level` and collapses the pair.
5587 assert_eq!(
5588 lod_mip_tiles(0, sig(1, false, true), 3, false, false),
5589 (3, 3),
5590 "distant pins to max_level and collapses"
5591 );
5592 // Plain magnification also collapses (nothing finer to blend toward)...
5593 assert_eq!(
5594 lod_mip_tiles(0, sig(0, true, false), 2, false, false),
5595 (0, 0),
5596 "magnify collapses"
5597 );
5598 // ...unless sharpen texturing is on, which keeps the second tile.
5599 assert_eq!(
5600 lod_mip_tiles(0, sig(0, true, false), 2, true, false),
5601 (0, 1),
5602 "sharpen keeps the pair while magnifying"
5603 );
5604 // Detail texturing shifts both one level finer.
5605 assert_eq!(
5606 lod_mip_tiles(0, sig(1, false, false), 2, false, true),
5607 (2, 3),
5608 "detail shifts by one"
5609 );
5610 // Detail while MAGNIFYING does not take the extra finer step, and the pair
5611 // collapses onto consecutive tiles rather than straddling.
5612 assert_eq!(
5613 lod_mip_tiles(0, sig(0, true, false), 2, false, true),
5614 (0, 1),
5615 "detail + magnify"
5616 );
5617 // Detail while DISTANT pins the level to max_level and stops straddling.
5618 assert_eq!(
5619 lod_mip_tiles(0, sig(1, false, true), 2, false, true),
5620 (3, 3),
5621 "detail + distant"
5622 );
5623 // Every index wraps into the 8 tile descriptors.
5624 assert_eq!(
5625 lod_mip_tiles(7, sig(1, false, false), 2, false, false),
5626 (0, 1),
5627 "wraps mod 8"
5628 );
5629 }
5630
5631 /// **A copy-mode Texture Rectangle round-trips a texture.** `Load Tile` loads a
5632 /// 4×2 16-bit texture into TMEM; a 1:1 `Texture Rectangle` (copy mode) blits it
5633 /// into a 16-bit color image. Because the load and the copy fetch share the
5634 /// odd-row swap, the framebuffer must equal the source texture exactly — the
5635 /// first textured picture, end to end.
5636 #[test]
5637 fn texture_rectangle_copy_round_trips_a_texture() {
5638 let mut mem = alloc::vec![0u8; 0x400];
5639 // Source 4x2 16-bit texture at 0x100 (8 distinct texels).
5640 let tex: [u16; 8] = [
5641 0x0102, 0x0304, 0x0506, 0x0708, 0x090A, 0x0B0C, 0x0D0E, 0x0F10,
5642 ];
5643 for (i, &v) in tex.iter().enumerate() {
5644 mem[0x100 + i * 2] = (v >> 8) as u8;
5645 mem[0x100 + i * 2 + 1] = (v & 0xFF) as u8;
5646 }
5647 // Texture Rectangle command word 1 at 0x308 (word 0 supplied to dispatch):
5648 // S=0 T=0 | DsDx=4.0 (0x1000) DtDy=1.0 (0x400) -> a 1:1 blit.
5649 mem[0x308..0x30C].copy_from_slice(&0u32.to_be_bytes()); // S=0, T=0
5650 mem[0x30C..0x310].copy_from_slice(&0x1000_0400u32.to_be_bytes());
5651 let mut bus = SliceBus {
5652 mem,
5653 dp_raised: false,
5654 };
5655 let mut rdp = Rdp::new();
5656 // Load the texture into TMEM (tile 0: 16-bit, line 1).
5657 rdp.tex_image_size = 2;
5658 rdp.tex_image_width = 4;
5659 rdp.tex_image_addr = 0x100;
5660 rdp.tiles[0].size = 2;
5661 rdp.tiles[0].line = 1;
5662 rdp.load_tile(0x0000_0000, 0x0000_C004, &bus); // SL0 TL0 SH3 TH1 -> 4x2
5663 // Color image: 16-bit, width 4, at 0x200.
5664 rdp.color_image_size = 2;
5665 rdp.color_image_width = 4;
5666 rdp.color_image = 0x200;
5667 // Scissor covering the 4x2 rect (a real command list always sets it).
5668 rdp.scissor_lrx = 4 << 2;
5669 rdp.scissor_lry = 2 << 2;
5670 // Texture Rectangle: word0 XL=3<<2 (0xC), YL=1<<2 (4), tile 0, XH=0, YH=0.
5671 rdp.dispatch(
5672 OP_TEXTURE_RECTANGLE,
5673 0x0000_C004,
5674 0x0000_0000,
5675 0x300,
5676 &mut bus,
5677 );
5678 // The color image equals the source texture, texel for texel.
5679 for (i, &v) in tex.iter().enumerate() {
5680 let hi = bus.mem[0x200 + i * 2];
5681 let lo = bus.mem[0x200 + i * 2 + 1];
5682 assert_eq!(
5683 u16::from_be_bytes([hi, lo]),
5684 v,
5685 "framebuffer pixel {i} matches the source texel"
5686 );
5687 }
5688 }
5689
5690 /// **`Texture Rectangle Flip` and unsupported sizes draw nothing** (R-8): the
5691 /// copy path is wired only for a 16-bit tile into a 16-bit color image.
5692 #[test]
5693 fn texture_rectangle_unsupported_configs_draw_nothing() {
5694 let mut mem = alloc::vec![0u8; 0x400];
5695 mem[0x308..0x310].copy_from_slice(&[0u8; 8]);
5696 let mut bus = SliceBus {
5697 mem,
5698 dp_raised: false,
5699 };
5700 let mut rdp = Rdp::new();
5701 rdp.tiles[0].size = 2;
5702 rdp.tiles[0].line = 1;
5703 rdp.color_image_size = 2;
5704 rdp.color_image_width = 4;
5705 rdp.color_image = 0x200;
5706 // Flip is deferred -> draws nothing.
5707 rdp.dispatch(
5708 OP_TEXTURE_RECTANGLE_FLIP,
5709 0x0000_C004,
5710 0x0000_0000,
5711 0x300,
5712 &mut bus,
5713 );
5714 assert!(
5715 bus.mem[0x200..0x210].iter().all(|&b| b == 0),
5716 "Flip draws nothing"
5717 );
5718 // 8-bit color image (unsupported) -> draws nothing.
5719 rdp.color_image_size = 1;
5720 rdp.dispatch(
5721 OP_TEXTURE_RECTANGLE,
5722 0x0000_C004,
5723 0x0000_0000,
5724 0x300,
5725 &mut bus,
5726 );
5727 assert!(
5728 bus.mem[0x200..0x210].iter().all(|&b| b == 0),
5729 "8-bit target draws nothing"
5730 );
5731 }
5732
5733 // ---- T-33-001: flat-fill triangle rasterizer ----
5734
5735 /// **`Fill Triangle` (0x08) flat-fills a right triangle.** A left-major triangle
5736 /// with a vertical left edge at x=2 and a hypotenuse widening 1 pixel per row
5737 /// fills the staircase {row0:x2, row1:x2-3, row2:x2-4, row3:x2-5} — verified
5738 /// pixel-for-pixel against a 32-bit color image, which pins the edge-walk and
5739 /// the s11.2/s11.16 fixed-point decode. This exact staircase is oracle-confirmed:
5740 /// `fill_tri_wide_16` (T-33-005) renders the same geometry byte-for-byte in Angrylion.
5741 #[test]
5742 fn fill_triangle_flat_fills_a_right_triangle() {
5743 let mut mem = alloc::vec![0u8; 0x400];
5744 // Edge words at cmd_base 0x300: word1 (L, unused) = 0; word2 (H major):
5745 // xh = 2.0 (0x2_0000), dxhdy = 0; word3 (M): xm = 2.0, dxmdy = 1.0 (0x1_0000).
5746 // The slope is dx per *pixel* row (R-14): 1.0 widens the span one pixel per row.
5747 mem[0x310..0x314].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
5748 mem[0x318..0x31C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
5749 mem[0x31C..0x320].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // dxmdy = 1.0
5750 let mut bus = SliceBus {
5751 mem,
5752 dp_raised: false,
5753 };
5754 let mut rdp = Rdp::new();
5755 rdp.color_image_size = 3; // 32-bit
5756 rdp.color_image_width = 8;
5757 rdp.color_image = 0x200;
5758 rdp.fill_color = 0xAABB_CCDD;
5759 rdp.scissor_lrx = 8 << 2;
5760 rdp.scissor_lry = 8 << 2;
5761 // word0: opcode 0x08, flip/lmajor (bit 55), yl=16, ym=16, yh=0.
5762 rdp.dispatch(0x08, 0x0880_0010, 0x0010_0000, 0x300, &mut bus);
5763
5764 let filled = |bus: &SliceBus, x: usize, row: usize| -> bool {
5765 let a = 0x200 + row * 32 + x * 4;
5766 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]])
5767 == 0xAABB_CCDD
5768 };
5769 let expected = [
5770 (2, 0),
5771 (2, 1),
5772 (3, 1),
5773 (2, 2),
5774 (3, 2),
5775 (4, 2),
5776 (2, 3),
5777 (3, 3),
5778 (4, 3),
5779 (5, 3),
5780 ];
5781 for row in 0..4 {
5782 for x in 0..8 {
5783 let want = expected.contains(&(x, row));
5784 assert_eq!(filled(&bus, x, row), want, "pixel ({x},{row})");
5785 }
5786 }
5787 }
5788
5789 /// **The triangle span is clipped to the scissor.** The same right triangle
5790 /// with the scissor's right edge at x=3 must lose the pixels the hypotenuse
5791 /// would otherwise reach (x=4 on row 2, x=5 on row 3) — exercising the X
5792 /// scissor boundary with an independent expectation.
5793 #[test]
5794 fn fill_triangle_is_clipped_to_the_scissor() {
5795 let mut mem = alloc::vec![0u8; 0x400];
5796 mem[0x310..0x314].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh = 2.0
5797 mem[0x318..0x31C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm = 2.0
5798 mem[0x31C..0x320].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // dxmdy = 1.0 (R-14)
5799 let mut bus = SliceBus {
5800 mem,
5801 dp_raised: false,
5802 };
5803 let mut rdp = Rdp::new();
5804 rdp.color_image_size = 3;
5805 rdp.color_image_width = 8;
5806 rdp.color_image = 0x200;
5807 rdp.fill_color = 0xAABB_CCDD;
5808 rdp.scissor_lrx = 3 << 2; // right edge at x=3 -> clips x>=4
5809 rdp.scissor_lry = 8 << 2; // all rows kept
5810 rdp.dispatch(0x08, 0x0880_0010, 0x0010_0000, 0x300, &mut bus);
5811
5812 let filled = |bus: &SliceBus, x: usize, row: usize| -> bool {
5813 let a = 0x200 + row * 32 + x * 4;
5814 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]])
5815 == 0xAABB_CCDD
5816 };
5817 // The hypotenuse pixels past x=3 are clipped away.
5818 assert!(!filled(&bus, 4, 2), "x=4 row 2 clipped by scissor");
5819 assert!(!filled(&bus, 5, 3), "x=5 row 3 clipped by scissor");
5820 // The in-scissor part of every row is still drawn.
5821 assert!(filled(&bus, 2, 0));
5822 assert!(filled(&bus, 3, 1));
5823 assert!(filled(&bus, 3, 3), "x=3 row 3 kept (at the scissor edge)");
5824 }
5825
5826 // ---- T-33-002: the color combiner ----
5827
5828 /// **`(A − B) * C + D` matches the hand-computed RDP arithmetic.** The `+0x80`
5829 /// rounding before `>> 8`, C's plain 9-bit sign, and D added unscaled.
5830 #[test]
5831 fn combiner_equation_matches_hand_computed() {
5832 // Passthrough C: A=One (0x100), B=0, D=0 -> the result is C.
5833 assert_eq!(combine_channel(0x100, 0, 128, 0), 128, "One*C passthrough");
5834 assert_eq!(combine_channel(0x100, 0, 255, 0), 255);
5835 // Lerp: (200-100)*128/256 + 100 = 50 + 100 = 150.
5836 assert_eq!(combine_channel(200, 100, 128, 100), 150);
5837 // clamp_9bit folds an over-range result: 300 -> 300-0x80=0x94 -> sext9 stays
5838 // positive (0x94<0x100) -> +0x80 = 0x114 -> clamp to 0xFF.
5839 assert_eq!(clamp_9bit(300), 0xFF, "over-range saturates");
5840 assert_eq!(clamp_9bit(-10), 0, "negative clamps to 0");
5841 }
5842
5843 /// **`Set Combine Mode` (0x3C) decodes all 16 selects.** Each field is seeded
5844 /// distinctly so a swapped bit range surfaces as a wrong select.
5845 #[test]
5846 fn set_combine_mode_decodes_selects() {
5847 // Each field gets a distinct value in its own (non-overlapping) bit range.
5848 let mut rdp = Rdp::new();
5849 let hi = (0xA << 20) | (0x15 << 15) | (1 << 12) | (2 << 9) | (0xB << 5) | 0x1A;
5850 let lo = (3 << 28)
5851 | (0xC << 24)
5852 | (3 << 21)
5853 | (4 << 18)
5854 | (5 << 15)
5855 | (6 << 12)
5856 | (7 << 9)
5857 | (1 << 6)
5858 | (2 << 3)
5859 | 3;
5860 rdp.set_combine_mode(hi, lo);
5861 assert_eq!(rdp.combine.cyc0.rgb_a, 0xA);
5862 assert_eq!(rdp.combine.cyc0.rgb_c, 0x15);
5863 assert_eq!(rdp.combine.cyc0.rgb_b, 3);
5864 assert_eq!(rdp.combine.cyc0.rgb_d, 5);
5865 assert_eq!(rdp.combine.cyc0.a_a, 1);
5866 assert_eq!(rdp.combine.cyc0.a_c, 2);
5867 assert_eq!(rdp.combine.cyc0.a_b, 6);
5868 assert_eq!(rdp.combine.cyc0.a_d, 7);
5869 assert_eq!(rdp.combine.cyc1.rgb_a, 0xB);
5870 assert_eq!(rdp.combine.cyc1.rgb_c, 0x1A);
5871 assert_eq!(rdp.combine.cyc1.rgb_b, 0xC);
5872 assert_eq!(rdp.combine.cyc1.rgb_d, 1);
5873 assert_eq!(rdp.combine.cyc1.a_a, 3);
5874 assert_eq!(rdp.combine.cyc1.a_c, 4);
5875 assert_eq!(rdp.combine.cyc1.a_b, 2);
5876 assert_eq!(rdp.combine.cyc1.a_d, 3);
5877 }
5878
5879 /// **A one-cycle combiner passes texel0 through.** `A = One`, `B = Zero`,
5880 /// `C = Texel0`, `D = Zero` for both RGB and alpha, so the output equals the
5881 /// texel — an observable evaluation, seeded so a no-op differs.
5882 #[test]
5883 fn combine_cycle_passes_texel0_through() {
5884 let cfg = CombineCycle {
5885 rgb_a: 6, // One
5886 rgb_b: 8, // Zero (select 8+; unambiguous, unlike select 7 = K4)
5887 rgb_c: 1, // Texel0
5888 rgb_d: 7, // Zero
5889 a_a: 6, // One
5890 a_b: 7, // Zero
5891 a_c: 1, // Texel0 alpha
5892 a_d: 7, // Zero
5893 };
5894 let inp = CombinerInputs {
5895 texel0: [10, 20, 30, 40],
5896 ..CombinerInputs::default()
5897 };
5898 assert_eq!(Rdp::combine_cycle(cfg, &inp), [10, 20, 30, 40]);
5899 }
5900
5901 /// **`PRIM_LOD_FRAC` reaches the combiner as RGB mul-select 14 and alpha
5902 /// mul-select 6 (R-10).** `A = One`, `B = Zero`, `C = PrimLODFrac` for both RGB
5903 /// and alpha, so the output equals `One * prim_lod_frac >> 8 ≈ prim_lod_frac`
5904 /// (`256 * 100 + 0x80 >> 8 = 100`). Mutation guard: if either select fell back
5905 /// to the R-10 "→ 0" arm the output would be `[0, 0, 0, 0]`, so the seeded
5906 /// `100` value cannot pass vacuously.
5907 #[test]
5908 fn combine_cycle_routes_prim_lod_frac() {
5909 let cfg = CombineCycle {
5910 rgb_a: 6, // One
5911 rgb_b: 8, // Zero
5912 rgb_c: 14, // Prim LOD fraction
5913 rgb_d: 7, // Zero
5914 a_a: 6, // One
5915 a_b: 7, // Zero
5916 a_c: 6, // Prim LOD fraction (alpha mul-select 6)
5917 a_d: 7, // Zero
5918 };
5919 let inp = CombinerInputs {
5920 prim_lod_frac: 100,
5921 ..CombinerInputs::default()
5922 };
5923 assert_eq!(Rdp::combine_cycle(cfg, &inp), [100, 100, 100, 100]);
5924 }
5925
5926 /// **`Set Convert` `K4`/`K5` reach the combiner as RGB sub-B select 7 and RGB
5927 /// mul-select 15 (R-10).** `A = One`, `B = K4`, `C = K5`, `D = Zero`, so RGB is
5928 /// `(One − K4) * K5 >> 8 = (256 − 64) * 64 + 0x80 >> 8 = 48`. Alpha is a fixed
5929 /// `One` (K4/K5 are RGB-only) so it does not confound the RGB check. Mutation
5930 /// guard: an unwired `K4` gives `64`, an unwired `K5` gives `0`, either one
5931 /// unwired changes the result away from `48`.
5932 #[test]
5933 fn combine_cycle_routes_convert_k4_k5() {
5934 let cfg = CombineCycle {
5935 rgb_a: 6, // One
5936 rgb_b: 7, // Convert K4 (sub-B)
5937 rgb_c: 15, // Convert K5 (mul)
5938 rgb_d: 7, // Zero
5939 a_a: 7, // Zero
5940 a_b: 7, // Zero
5941 a_c: 7, // Zero
5942 a_d: 6, // One
5943 };
5944 let inp = CombinerInputs {
5945 k4: 64,
5946 k5: 64,
5947 ..CombinerInputs::default()
5948 };
5949 assert_eq!(Rdp::combine_cycle(cfg, &inp), [48, 48, 48, 255]);
5950 }
5951
5952 /// **Chroma-key center/scale reach the combiner as RGB sub-B select 6 and RGB
5953 /// mul-select 6 (`Set Key R`/`GB`, R-10).** `A = One`, `B = KeyCenter`,
5954 /// `C = KeyScale`, `D = Zero`, so RGB is `(One − center) * scale >> 8`. Per-channel
5955 /// center `[32, 64, 96]` and scale `[64, 128, 192]` give distinct results
5956 /// (`(256−32)*64 = 56`, `(256−64)*128 = 96`, `(256−96)*192 = 120`, each `+ 0x80 >> 8`),
5957 /// so a channel-swap on either input is caught. Alpha is a fixed `One` (keys are
5958 /// RGB-only). Mutation guard: an unwired `KeyCenter` (→ 0) gives `[64, 128, 192]`-ish
5959 /// and an unwired `KeyScale` (→ 0) gives `[0, 0, 0]` — either changes the result.
5960 #[test]
5961 fn combine_cycle_routes_chroma_key() {
5962 let cfg = CombineCycle {
5963 rgb_a: 6, // One
5964 rgb_b: 6, // KeyCenter (sub-B)
5965 rgb_c: 6, // KeyScale (mul)
5966 rgb_d: 7, // Zero
5967 a_a: 7, // Zero
5968 a_b: 7, // Zero
5969 a_c: 7, // Zero
5970 a_d: 6, // One
5971 };
5972 let inp = CombinerInputs {
5973 key_center: [32, 64, 96],
5974 key_scale: [64, 128, 192],
5975 ..CombinerInputs::default()
5976 };
5977 assert_eq!(Rdp::combine_cycle(cfg, &inp), [56, 96, 120, 255]);
5978 }
5979
5980 /// **`chroma_key_min` folds each channel, offsets by the width, and takes the
5981 /// minimum (R-10).** Per channel `k = SIGN(col17, 17)`; if `k > 0`, `k = -k`
5982 /// (or `-k + 0x10` when the low nibble is 8); then `k = (width << 4) + k`; the
5983 /// result is `min(kr, kg, kb)` clamped to `[0, 0xff]`. Hand-computed cases:
5984 #[test]
5985 fn chroma_key_min_folds_and_takes_the_minimum() {
5986 // Positive col17, low nibble != 8: k = (width<<4) - col17.
5987 // r: 128 - 0x10 = 112; g: 128 - 0x20 = 96; b: 128 - 0x30 = 80 -> min 80.
5988 assert_eq!(chroma_key_min([0x10, 0x20, 0x30], [8, 8, 8]), 80);
5989 // Low-nibble == 8 special fold: k = -col17 + 0x10. col17 0x18 -> -0x18+0x10 = -8;
5990 // r: 128 - 8 = 120; the wider g/b (256) leave r the minimum.
5991 assert_eq!(chroma_key_min([0x18, 0x300, 0x300], [8, 0x40, 0x40]), 120);
5992 // A large col17 drives k negative -> the minimum clamps to 0.
5993 assert_eq!(chroma_key_min([0x400, 0x10, 0x10], [8, 8, 8]), 0);
5994 // Bit 16 set: SIGN(col17, 17) is NEGATIVE (col17 - 0x20000), so the `k > 0`
5995 // fold is skipped. 0x1FFF0 -> -16; r: (8<<4) - 16 = 112, the minimum vs the
5996 // wider g/b. This exercises the signed-fold branch (a broken sign-extend that
5997 // read 0x1FFF0 as positive would fold to a large negative and clamp to 0).
5998 assert_eq!(
5999 chroma_key_min([0x1_FFF0, 0x300, 0x300], [8, 0x40, 0x40]),
6000 112
6001 );
6002 }
6003
6004 /// **Two-cycle mode chains cycle 0 into cycle 1's `Combined` input.** Cycle 0
6005 /// passes texel0 through; cycle 1 selects `Combined` for A (with C=One-ish),
6006 /// so the final output reflects cycle 0's result — not cycle 1 reading a stale
6007 /// combined value.
6008 #[test]
6009 fn combine_two_cycle_chains() {
6010 let passthrough_texel0 = CombineCycle {
6011 rgb_a: 6,
6012 rgb_b: 7,
6013 rgb_c: 1,
6014 rgb_d: 7,
6015 a_a: 6,
6016 a_b: 7,
6017 a_c: 1,
6018 a_d: 7,
6019 };
6020 // Cycle 1: pass the Combined input through (A=One, B=Zero, C=Combined, D=Zero).
6021 let passthrough_combined = CombineCycle {
6022 rgb_a: 6,
6023 rgb_b: 7,
6024 rgb_c: 0, // Combined
6025 rgb_d: 7,
6026 a_a: 6,
6027 a_b: 7,
6028 a_c: 0, // combined alpha via C? alpha C 0 = lod-frac (0); use D instead
6029 a_d: 0, // combined alpha
6030 };
6031 let mut rdp = Rdp::new();
6032 rdp.combine.cyc0 = passthrough_texel0;
6033 rdp.combine.cyc1 = passthrough_combined;
6034 let inp = CombinerInputs {
6035 texel0: [11, 22, 33, 44],
6036 ..CombinerInputs::default()
6037 };
6038 let out = rdp.combine(inp, true);
6039 // RGB is cycle0's texel0 passed through cycle1's Combined.
6040 assert_eq!(&out[0..3], &[11, 22, 33], "2-cycle chains RGB");
6041 // Alpha also chains: cycle0 passes texel0's alpha (44) to Combined, and
6042 // cycle1's D = combined-alpha (C = lod-frac = 0), so the output is 44.
6043 assert_eq!(out[3], 44, "2-cycle chains alpha");
6044 }
6045
6046 /// **Two-cycle mode swaps texel0/texel1 before cycle 1 (R-13).** Both cycles
6047 /// output TEXEL0 (`D` select). Cycle 0 sees texel0 (red); the swap then makes
6048 /// cycle 1's TEXEL0 the original texel1 (green), so the pixel is green. Without
6049 /// the swap it would stay red — the mutation this row catches.
6050 #[test]
6051 fn combine_two_cycle_swaps_texels() {
6052 let out_texel0 = CombineCycle {
6053 rgb_a: 0,
6054 rgb_b: 0,
6055 rgb_c: 0,
6056 rgb_d: 1, // Texel0 in the add slot
6057 a_a: 0,
6058 a_b: 0,
6059 a_c: 0,
6060 a_d: 1,
6061 };
6062 let mut rdp = Rdp::new();
6063 rdp.combine.cyc0 = out_texel0;
6064 rdp.combine.cyc1 = out_texel0;
6065 let inp = CombinerInputs {
6066 texel0: [255, 0, 0, 255],
6067 texel1: [0, 255, 0, 255],
6068 ..CombinerInputs::default()
6069 };
6070 assert_eq!(
6071 rdp.combine(inp, true),
6072 [0, 255, 0, 255],
6073 "cycle 1 reads the swapped texel1 (green), not texel0 (red)"
6074 );
6075 }
6076
6077 // ---- T-33-003: the blender ----
6078
6079 /// **`Set Other Modes` (0x2F) decodes every field the blender uses.** Each is
6080 /// seeded distinctly in its own bit range so a swapped range surfaces as a
6081 /// wrong select — the two blend cycles interleave `P0 P1 A0 A1 M0 M1 B0 B1`.
6082 #[test]
6083 fn set_other_modes_decodes_fields() {
6084 let mut rdp = Rdp::new();
6085 let hi = (1 << 20) // cycle_type = 1 (2-cycle)
6086 | (1 << 6); // rgb_dither_mode = 1 (bayer), command bits 39:38
6087 let lo = (2 << 30) // P0
6088 | (3 << 28) // P1
6089 | (1 << 26) // A0
6090 | (2 << 24) // A1
6091 | (3 << 22) // M0
6092 | (1 << 20) // M1
6093 | (2 << 18) // B0
6094 | (3 << 16) // B1
6095 | (1 << 14) // force_blend
6096 | (2 << 10) // z_mode
6097 | (1 << 8) // cvg_dest
6098 | (1 << 6) // image_read_en
6099 | (1 << 5) // z_update_en
6100 | (1 << 4) // z_compare_en
6101 | (1 << 3) // aa_enable
6102 | 1; // alpha_compare_en
6103 rdp.set_other_modes(hi, lo);
6104 let om = rdp.other_modes;
6105 assert_eq!(om.cycle_type, 1);
6106 assert_eq!(
6107 om.blend[0],
6108 BlendCycle {
6109 p: 2,
6110 a: 1,
6111 m: 3,
6112 b: 2
6113 }
6114 );
6115 assert_eq!(
6116 om.blend[1],
6117 BlendCycle {
6118 p: 3,
6119 a: 2,
6120 m: 1,
6121 b: 3
6122 }
6123 );
6124 assert!(om.force_blend);
6125 assert_eq!(om.z_mode, 2);
6126 assert_eq!(om.cvg_dest, 1);
6127 assert!(om.image_read_en);
6128 assert!(om.z_update_en);
6129 assert!(om.z_compare_en);
6130 assert!(om.aa_enable);
6131 assert!(om.alpha_compare_en);
6132 assert_eq!(om.rgb_dither_mode, 1);
6133
6134 // **`tlut_en` (bit 47) and `tlut_type` (bit 46) decode independently.**
6135 // They are ADJACENT bits, so a swapped extraction is the likely error and
6136 // would pass any test that sets both or neither. Assert each with the other
6137 // clear, in both polarities, so a swap fails and so does dropping either.
6138 let om = |hi: u32| {
6139 let mut r = Rdp::new();
6140 r.set_other_modes(hi, 0);
6141 r.other_modes
6142 };
6143 let a = om(1 << 15); // tlut_en only
6144 assert!(a.tlut_en, "bit 47 must set tlut_en");
6145 assert!(!a.tlut_type, "bit 47 must NOT set tlut_type");
6146 let b = om(1 << 14); // tlut_type only
6147 assert!(!b.tlut_en, "bit 46 must NOT set tlut_en");
6148 assert!(b.tlut_type, "bit 46 must set tlut_type");
6149 }
6150
6151 /// **The magic-matrix RGB dither matches Angrylion's `rgb_dither` cell-for-cell.**
6152 /// Verified against the `dither_tri_32` oracle: for the flat shade `0x112233`
6153 /// (low 3 bits R=1 G=2 B=3), a channel rounds up to `(c & 0xf8) + 8` exactly
6154 /// where the matrix cell is **strictly less than** that channel's low 3 bits.
6155 /// - Cell 5 (magic `(2,1)`): 5 ≥ 1,2,3 → no channel rounds → `0x112233`.
6156 /// - Cell 2 (magic `(2,2)`): 2 < 3 only → B rounds → `0x112238`.
6157 /// - Cell 0 (magic `(3,3)`): 0 < 1,2,3 → all round → `0x182838`.
6158 ///
6159 /// A mutation of the round-up predicate or the matrix contents changes at least
6160 /// one of these, so the test fails without the exact Angrylion behavior.
6161 #[test]
6162 fn rgb_dither_matches_angrylion_magic_matrix() {
6163 // The magic matrix is indexed `(y & 3) * 4 + (x & 3)`.
6164 assert_eq!(rgb_dither_value(0, 1, 2), 5); // (x=1,y=2)
6165 assert_eq!(rgb_dither_value(0, 2, 2), 2); // (x=2,y=2)
6166 assert_eq!(rgb_dither_value(0, 3, 3), 0); // (x=3,y=3)
6167
6168 let shade = [0x11, 0x22, 0x33, 0xFF];
6169 assert_eq!(apply_rgb_dither(shade, 5), [0x11, 0x22, 0x33, 0xFF]);
6170 assert_eq!(apply_rgb_dither(shade, 2), [0x11, 0x22, 0x38, 0xFF]);
6171 assert_eq!(apply_rgb_dither(shade, 0), [0x18, 0x28, 0x38, 0xFF]);
6172
6173 // Dither mode 3 ("off") returns a constant 7, which never rounds up.
6174 assert_eq!(rgb_dither_value(3, 0, 0), 7);
6175 assert_eq!(apply_rgb_dither(shade, 7), shade);
6176
6177 // The 5-bit saturation: a channel already at the top level stays put, and a
6178 // dither-invariant channel (low 3 bits zero) is never touched.
6179 assert_eq!(
6180 apply_rgb_dither([0xFF, 0xF8, 0x00, 0x00], 0),
6181 [0xFF, 0xF8, 0x00, 0x00]
6182 );
6183 }
6184
6185 /// **Alpha-compare gates on `combiner_alpha >= Set Blend Color alpha`.** With the
6186 /// gate off every alpha passes; with it on, only alphas at or above the threshold
6187 /// survive. Mutating `>=` to `>` flips the boundary case, and dropping the enable
6188 /// check makes the "gate off" case fail — so the test pins both.
6189 #[test]
6190 fn alpha_compare_gates_on_blend_color_alpha() {
6191 let mut rdp = Rdp::new();
6192 rdp.blend_color = 0x0000_0080; // threshold alpha = 0x80
6193
6194 // Gate off: everything passes regardless of the threshold.
6195 rdp.other_modes.alpha_compare_en = false;
6196 assert!(rdp.alpha_compare_passes(0x00));
6197 assert!(rdp.alpha_compare_passes(0x7F));
6198
6199 // Gate on: `alpha >= 0x80` passes, below fails, and the boundary is inclusive.
6200 rdp.other_modes.alpha_compare_en = true;
6201 assert!(!rdp.alpha_compare_passes(0x00));
6202 assert!(!rdp.alpha_compare_passes(0x7F));
6203 assert!(rdp.alpha_compare_passes(0x80));
6204 assert!(rdp.alpha_compare_passes(0xFF));
6205 }
6206
6207 /// **Alpha-compare suppresses the Z-write on the depth path.** A z-suffixed
6208 /// shaded triangle with alpha-compare on and `z_compare` off (depth always
6209 /// passes, so alpha is the only gate). With a flat shade alpha **below** the
6210 /// threshold every pixel is rejected, so the Z buffer stays at its cleared value;
6211 /// **above** the threshold the pixels draw and write Z. A mutant that drops the
6212 /// alpha-compare `continue` in `depth_span` (Z-writing rejected pixels) fails the
6213 /// first assertion — the check the color-only `alpha_compare_z_16` vector cannot
6214 /// make (it reads only the color framebuffer).
6215 #[test]
6216 fn alpha_compare_suppresses_zwrite_on_depth_path() {
6217 let render = |shade_alpha: u32| -> u16 {
6218 let mut bus = ZBufBus {
6219 mem: alloc::vec![0u8; 0x1000],
6220 hidden: alloc::vec![0u8; 0x800],
6221 };
6222 let mut rdp = Rdp::new();
6223 rdp.color_image = 0x200;
6224 rdp.color_image_size = 2; // 16-bit
6225 rdp.color_image_width = 8;
6226 rdp.z_image = 0x400;
6227 rdp.blend_color = 0x0000_0080; // alpha-compare threshold 0x80
6228 rdp.scissor_lrx = 8 << 2;
6229 rdp.scissor_lry = 8 << 2;
6230 rdp.other_modes.alpha_compare_en = true;
6231 rdp.other_modes.z_update_en = true;
6232 rdp.other_modes.z_compare_en = false; // depth always passes; alpha is the only gate
6233 // Shade-passthrough combiner: cyc1 rgb_d = shade (4), a_d = shade alpha (4).
6234 rdp.combine.cyc1 = CombineCycle {
6235 rgb_a: 0,
6236 rgb_b: 0,
6237 rgb_c: 0,
6238 rgb_d: 4,
6239 a_a: 0,
6240 a_b: 0,
6241 a_c: 0,
6242 a_d: 4,
6243 };
6244 let base = 0x600usize;
6245 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
6246 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
6247 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // dxmdy = 1.0
6248 // Shade int-base (base + 0x20/0x24): R=0xFF, G=B=0, A = shade_alpha.
6249 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&(0xFFu32 << 16).to_be_bytes());
6250 bus.mem[base + 0x24..base + 0x28].copy_from_slice(&(shade_alpha & 0xFF).to_be_bytes());
6251 // z-suffix after the 4-word base + 8-word shade block = base + 0x60.
6252 bus.mem[base + 0x60..base + 0x64].copy_from_slice(&0x0800_0000u32.to_be_bytes());
6253 rdp.dispatch(0x0D, 0x0D80_0010, 0x0010_0000, base as u32, &mut bus);
6254 rdp.zbuffer_read(2, 1, &bus).0
6255 };
6256 assert_eq!(render(0x40), 0, "alpha-rejected pixel does not write Z");
6257 assert_ne!(render(0xC0), 0, "alpha-passing pixel writes Z");
6258 }
6259
6260 /// **`Set Blend Color` / `Set Fog Color` latch their RGBA8888 registers.**
6261 #[test]
6262 fn set_blend_and_fog_color_latch() {
6263 let mut bus = SliceBus {
6264 mem: alloc::vec![0u8; 0x100],
6265 dp_raised: false,
6266 };
6267 let mut rdp = Rdp::new();
6268 rdp.dispatch(OP_SET_BLEND_COLOR, 0, 0x1122_3344, 0, &mut bus);
6269 rdp.dispatch(OP_SET_FOG_COLOR, 0, 0x5566_7788, 0, &mut bus);
6270 assert_eq!(rdp.blend_color, 0x1122_3344);
6271 assert_eq!(rdp.fog_color, 0x5566_7788);
6272 }
6273
6274 /// **`(P * a0 + M * (a1 + 1)) >> 5` matches the hand-computed no-divide blend.**
6275 /// `P = pixel`, `M = memory`, `A = pixel-alpha` (128 → a0 = 16), `B = one`
6276 /// (0xFF → a1 = 31): ch0 = 100·16 + 10·32 = 1920 → 60; ch1 → 95; ch2 → 130.
6277 #[test]
6278 fn blend_cycle_matches_hand_computed() {
6279 let cycle = BlendCycle {
6280 p: 0,
6281 a: 0,
6282 m: 1,
6283 b: 2,
6284 };
6285 let inp = BlendInputs {
6286 pixel: [100, 150, 200, 128],
6287 memory: [10, 20, 30, 40],
6288 ..BlendInputs::default()
6289 };
6290 assert_eq!(Rdp::blend_cycle(cycle, &inp), [60, 95, 130]);
6291 }
6292
6293 /// **Two-cycle mode chains cycle 0's RGB into cycle 1's pixel input.** Cycle 0
6294 /// blends pixel⊕fog → [93,93,93]; cycle 1 re-blends that against fog with
6295 /// `B = zero` → [90,90,90], which differs from cycle 0 alone — proving the
6296 /// chain feeds forward rather than re-reading the original pixel.
6297 #[test]
6298 fn blend_two_cycle_chains() {
6299 let mut rdp = Rdp::new();
6300 rdp.other_modes.cycle_type = 1;
6301 rdp.other_modes.blend[0] = BlendCycle {
6302 p: 0,
6303 a: 0,
6304 m: 3,
6305 b: 2,
6306 };
6307 rdp.other_modes.blend[1] = BlendCycle {
6308 p: 0,
6309 a: 0,
6310 m: 3,
6311 b: 3,
6312 };
6313 let inp = BlendInputs {
6314 pixel: [80, 80, 80, 0xFF],
6315 fog: [16, 16, 16, 0xFF],
6316 ..BlendInputs::default()
6317 };
6318 // Cycle 0 alone would give [93,93,93]; the chained result is [90,90,90].
6319 assert_eq!(
6320 Rdp::blend_cycle(rdp.other_modes.blend[0], &inp),
6321 [93, 93, 93]
6322 );
6323 assert_eq!(rdp.blend(inp), [90, 90, 90], "2-cycle chains forward");
6324 }
6325
6326 /// **The `B = 1 − A` select complements the *resolved* `A`, not pixel alpha.**
6327 /// `A` selects fog alpha (0xFF → a0 = 31) and `B` selects `1 − A`, so
6328 /// `a1 = (~0xFF) >> 3 = 0` and `a1 + 1 = 1`: `100·31 + 10·1 = 3110 → 97`.
6329 /// Pixel alpha is 0x00 here, so the old `!pixel_alpha` bug would use
6330 /// `a1 = (~0x00) >> 3 = 31` → `100·31 + 10·32 = 3420 → 106`; asserting 97
6331 /// fails against that regression (ParaLLEl-RDP `blender.h:106`, `~a0`).
6332 #[test]
6333 fn blend_inv_alpha_complements_selected_a_not_pixel() {
6334 let cycle = BlendCycle {
6335 p: 0,
6336 a: 1,
6337 m: 1,
6338 b: 0,
6339 };
6340 let inp = BlendInputs {
6341 pixel: [100, 100, 100, 0x00],
6342 memory: [10, 10, 10, 40],
6343 fog: [0, 0, 0, 0xFF],
6344 ..BlendInputs::default()
6345 };
6346 assert_eq!(Rdp::blend_cycle(cycle, &inp), [97, 97, 97]);
6347 }
6348
6349 // ---- T-33-004: the Z-buffer machinery ----
6350
6351 /// **The Z codec matches the ParaLLEl-RDP `z_encode.h` arithmetic.** Boundary
6352 /// values are hand-computed; `z_compress ∘ z_decompress` round-trips canonical
6353 /// stored values; `dz` is `1 << n` with an integer-`log2` inverse.
6354 #[test]
6355 fn z_codec_matches_hand_computed() {
6356 // decompress: 0 → 0; max 14-bit (0x3FFF) → max 18-bit (0x3FFFF); a mid value.
6357 assert_eq!(z_decompress(0), 0);
6358 assert_eq!(z_decompress(0x3FFF), 0x3_FFFF);
6359 assert_eq!(z_decompress(0x3000), 0x3_F000); // exp 6, man 0
6360 // round-trip canonical stored values.
6361 for stored in [0u16, 0x2000, 0x3FFF] {
6362 assert_eq!(
6363 z_compress(z_decompress(stored)),
6364 stored,
6365 "round-trip {stored:#x}"
6366 );
6367 }
6368 // dz: 1<<n and its integer-log2 inverse (0 maps to 0 via find_msb == -1).
6369 assert_eq!(dz_decompress(15), 0x8000);
6370 assert_eq!(dz_decompress(0), 1);
6371 assert_eq!(dz_compress(0x8000), 15);
6372 assert_eq!(dz_compress(1), 0);
6373 assert_eq!(dz_compress(0), 0);
6374 assert_eq!(combine_dz(0x180), 0x100, "largest POT <= 384 is 256");
6375 assert_eq!(combine_dz(0), 0);
6376 }
6377
6378 /// **`Set Primitive Depth` (0x2E) and `Set Depth Image` (0x3E) latch.** z in
6379 /// `lo[31:16]`, dz in `lo[15:0]`; the depth-image base masks to 24 bits.
6380 #[test]
6381 fn set_prim_depth_and_depth_image_latch() {
6382 let mut bus = SliceBus {
6383 mem: alloc::vec![0u8; 0x100],
6384 dp_raised: false,
6385 };
6386 let mut rdp = Rdp::new();
6387 rdp.dispatch(OP_SET_PRIM_DEPTH, 0, (0x1234 << 16) | 0x0080, 0, &mut bus);
6388 assert_eq!(rdp.prim_z, 0x1234);
6389 assert_eq!(rdp.prim_dz, 0x0080);
6390 rdp.dispatch(OP_SET_DEPTH_IMAGE, 0, 0xAB00_1240, 0, &mut bus);
6391 assert_eq!(rdp.z_image, 0x0000_1240, "24-bit masked base");
6392 }
6393
6394 /// Build [`DepthInputs`] for a memory pixel at stored depth `0x3000`
6395 /// (`memory_z == 0x3F000`), `dz == 0`, precision-factor 6 (so no `dz`
6396 /// adjustment), with the given mode. `aa`/`force_blend`/`coverage` are off so
6397 /// the depth decision is pure less-than.
6398 fn depth_inputs(z_mode: u8) -> DepthInputs {
6399 DepthInputs {
6400 current_depth: 0x3000,
6401 current_dz: 0,
6402 current_coverage: 0,
6403 z_compare: true,
6404 z_mode,
6405 force_blend: false,
6406 aa_enable: false,
6407 }
6408 }
6409
6410 /// **Opaque Z mode: the nearer pixel passes, the farther pixel is rejected.**
6411 /// The observable occluding-vs-occluded pair against a memory depth of
6412 /// `0x3F000`: an in-front pixel (`z = 0x30000`) writes, a behind one
6413 /// (`z = 0x3FF00`) does not.
6414 #[test]
6415 fn depth_test_opaque_occludes() {
6416 let inp = depth_inputs(0);
6417 assert!(
6418 Rdp::depth_test(0x30000, 0, 0, 1, &inp).depth_pass,
6419 "nearer pixel passes"
6420 );
6421 assert!(
6422 !Rdp::depth_test(0x3FF00, 0, 0, 1, &inp).depth_pass,
6423 "farther pixel rejected"
6424 );
6425 }
6426
6427 /// **Transparent Z mode passes strictly-in-front pixels only.** Same pair:
6428 /// front passes, behind fails (no coverage/decal subtlety).
6429 #[test]
6430 fn depth_test_transparent_passes_front() {
6431 let inp = depth_inputs(2);
6432 assert!(Rdp::depth_test(0x30000, 0, 0, 1, &inp).depth_pass);
6433 assert!(!Rdp::depth_test(0x3FF00, 0, 0, 1, &inp).depth_pass);
6434 }
6435
6436 /// **Decal Z mode passes only coplanar pixels.** A pixel at the memory depth
6437 /// (`z = 0x3F000`) passes; an in-front pixel (`z = 0x30000`) — which opaque
6438 /// mode would accept — is rejected, distinguishing decal from opaque.
6439 #[test]
6440 fn depth_test_decal_passes_coplanar_only() {
6441 let inp = depth_inputs(3);
6442 assert!(
6443 Rdp::depth_test(0x3_F000, 0, 0, 1, &inp).depth_pass,
6444 "coplanar passes"
6445 );
6446 assert!(
6447 !Rdp::depth_test(0x30000, 0, 0, 1, &inp).depth_pass,
6448 "in-front (non-coplanar) rejected"
6449 );
6450 }
6451
6452 /// **`z_compare` off: every pixel passes**, regardless of stored depth — the
6453 /// depth test is bypassed and only coverage/blend state is derived.
6454 #[test]
6455 fn depth_test_disabled_always_passes() {
6456 let mut inp = depth_inputs(0);
6457 inp.z_compare = false;
6458 assert!(Rdp::depth_test(0x3FF00, 0, 0, 1, &inp).depth_pass);
6459 assert!(Rdp::depth_test(0x00000, 0, 0, 1, &inp).depth_pass);
6460 }
6461
6462 /// **Interpenetrating Z mode reduces coverage at an intersect.** With the
6463 /// `front && farther && overflow` intersect condition met (a near pixel just
6464 /// short of `memory_z = 0x3F000`, coverage overflowing), the pixel passes and
6465 /// its coverage is scaled: `cvg_coeff = (0x3F000 − 0x3EFFC) & 0xf = 4`, so
6466 /// `coverage_count = min((4·4) >> 3, 8) = 2` — hand-computed from `depth_test.h`.
6467 #[test]
6468 fn depth_test_interpenetrating_reduces_coverage() {
6469 let inp = DepthInputs {
6470 current_depth: 0x3000, // memory_z = 0x3F000, precision-factor 6
6471 current_dz: 0,
6472 current_coverage: 4, // + coverage_count 4 => overflow (>= 8)
6473 z_compare: true,
6474 z_mode: 1,
6475 force_blend: false,
6476 aa_enable: false,
6477 };
6478 let r = Rdp::depth_test(0x3_EFFC, 0, 0, 4, &inp);
6479 assert!(r.depth_pass, "intersect passes");
6480 assert_eq!(r.coverage_count, 2, "coverage scaled down at the intersect");
6481 }
6482
6483 /// **The `precision_factor < 3` coplanar path forces a pass.** A memory pixel
6484 /// with a low exponent (`precision-factor 2`) and `current_dz == 15`
6485 /// (`memory_dz == 0x8000`) is treated as coplanar, so even a pixel *behind*
6486 /// `memory_z` passes opaque mode — exercising the stored-`dz` adjustment that
6487 /// the plain occluding pairs (precision-factor 6) deliberately avoid.
6488 #[test]
6489 fn depth_test_precision_factor_coplanar_forces_pass() {
6490 let inp = DepthInputs {
6491 current_depth: 0x1000, // memory_z = 0x30000, precision-factor 2 (< 3)
6492 current_dz: 15, // memory_dz = 0x8000 -> coplanar branch
6493 current_coverage: 0,
6494 z_compare: true,
6495 z_mode: 0,
6496 force_blend: false,
6497 aa_enable: false,
6498 };
6499 // 0x3FF00 is behind memory_z (0x30000); without the coplanar path it would
6500 // fail opaque mode, but coplanar makes `nearer` unconditionally true.
6501 assert!(Rdp::depth_test(0x3_FF00, 0, 0, 1, &inp).depth_pass);
6502 }
6503
6504 /// **Out-of-domain inputs are sanitized, not panicked on.** Every argument here
6505 /// is outside its hardware domain — a `current_depth`/`current_dz` with junk in
6506 /// the upper bits, a negative `z`, a huge `dz`, an out-of-4-bit `dz_compressed`.
6507 /// The boundary clamps/masks bound them all, so the shifts (`1 << dz`,
6508 /// `combine_dz`, `combined_dz << 3`) and the `z ± combined_dz` sums stay in
6509 /// range. Without the sanitization this panics in a debug build.
6510 #[test]
6511 fn depth_test_out_of_domain_inputs_do_not_panic() {
6512 let inp = DepthInputs {
6513 current_depth: 0xFFFF, // masked to 14 bits
6514 current_dz: 200, // masked to 4 bits (→ 8)
6515 current_coverage: 0,
6516 z_compare: true,
6517 z_mode: 0,
6518 force_blend: false,
6519 aa_enable: false,
6520 };
6521 // Negative z (clamped to 0), a large dz (clamped to the 18-bit range, so
6522 // combine_dz << 3 stays bounded), and dz_compressed 20 (clamped to 0xf).
6523 let r = Rdp::depth_test(-5, 0x7FFF_FFFF, 20, 1, &inp);
6524 assert!(
6525 r.blend_shift[0] <= 4 && r.blend_shift[1] <= 4,
6526 "invariant holds"
6527 );
6528 }
6529
6530 /// A test bus that models the RDRAM hidden bits (one 2-bit value per 16-bit
6531 /// halfword), so the full 4-bit `dz` round-trip can be exercised.
6532 struct ZBufBus {
6533 mem: Vec<u8>,
6534 hidden: Vec<u8>,
6535 }
6536 impl RdramBus for ZBufBus {
6537 fn rdram_read(&self, addr: u32) -> u8 {
6538 self.mem.get(addr as usize).copied().unwrap_or(0)
6539 }
6540 fn rdram_write(&mut self, addr: u32, val: u8) {
6541 if let Some(b) = self.mem.get_mut(addr as usize) {
6542 *b = val;
6543 }
6544 }
6545 fn rdram_read_hidden(&self, addr: u32) -> u8 {
6546 self.hidden.get((addr >> 1) as usize).copied().unwrap_or(0) & 0x3
6547 }
6548 fn rdram_write_hidden(&mut self, addr: u32, val: u8) {
6549 if let Some(b) = self.hidden.get_mut((addr >> 1) as usize) {
6550 *b = val & 0x3;
6551 }
6552 }
6553 }
6554 impl VideoBus for ZBufBus {
6555 fn raise_dp_interrupt(&mut self) {}
6556 }
6557
6558 /// **The Z buffer round-trips the compressed z and the full 4-bit dz.** The dz
6559 /// splits across the halfword's low 2 bits and the hidden bits, so a value like
6560 /// `0xB` (`0b1011`) only survives if the hidden path carries the low 2 bits —
6561 /// without it, `read` would return `0b1000` (`8`). `0x30000` is a canonical
6562 /// depth that `z_compress`/`z_decompress` reproduce exactly.
6563 #[test]
6564 fn zbuffer_round_trips_z_and_dz() {
6565 let mut bus = ZBufBus {
6566 mem: alloc::vec![0u8; 0x1000],
6567 hidden: alloc::vec![0u8; 0x800],
6568 };
6569 let mut rdp = Rdp::new();
6570 rdp.z_image = 0x200;
6571 rdp.color_image_width = 8;
6572 rdp.zbuffer_write(3, 2, 0x30000, 0xB, &mut bus);
6573 let (cz, dz) = rdp.zbuffer_read(3, 2, &bus);
6574 assert_eq!(cz, z_compress(0x30000), "compressed z stored and loaded");
6575 assert_eq!(
6576 z_decompress(cz),
6577 0x30000,
6578 "canonical depth decompresses exactly"
6579 );
6580 assert_eq!(dz, 0xB, "full 4-bit dz survives via halfword + hidden bits");
6581 // A different pixel is untouched (independent entry).
6582 assert_eq!(rdp.zbuffer_read(4, 2, &bus), (0, 0));
6583 }
6584
6585 /// **`interpolate_z` matches the hand-computed ParaLLEl-RDP snap.** A flat depth
6586 /// (`z_base = 0x0800_0000`, no gradient) snaps to `0x4000`; a pure horizontal
6587 /// gradient (`dzdx = 1.0`) advances `4 · 0x1_0000` over four pixels, which the
6588 /// `>> 10 << 2 >> 5` snap folds to `0x20`.
6589 #[test]
6590 fn interpolate_z_matches_hand_computed() {
6591 assert_eq!(interpolate_z(0x0800_0000, 0, 0, 0, 0, 0, 0), 0x4000);
6592 assert_eq!(interpolate_z(0, 0x0001_0000, 0, 0, 0, 0, 4), 0x20);
6593 // A negative (below-near-plane) depth clamps to 0.
6594 assert_eq!(interpolate_z(-0x0001_0000, 0, 0, 0, 0, 0, 0), 0);
6595 }
6596
6597 /// **A Z-buffered triangle occludes a farther one and yields to a nearer one.**
6598 /// The first-ever depth-tested rendering: three overlapping right triangles into
6599 /// a Z buffer pre-cleared to the far plane, with flat depths `z_px` 0x4000 (near),
6600 /// 0x8000 (far), 0x2000 (nearer). The near draws (vs the cleared buffer), the far
6601 /// is **rejected** (stays the near color), and the nearer **overwrites** — so the
6602 /// test discriminates both the accept and reject paths of `depth_test`.
6603 #[test]
6604 fn depth_tested_triangle_occludes_farther_and_yields_to_nearer() {
6605 let mut bus = ZBufBus {
6606 mem: alloc::vec![0u8; 0x1000],
6607 hidden: alloc::vec![0u8; 0x800],
6608 };
6609 // Pre-clear the Z buffer to the far plane (compressed 0x3FFF → z 0x3FFFF).
6610 for b in &mut bus.mem[0x400..0x500] {
6611 *b = 0xFF;
6612 }
6613 let mut rdp = Rdp::new();
6614 rdp.color_image = 0x200;
6615 rdp.color_image_size = 3; // 32-bit
6616 rdp.color_image_width = 8;
6617 rdp.z_image = 0x400;
6618 rdp.scissor_lrx = 8 << 2;
6619 rdp.scissor_lry = 8 << 2;
6620 rdp.other_modes.z_compare_en = true;
6621 rdp.other_modes.z_update_en = true;
6622 rdp.other_modes.z_mode = 0; // opaque
6623
6624 // Draw the same staircase triangle (as the flat-fill test) at each cmd_base,
6625 // now as the Z-buffered variant (opcode 0x09), with a flat z-suffix (z_base
6626 // only; dzdx/dzde/dzdy = 0). The command word matches the flat-fill test with
6627 // the z-flag bit (56) set → hi 0x0980_0010.
6628 let draw = |bus: &mut ZBufBus, rdp: &mut Rdp, base: usize, z_base: u32, color: u32| {
6629 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
6630 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
6631 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // dxmdy
6632 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&z_base.to_be_bytes()); // z_base
6633 rdp.fill_color = color;
6634 rdp.dispatch(0x09, 0x0980_0010, 0x0010_0000, base as u32, bus);
6635 };
6636 let px = |bus: &ZBufBus, x: usize, row: usize| -> u32 {
6637 let a = 0x200 + row * 32 + x * 4;
6638 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]])
6639 };
6640
6641 // 1-cycle sub-pixel coverage excludes the degenerate top vertex (2,0); check the
6642 // drawn pixel (2,1). The no-shade fill path writes the FILL color verbatim (no
6643 // coverage-alpha), so the full 32-bit value is asserted.
6644 draw(&mut bus, &mut rdp, 0x600, 0x0800_0000, 0x1111_1111); // near (z_px 0x4000)
6645 assert_eq!(px(&bus, 2, 1), 0x1111_1111, "near draws vs cleared buffer");
6646 draw(&mut bus, &mut rdp, 0x700, 0x1000_0000, 0x2222_2222); // far (z_px 0x8000)
6647 assert_eq!(
6648 px(&bus, 2, 1),
6649 0x1111_1111,
6650 "far is occluded (depth rejects)"
6651 );
6652 assert_eq!(px(&bus, 2, 3), 0x1111_1111);
6653 draw(&mut bus, &mut rdp, 0x800, 0x0400_0000, 0x3333_3333); // nearer(z_px 0x2000)
6654 assert_eq!(
6655 px(&bus, 2, 1),
6656 0x3333_3333,
6657 "nearer overwrites (depth accepts)"
6658 );
6659 }
6660
6661 /// **`decode_triangle_z` does not panic on an `i32::MIN` gradient.** The z-suffix
6662 /// is unvalidated RDRAM; a `dzdx`/`dzde` of `0x8000_0000` would overflow `.abs()`.
6663 /// `saturating_abs` keeps it total.
6664 #[test]
6665 fn decode_triangle_z_survives_i32_min_gradient() {
6666 let mut bus = ZBufBus {
6667 mem: alloc::vec![0u8; 0x100],
6668 hidden: alloc::vec![0u8; 0x80],
6669 };
6670 // z-flag set (bit 24), no shade/tex -> z-suffix at cmd_base + 0x20; put
6671 // i32::MIN at dzdx (za + 4 = 0x24).
6672 bus.mem[0x24..0x28].copy_from_slice(&0x8000_0000u32.to_be_bytes());
6673 assert!(Rdp::decode_triangle_z(1 << 24, 0, &bus).is_some());
6674 }
6675
6676 // ---- T-33-004 PR-B 2b: shade interpolation ----
6677
6678 /// **`decode_shade` assembles the RGBA base and `interpolate_shade` yields the
6679 /// byte colors.** The shade block's int-base word packs `R.i`/`G.i` in the hi
6680 /// u32 (bits 56:48 / 40:32) and `B.i`/`A.i` in the lo u32; a flat base (no
6681 /// deltas) of `(100, 150, 200, 255)` interpolates to exactly those bytes.
6682 #[test]
6683 fn decode_shade_assembles_base_and_interpolates() {
6684 let mut bus = ZBufBus {
6685 mem: alloc::vec![0u8; 0x100],
6686 hidden: alloc::vec![0u8; 0x80],
6687 };
6688 // Shade block at cmd_base(0) + 4 words = 0x20. Word0 int-base:
6689 // hi = (R.i << 16) | G.i, lo = (B.i << 16) | A.i. Frac/deltas left 0.
6690 bus.mem[0x20..0x24].copy_from_slice(&((0x64u32 << 16) | 0x96).to_be_bytes()); // R=100 G=150
6691 bus.mem[0x24..0x28].copy_from_slice(&((0xC8u32 << 16) | 0xFF).to_be_bytes()); // B=200 A=255
6692 let shade = Rdp::decode_shade(1 << 26, 0, &bus).expect("shade block present");
6693 assert_eq!(shade.base, [0x64_0000, 0x96_0000, 0xC8_0000, 0xFF_0000]);
6694 assert_eq!(shade.dx, [0; 4]);
6695 assert_eq!(shade.de, [0; 4]);
6696 assert_eq!(
6697 interpolate_shade(&shade.base, &shade.dx, &shade.de, 0, 0, 0, 0),
6698 [100, 150, 200, 255],
6699 "flat base interpolates to the byte color"
6700 );
6701 }
6702
6703 /// **A shaded triangle renders the interpolated color through the combiner.**
6704 /// A flat-shaded triangle (base `(0x11, 0x22, 0x33, 0xFF)`) with a shade-
6705 /// passthrough combiner (`D = shade`, `A = B` so `(A−B)·C = 0`) writes that
6706 /// color — not the FILL register — into the 32-bit color image, proving the
6707 /// decode → interpolate → combine → write path.
6708 #[test]
6709 fn shaded_triangle_renders_combined_shade() {
6710 let mut bus = ZBufBus {
6711 mem: alloc::vec![0u8; 0x1000],
6712 hidden: alloc::vec![0u8; 0x800],
6713 };
6714 let mut rdp = Rdp::new();
6715 rdp.color_image = 0x200;
6716 rdp.color_image_size = 3; // 32-bit
6717 rdp.color_image_width = 8;
6718 rdp.scissor_lrx = 8 << 2;
6719 rdp.scissor_lry = 8 << 2;
6720 rdp.fill_color = 0xDEAD_BEEF; // must NOT appear
6721 // Shade-passthrough combiner: cyc1 D = shade (4), A = B (cancel).
6722 rdp.combine.cyc1 = CombineCycle {
6723 rgb_a: 0,
6724 rgb_b: 0,
6725 rgb_c: 0,
6726 rgb_d: 4,
6727 a_a: 0,
6728 a_b: 0,
6729 a_c: 0,
6730 a_d: 4,
6731 };
6732 // Staircase triangle (as the flat-fill test) with the shade flag (bit 58 ->
6733 // hi bit 26), flat base color (0x11, 0x22, 0x33, 0xFF).
6734 let base = 0x600usize;
6735 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
6736 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
6737 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // dxmdy = 1.0 (R-14)
6738 // Shade int-base at base + 0x20 / 0x24.
6739 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&((0x11u32 << 16) | 0x22).to_be_bytes());
6740 bus.mem[base + 0x24..base + 0x28].copy_from_slice(&((0x33u32 << 16) | 0xFF).to_be_bytes());
6741 // opcode 0x0C = Fill Shaded Triangle (bit 58 set); hi = 0x0880_0010 | (1<<26).
6742 rdp.dispatch(0x0C, 0x0C80_0010, 0x0010_0000, base as u32, &mut bus);
6743
6744 let px = |bus: &ZBufBus, x: usize, row: usize| -> u32 {
6745 let a = 0x200 + row * 32 + x * 4;
6746 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]])
6747 };
6748 // 1-cycle mode stores sub-pixel coverage in the alpha byte (validated against
6749 // Angrylion by `shade_tri_frac_16`), so check the combiner RGB at a fully-
6750 // covered interior pixel — full coverage stores `7 << 5 = 0xE0`. The top
6751 // vertex (2,0) is a degenerate single point, excluded by the AA-off top-left
6752 // sample rule.
6753 assert_eq!(
6754 px(&bus, 2, 3),
6755 0x1122_33E0,
6756 "shaded RGB + full-coverage alpha"
6757 );
6758 assert_eq!(px(&bus, 0, 0), 0, "outside the triangle stays clear");
6759 }
6760
6761 /// **A combined shaded + depth-tested triangle (0x0D) decodes both blocks at the
6762 /// right offsets.** With the shade block at `+0x20` and the z block at `+0x60`
6763 /// (past the 8-word shade block), the pixel must be the shade color *and* the
6764 /// stored depth must be the z-block's value — if `decode_triangle_z` misread the
6765 /// shade block as z, the stored `compressed_z` would differ.
6766 #[test]
6767 fn shaded_and_depth_tested_triangle_reads_both_blocks() {
6768 let mut bus = ZBufBus {
6769 mem: alloc::vec![0u8; 0x1000],
6770 hidden: alloc::vec![0u8; 0x800],
6771 };
6772 for b in &mut bus.mem[0x400..0x500] {
6773 *b = 0xFF; // Z buffer pre-cleared to the far plane
6774 }
6775 let mut rdp = Rdp::new();
6776 rdp.color_image = 0x200;
6777 rdp.color_image_size = 3;
6778 rdp.color_image_width = 8;
6779 rdp.z_image = 0x400;
6780 rdp.scissor_lrx = 8 << 2;
6781 rdp.scissor_lry = 8 << 2;
6782 rdp.other_modes.z_compare_en = true;
6783 rdp.other_modes.z_update_en = true;
6784 rdp.combine.cyc1 = CombineCycle {
6785 rgb_a: 0,
6786 rgb_b: 0,
6787 rgb_c: 0,
6788 rgb_d: 4,
6789 a_a: 0,
6790 a_b: 0,
6791 a_c: 0,
6792 a_d: 4,
6793 };
6794 let base = 0x600usize;
6795 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
6796 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
6797 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // dxmdy
6798 // Shade block int-base at +0x20 (color 0x1122_33FF).
6799 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&((0x11u32 << 16) | 0x22).to_be_bytes());
6800 bus.mem[base + 0x24..base + 0x28].copy_from_slice(&((0x33u32 << 16) | 0xFF).to_be_bytes());
6801 // Z block at +0x60 (past the 8-word shade block): z_base -> z_px 0x4000.
6802 bus.mem[base + 0x60..base + 0x64].copy_from_slice(&0x0800_0000u32.to_be_bytes());
6803 // opcode 0x0D = shade (bit 58) + z (bit 56); hi = 0x0880_0010 | (1<<26) | (1<<24).
6804 rdp.dispatch(0x0D, 0x0D80_0010, 0x0010_0000, base as u32, &mut bus);
6805
6806 // 1-cycle coverage excludes the degenerate top vertex (2,0); check the drawn
6807 // pixel (2,1). The shade RGB is `0x112233`; the alpha holds sub-pixel coverage.
6808 let a = 0x200 + 32 + 2 * 4; // pixel (2, 1)
6809 let color =
6810 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]]);
6811 assert_eq!(
6812 color & 0xFFFF_FF00,
6813 0x1122_3300,
6814 "shade block decoded (color)"
6815 );
6816 let (cz, _) = rdp.zbuffer_read(2, 1, &bus);
6817 assert_eq!(
6818 cz,
6819 z_compress(0x4000),
6820 "z block decoded at +0x60, not the shade block"
6821 );
6822 }
6823
6824 /// **A textured triangle samples the tile through the combiner.** A flat texture
6825 /// coordinate (`s = t = 0`, scale-independent of the perspective divide) samples
6826 /// texel `(0, 0)` — an opaque red RGBA16 — and a texel-passthrough combiner
6827 /// (`D = texel0`) writes `0xFF0000FF`, not the FILL register, proving the
6828 /// `decode_texture` → `interpolate_st` → `fetch_texel` → combine → write path.
6829 #[test]
6830 fn textured_triangle_samples_the_texel() {
6831 let mut bus = ZBufBus {
6832 mem: alloc::vec![0u8; 0x1000],
6833 hidden: alloc::vec![0u8; 0x800],
6834 };
6835 let mut rdp = Rdp::new();
6836 rdp.color_image = 0x200;
6837 rdp.color_image_size = 3;
6838 rdp.color_image_width = 8;
6839 rdp.scissor_lrx = 8 << 2;
6840 rdp.scissor_lry = 8 << 2;
6841 rdp.fill_color = 0xDEAD_BEEF; // must NOT appear
6842 // Tile 0: RGBA16 at TMEM 0; texel (0,0) = 0xF801 (opaque red).
6843 rdp.tiles[0].format = 0;
6844 rdp.tiles[0].size = 2;
6845 rdp.tiles[0].tmem_addr = 0;
6846 rdp.tiles[0].line = 0;
6847 rdp.tmem_write(0, 0xF8);
6848 rdp.tmem_write(1, 0x01);
6849 // Texel-passthrough combiner: cyc1 D = texel0 (1), A = B (cancel).
6850 rdp.combine.cyc1 = CombineCycle {
6851 rgb_a: 0,
6852 rgb_b: 0,
6853 rgb_c: 0,
6854 rgb_d: 1,
6855 a_a: 0,
6856 a_b: 0,
6857 a_c: 0,
6858 a_d: 1,
6859 };
6860 let base = 0x600usize;
6861 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
6862 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
6863 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // dxmdy = 1.0 (R-14)
6864 // Texture block at base + 0x20 (no shade): all-zero -> s = t = 0.
6865 // opcode 0x0A = texture (bit 57); hi = 0x0880_0010 | (1 << 25).
6866 rdp.dispatch(0x0A, 0x0A80_0010, 0x0010_0000, base as u32, &mut bus);
6867
6868 let px = |bus: &ZBufBus, x: usize, row: usize| -> u32 {
6869 let a = 0x200 + row * 32 + x * 4;
6870 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]])
6871 };
6872 // 1-cycle sub-pixel coverage stores `7 << 5 = 0xE0` in the alpha at a
6873 // fully-covered interior pixel; the degenerate top vertex (2,0) is excluded.
6874 assert_eq!(
6875 px(&bus, 2, 3),
6876 0xFF00_00E0,
6877 "textured red + full-coverage alpha, not the FILL color"
6878 );
6879 }
6880
6881 /// **`decode_texture` pairs the interleaved int/frac words per the wiki.** The
6882 /// block is `word0 = s.i/t.i`, `word2 = s.f/t.f`, `word4 = dsde.i` — so the base
6883 /// assembles from words 0 (int) and **2** (frac). Distinct frac bytes absent
6884 /// from word 4 make a mispaired decode (e.g. int + de) surface as a wrong base.
6885 #[test]
6886 fn decode_texture_pairs_interleaved_int_frac() {
6887 let mut bus = ZBufBus {
6888 mem: alloc::vec![0u8; 0x100],
6889 hidden: alloc::vec![0u8; 0x80],
6890 };
6891 // Word0 int-base hi = (s.i << 16) | t.i; word2 frac-base hi = (s.f << 16) | t.f.
6892 // Word4 (de int) left 0, so a base that read word4 as its frac would be wrong.
6893 bus.mem[0x20..0x24].copy_from_slice(&((0x0005u32 << 16) | 0x0007).to_be_bytes());
6894 bus.mem[0x30..0x34].copy_from_slice(&((0x8000u32 << 16) | 0x4000).to_be_bytes());
6895 let tex = Rdp::decode_texture(1 << 25, 0, &bus).expect("texture block present");
6896 assert_eq!(
6897 [tex.base[0], tex.base[1]],
6898 [0x0005_8000, 0x0007_4000],
6899 "base = word0 (int) + word2 (frac), not word4"
6900 );
6901 assert_eq!(tex.de, [0, 0, 0], "de reads word4/word6 (all zero here)");
6902 }
6903
6904 /// **A shaded + textured triangle (0x0E) reads the texture block past the 8-word
6905 /// shade block.** The shade block is 8 words, so the texture block sits at
6906 /// `+0x60`. The texture coordinate there selects texel column 1 (green); if the
6907 /// texture offset were wrong (e.g. a 16-word shade assumption → `+0xA0`), it would
6908 /// read zeros and sample column 0 (red) instead. The green result pins `+0x60`.
6909 #[test]
6910 fn shaded_and_textured_triangle_reads_texture_past_shade() {
6911 let mut bus = ZBufBus {
6912 mem: alloc::vec![0u8; 0x1000],
6913 hidden: alloc::vec![0u8; 0x800],
6914 };
6915 let mut rdp = Rdp::new();
6916 rdp.color_image = 0x200;
6917 rdp.color_image_size = 3;
6918 rdp.color_image_width = 8;
6919 rdp.scissor_lrx = 8 << 2;
6920 rdp.scissor_lry = 8 << 2;
6921 rdp.tiles[0].format = 0;
6922 rdp.tiles[0].size = 2;
6923 // An 8×8 tile so S = texel 1 is in-bounds: the R-13 tile transform forces
6924 // the clamp when `mask == 0`, and a coordinate past `SH` clamps to the last
6925 // texel — with the default `SH = 0` this would clamp S = 1 back to texel 0.
6926 rdp.tiles[0].sh = 7 << 2;
6927 rdp.tiles[0].th = 7 << 2;
6928 // texel (0,0) = red 0xF801, texel (1,0) = green 0x07C1.
6929 rdp.tmem_write(0, 0xF8);
6930 rdp.tmem_write(1, 0x01);
6931 rdp.tmem_write(2, 0x07);
6932 rdp.tmem_write(3, 0xC1);
6933 rdp.combine.cyc1 = CombineCycle {
6934 rgb_a: 0,
6935 rgb_b: 0,
6936 rgb_c: 0,
6937 rgb_d: 1, // texel0
6938 a_a: 0,
6939 a_b: 0,
6940 a_c: 0,
6941 a_d: 1,
6942 };
6943 let base = 0x600usize;
6944 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
6945 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
6946 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // dxmdy
6947 // Shade block at +0x20 (8 words). Texture block at +0x60: the RDP texel
6948 // coordinate is s.5, so S = 32 (`0x20`) selects texel index 32 >> 5 = 1
6949 // (green). This is the R-13 coordinate scale — under the old `v >> 16`
6950 // (no `>> 5`) this same field would have selected column 1 directly.
6951 bus.mem[base + 0x60..base + 0x64].copy_from_slice(&(0x0020u32 << 16).to_be_bytes());
6952 // opcode 0x0E = shade (bit 58) + texture (bit 57); hi = 0x0880_0010 | (1<<26) | (1<<25).
6953 rdp.dispatch(0x0E, 0x0E80_0010, 0x0010_0000, base as u32, &mut bus);
6954
6955 // This near-vertical triangle (DxMDy 0.25) covers column 2 only partially, so
6956 // check the combiner RGB (the point of this test — the texture is decoded at
6957 // +0x60, past the shade block); the alpha holds sub-pixel coverage, exercised
6958 // separately by `shade_tri_frac_16`. Pixel (2,1) is drawn (the top vertex is a
6959 // degenerate point).
6960 let a = 0x200 + 32 + 2 * 4; // pixel (2, 1)
6961 let color =
6962 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]]);
6963 assert_eq!(
6964 color & 0xFFFF_FF00,
6965 0x00FF_0000,
6966 "texel column 1 (green) — texture read at +0x60"
6967 );
6968 assert_ne!(
6969 color & 0xFF,
6970 0,
6971 "the covered pixel stores non-zero coverage"
6972 );
6973 }
6974
6975 /// **`perspective_divide` matches the hand-computed ParaLLEl-RDP arithmetic.** For
6976 /// `w = 0x4000` the LUT gives `rcp = 0x4000`, `shift = 0`, so `s → (s·0x4000) >> 13`
6977 /// (`0x10 → 0x20`); `w = 0x2000` gives `shift = 1` so `>> 12` (`0x100 → 0x400`); and
6978 /// `w <= 0` sets the carry to `0x7FFF`.
6979 #[test]
6980 fn perspective_divide_matches_hand_computed() {
6981 assert_eq!(perspective_divide(0x10, 0x20, 0x4000), (0x20, 0x40));
6982 assert_eq!(perspective_divide(0x100, 0, 0x2000), (0x400, 0));
6983 assert_eq!(
6984 perspective_divide(0x10, 0x20, -1),
6985 (0x7FFF, 0x7FFF),
6986 "w<=0 carry"
6987 );
6988 // The LUT's first/last entries pin the transcription boundaries.
6989 assert_eq!(PERSPECTIVE_TABLE[0], (0x4000, -1008));
6990 assert_eq!(PERSPECTIVE_TABLE[63], (0x2041, -260));
6991 }
6992
6993 /// **A translucent triangle blends with the framebuffer.** A shaded triangle
6994 /// (combiner → red, alpha `0x80`) over a green background, with `force_blend`
6995 /// and blend modes `P = pixel`, `A = pixel-alpha`, `M = memory`, `B = 1−A`, blends
6996 /// 50/50: `a0 = 0x80>>3 = 16`, `a1+1 = (~0x80>>3)+1 = 16`, so each channel is
6997 /// `(pixel + memory)/2` → `0x7F7F00`. Without the memory read (or with blend off)
6998 /// it would be plain red — the green contribution proves the blender ran.
6999 #[test]
7000 fn translucent_triangle_blends_with_framebuffer() {
7001 let mut bus = ZBufBus {
7002 mem: alloc::vec![0u8; 0x1000],
7003 hidden: alloc::vec![0u8; 0x800],
7004 };
7005 // Pre-fill the color image with green (0x00FF00FF) and the Z buffer far.
7006 for row in 0..8 {
7007 for x in 0..8 {
7008 let a = 0x200 + row * 32 + x * 4;
7009 bus.mem[a..a + 4].copy_from_slice(&0x00FF_00FFu32.to_be_bytes());
7010 }
7011 }
7012 for b in &mut bus.mem[0x400..0x500] {
7013 *b = 0xFF;
7014 }
7015 let mut rdp = Rdp::new();
7016 rdp.color_image = 0x200;
7017 rdp.color_image_size = 3;
7018 rdp.color_image_width = 8;
7019 rdp.z_image = 0x400;
7020 rdp.scissor_lrx = 8 << 2;
7021 rdp.scissor_lry = 8 << 2;
7022 rdp.other_modes.z_compare_en = true;
7023 rdp.other_modes.z_update_en = true;
7024 rdp.other_modes.force_blend = true; // -> depth_test sets blend_en
7025 rdp.other_modes.rgb_dither_mode = 3; // dither off: isolate the blend under test
7026 rdp.other_modes.blend[0] = BlendCycle {
7027 p: 0,
7028 a: 0,
7029 m: 1,
7030 b: 0,
7031 }; // pixel, pixel-a, memory, 1-A
7032 rdp.combine.cyc1 = CombineCycle {
7033 rgb_a: 0,
7034 rgb_b: 0,
7035 rgb_c: 0,
7036 rgb_d: 4, // shade rgb
7037 a_a: 0,
7038 a_b: 0,
7039 a_c: 0,
7040 a_d: 4, // shade alpha
7041 };
7042 let base = 0x600usize;
7043 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
7044 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
7045 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // dxmdy
7046 // Shade int-base: R = 0xFF, G = B = 0, A = 0x80.
7047 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&(0xFFu32 << 16).to_be_bytes());
7048 bus.mem[base + 0x24..base + 0x28].copy_from_slice(&0x0000_0080u32.to_be_bytes());
7049 // opcode 0x0D = shade (bit 58) + z (bit 56); z block at +0x60 (near).
7050 bus.mem[base + 0x60..base + 0x64].copy_from_slice(&0x0800_0000u32.to_be_bytes()); // z_px 0x4000
7051 rdp.dispatch(0x0D, 0x0D80_0010, 0x0010_0000, base as u32, &mut bus);
7052
7053 // 1-cycle coverage excludes the degenerate top vertex (2,0) and stores coverage
7054 // in the alpha; check the blended RGB at the drawn pixel (2,1).
7055 let a = 0x200 + 32 + 2 * 4; // pixel (2, 1)
7056 let color =
7057 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]]);
7058 assert_eq!(
7059 color & 0xFFFF_FF00,
7060 0x7F7F_0000,
7061 "50/50 blend of red over green (not plain red)"
7062 );
7063 }
7064
7065 /// **`unpack_rgba5551` widens each 5-bit channel by high-bit replication** and
7066 /// maps the 1-bit alpha to `0x00`/`0xFF` — the exact inverse of `pack_rgba5551`
7067 /// on the packable values. `0x1F → 0xFF`, `0x00 → 0x00`, `0x10 → 0x84`
7068 /// (`0b10000 << 3 | 0b10000 >> 2 = 0x80 | 0x04`).
7069 #[test]
7070 fn unpack_rgba5551_widens_by_high_bit_replication() {
7071 assert_eq!(unpack_rgba5551(0xFFFF), [0xFF, 0xFF, 0xFF, 0xFF]);
7072 assert_eq!(unpack_rgba5551(0x0000), [0x00, 0x00, 0x00, 0x00]);
7073 // R = 0x10 (bits 15:11), everything else zero, alpha bit set.
7074 assert_eq!(unpack_rgba5551(0x8001), [0x84, 0x00, 0x00, 0xFF]);
7075 // Pure green (G = 0x1F) with alpha — the 16-bit background the blend test uses.
7076 assert_eq!(unpack_rgba5551(0x07C1), [0x00, 0xFF, 0x00, 0xFF]);
7077 // Round-trip every packable RGBA8888 whose low bits are already truncated.
7078 for &v in &[0x00u8, 0x08, 0x84, 0xF8, 0xFF] {
7079 let packed = pack_rgba5551([v & 0xF8, 0, v & 0xF8, 0x80]);
7080 let un = unpack_rgba5551(packed);
7081 assert_eq!(pack_rgba5551(un), packed, "round-trip stable for {v:#04x}");
7082 }
7083 }
7084
7085 /// **The blender also runs against a 16-bit RGBA5551 framebuffer.** The same
7086 /// red-over-green 50/50 blend as [`translucent_triangle_blends_with_framebuffer`]
7087 /// but with a 16-bit color image, exercising `read_pixel`'s RGBA5551 decode and
7088 /// `write_pixel`'s repack. Memory green `0x07C1` unpacks to `0x00FF00`; the blend
7089 /// `0x7F7F00` repacks to `0x7BC1` (`R,G = 0x7F>>3 = 0x0F`, alpha bit `0x80>>7 = 1`).
7090 #[test]
7091 fn translucent_triangle_blends_16bit_framebuffer() {
7092 let mut bus = ZBufBus {
7093 mem: alloc::vec![0u8; 0x1000],
7094 hidden: alloc::vec![0u8; 0x800],
7095 };
7096 // Pre-fill the 16-bit color image with green (RGBA5551 0x07C1); Z buffer far.
7097 for row in 0..8 {
7098 for x in 0..8 {
7099 let a = 0x200 + row * 16 + x * 2;
7100 bus.mem[a..a + 2].copy_from_slice(&0x07C1u16.to_be_bytes());
7101 }
7102 }
7103 for b in &mut bus.mem[0x400..0x500] {
7104 *b = 0xFF;
7105 }
7106 let mut rdp = Rdp::new();
7107 rdp.color_image = 0x200;
7108 rdp.color_image_size = 2; // 16-bit RGBA5551
7109 rdp.color_image_width = 8;
7110 rdp.z_image = 0x400;
7111 rdp.scissor_lrx = 8 << 2;
7112 rdp.scissor_lry = 8 << 2;
7113 rdp.other_modes.z_compare_en = true;
7114 rdp.other_modes.z_update_en = true;
7115 rdp.other_modes.force_blend = true;
7116 rdp.other_modes.rgb_dither_mode = 3; // dither off: isolate the blend under test
7117 rdp.other_modes.blend[0] = BlendCycle {
7118 p: 0,
7119 a: 0,
7120 m: 1,
7121 b: 0,
7122 };
7123 rdp.combine.cyc1 = CombineCycle {
7124 rgb_a: 0,
7125 rgb_b: 0,
7126 rgb_c: 0,
7127 rgb_d: 4,
7128 a_a: 0,
7129 a_b: 0,
7130 a_c: 0,
7131 a_d: 4,
7132 };
7133 let base = 0x600usize;
7134 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
7135 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
7136 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // dxmdy
7137 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&(0xFFu32 << 16).to_be_bytes()); // R=0xFF
7138 bus.mem[base + 0x24..base + 0x28].copy_from_slice(&0x0000_0080u32.to_be_bytes()); // A=0x80
7139 bus.mem[base + 0x60..base + 0x64].copy_from_slice(&0x0800_0000u32.to_be_bytes()); // z_px
7140 rdp.dispatch(0x0D, 0x0D80_0010, 0x0010_0000, base as u32, &mut bus);
7141
7142 // Drawn pixel (2,1); the RGBA5551 alpha bit (bit 0) now holds sub-pixel
7143 // coverage, so mask it and check the RGB (0x7BC0 = the 50/50 red-over-green).
7144 let a = 0x200 + 16 + 2 * 2; // pixel (2, 1), bpp = 2
7145 let color = u16::from_be_bytes([bus.mem[a], bus.mem[a + 1]]);
7146 assert_eq!(
7147 color & 0xFFFE,
7148 0x7BC0,
7149 "50/50 blend of red over green, repacked to RGBA5551"
7150 );
7151 }
7152
7153 /// **The blender's `A`-select 2 takes the interpolated shade alpha, not the
7154 /// combiner output alpha.** These are independent inputs; this test forces them
7155 /// apart so a regression that fed `color[3]` back in would be caught. The alpha
7156 /// combiner outputs the env alpha (`0xF0`) while the interpolated shade alpha is
7157 /// `0x80`; the blender selects `A = 2` (shade alpha). With shade alpha `0x80`,
7158 /// `a0 = 16` / `a1 + 1 = 16` gives the 50/50 red-over-green `0x7F7F00`. Had the
7159 /// combiner alpha `0xF0` leaked in, `a0 = 30` / `a1 + 1 = 2` would give
7160 /// `0xEF0F00` — a distinct value, so the test mutation-checks the fix.
7161 #[test]
7162 fn blender_shade_alpha_is_interpolated_not_combiner_output() {
7163 let mut bus = ZBufBus {
7164 mem: alloc::vec![0u8; 0x1000],
7165 hidden: alloc::vec![0u8; 0x800],
7166 };
7167 for row in 0..8 {
7168 for x in 0..8 {
7169 let a = 0x200 + row * 32 + x * 4;
7170 bus.mem[a..a + 4].copy_from_slice(&0x00FF_00FFu32.to_be_bytes()); // green
7171 }
7172 }
7173 for b in &mut bus.mem[0x400..0x500] {
7174 *b = 0xFF;
7175 }
7176 let mut rdp = Rdp::new();
7177 rdp.color_image = 0x200;
7178 rdp.color_image_size = 3;
7179 rdp.color_image_width = 8;
7180 rdp.z_image = 0x400;
7181 rdp.env_color = 0x0000_00F0; // env alpha = 0xF0 (the combiner output alpha)
7182 rdp.scissor_lrx = 8 << 2;
7183 rdp.scissor_lry = 8 << 2;
7184 rdp.other_modes.z_compare_en = true;
7185 rdp.other_modes.z_update_en = true;
7186 rdp.other_modes.force_blend = true;
7187 rdp.other_modes.rgb_dither_mode = 3; // dither off: isolate the blend under test
7188 rdp.other_modes.blend[0] = BlendCycle {
7189 p: 0,
7190 a: 2, // shade alpha -- the input under test
7191 m: 1,
7192 b: 0,
7193 };
7194 rdp.combine.cyc1 = CombineCycle {
7195 rgb_a: 0,
7196 rgb_b: 0,
7197 rgb_c: 0,
7198 rgb_d: 4, // shade rgb -> red
7199 a_a: 7,
7200 a_b: 7,
7201 a_c: 7,
7202 a_d: 5, // env alpha -> combiner output alpha = 0xF0 (!= shade alpha 0x80)
7203 };
7204 let base = 0x600usize;
7205 bus.mem[base + 0x10..base + 0x14].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xh
7206 bus.mem[base + 0x18..base + 0x1C].copy_from_slice(&0x0002_0000u32.to_be_bytes()); // xm
7207 bus.mem[base + 0x1C..base + 0x20].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // dxmdy
7208 bus.mem[base + 0x20..base + 0x24].copy_from_slice(&(0xFFu32 << 16).to_be_bytes()); // R=0xFF
7209 bus.mem[base + 0x24..base + 0x28].copy_from_slice(&0x0000_0080u32.to_be_bytes()); // shade A=0x80
7210 bus.mem[base + 0x60..base + 0x64].copy_from_slice(&0x0800_0000u32.to_be_bytes()); // z_px
7211 rdp.dispatch(0x0D, 0x0D80_0010, 0x0010_0000, base as u32, &mut bus);
7212
7213 // Drawn pixel (2,1); the RGB proves shade alpha 0x80 drove the 50/50 blend
7214 // (combiner alpha 0xF0 would give 0xEF0F00). The stored alpha now holds
7215 // sub-pixel coverage rather than the combiner alpha, so check RGB only.
7216 let a = 0x200 + 32 + 2 * 4; // pixel (2, 1)
7217 let color =
7218 u32::from_be_bytes([bus.mem[a], bus.mem[a + 1], bus.mem[a + 2], bus.mem[a + 3]]);
7219 assert_eq!(
7220 color & 0xFFFF_FF00,
7221 0x7F7F_0000,
7222 "shade alpha 0x80 drives a 50/50 blend, not combiner alpha 0xF0"
7223 );
7224 }
7225
7226 /// **`quantize_x` maps `s.16` edge X to the `s.3` coverage domain with the
7227 /// sticky bit.** An integer pixel `p` maps to `p << 3` (`p·8`); any discarded
7228 /// fraction bit forces the low bit set so the coordinate stays strictly inside
7229 /// the half-open span. Negative coordinates arithmetic-shift toward −∞.
7230 #[test]
7231 fn quantize_x_maps_to_subpixel_domain_with_sticky() {
7232 assert_eq!(quantize_x(5 << 16), 40, "pixel 5 -> 5*8");
7233 // Pixel 5 + a small fraction (0x1000, below sub-pixel resolution): 40 | sticky.
7234 assert_eq!(
7235 quantize_x((5 << 16) | 0x1000),
7236 41,
7237 "sticky bit forces the LSB"
7238 );
7239 // Exactly half a pixel: sub-pixel offset 4 within pixel 5, no discarded bits.
7240 assert_eq!(quantize_x((5 << 16) | (1 << 15)), 44, "pixel 5.5 -> 44");
7241 assert_eq!(
7242 quantize_x(-(3 << 16)),
7243 -24,
7244 "pixel -3 -> -24 (arithmetic shift)"
7245 );
7246 }
7247
7248 /// **`compute_coverage` yields a full mask for an interior pixel and a partial
7249 /// mask at an edge.** With the left edge quantized to sub-pixel `43` (between
7250 /// X-samples 2 and 4), pixel 5's samples at offsets `{0, 2}` fall outside and
7251 /// `{4, 6}` inside, so the four Y-subpixels each cover their high sample only:
7252 /// mask `0xAA`, count 4. A fully-enclosed pixel is `0xFF` (8); a fully-excluded
7253 /// one is `0` (hand-computed against `coverage.h:31-44`).
7254 #[test]
7255 fn compute_coverage_full_partial_and_empty() {
7256 // Fully inside: left edge at 0, right edge far away.
7257 assert_eq!(compute_coverage([0; 4], [800; 4], 5), 0xFF);
7258 assert_eq!(compute_coverage([0; 4], [800; 4], 5).count_ones(), 8);
7259 // Fully outside: right edge behind the pixel.
7260 assert_eq!(compute_coverage([800; 4], [0; 4], 5), 0x00);
7261 // Left edge at sub-pixel 43: each Y-subpixel's low X-sample (offset 0 or 2)
7262 // is outside and its high sample (4 or 6) inside, so every Y-subpixel keeps
7263 // its odd bit only -> 0xAA.
7264 let mask = compute_coverage([43; 4], [800; 4], 5);
7265 assert_eq!(mask, 0xAA, "high sample of each Y-subpixel covered");
7266 assert_eq!(mask.count_ones(), 4);
7267 }
7268
7269 /// **The mask bit layout is `2·Ysub + Xsample`, and the X-sample offsets
7270 /// alternate by Y-subpixel.** Covering only Y-subpixel 0 sets bits `{0, 1}`
7271 /// (`0x03`), proving the two X-samples of a Y-subpixel occupy adjacent bits.
7272 /// A uniform left edge at sub-pixel `41` then discriminates the diamond
7273 /// offsets: pixel 5's samples are at `{40, 44}` for Y-subpixels 0/2 and
7274 /// `{42, 46}` for 1/3, so 0/2 lose their offset-0 sample (`40 < 41`) while 1/3
7275 /// keep both — mask `0xEE`. (Hand-computed against `coverage.h:31-44`.)
7276 #[test]
7277 fn compute_coverage_bit_layout_and_diamond_offsets() {
7278 // Only Y-subpixel 0's span is valid; the others are poisoned (inverted).
7279 let only_y0 = compute_coverage([0, 800, 800, 800], [800, 0, 0, 0], 5);
7280 assert_eq!(only_y0, 0x03, "Y-subpixel 0 occupies bits 0 and 1");
7281 // Uniform left edge at 41: 0/2 (offsets 0,4) drop offset 0; 1/3 (offsets 2,6) keep both.
7282 let mask = compute_coverage([41; 4], [800; 4], 5);
7283 assert_eq!(
7284 mask, 0xEE,
7285 "Y0/Y2 lose their offset-0 sample; Y1/Y3 keep both"
7286 );
7287 assert_eq!(mask.count_ones(), 6);
7288 }
7289}