Skip to main content

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}