diff --git a/CHANGELOG.md b/CHANGELOG.md index fa662039..069ef060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ All notable changes to RustyN64 are documented here. The format is based on Work toward `v0.8.0 "Breadth"` — the accuracy battery (Phase 7). +### Added — RDP chroma-key alpha compare (gap-analysis Stage D, ledger R-10) + +- **`key_en` (Set Other Modes bit 40) chroma-keying.** `Set Key GB`/`R` now also + store the per-channel `key_width`, and when `key_en` is set `Rdp::combine` takes + the Angrylion `combiner_1cycle` key path: the RGB output is the sub-A chromabypass + colour and the pixel alpha is `chroma_key_min` over the pre-`>>8` 17-bit combined + colour and the key widths. The new behaviour is gated on `key_en`, so the common + combiner path is byte-identical (all 31 prior RDP conformance vectors unchanged). + Validated byte-for-byte against Angrylion by `tex_tri_chromakey_alpha_16` (golden + `0x4321`, non-vacuous — clearing `key_en` outputs the combined colour) plus + mutation-checked unit tests. n64-systemtest impact: none. Still deferred under + R-10: noise (un-oracled), the derivative `lod_frac`, and the YUV `K0`–`K3` convert. + ### Added — RDP chroma-key combiner inputs (gap-analysis Stage D, ledger R-10) - **`Set Key GB` (0x2A) / `Set Key R` (0x2B) now decode** the per-channel chroma-key @@ -20,8 +33,8 @@ Work toward `v0.8.0 "Breadth"` — the accuracy battery (Phase 7). (`combine_cycle_routes_chroma_key`), and the `tex_tri_chromakey_16` conformance vector validates the path byte-for-byte end-to-end against Angrylion (RGBA5551 `0x3b1f`, black if unwired). n64-systemtest impact: none (no RDP-combiner - coverage). Still deferred under R-10: noise, the derivative `lod_frac`, the - chroma-key alpha compare, and the YUV convert `K0`–`K3`. + coverage). Still deferred under R-10: noise, the derivative `lod_frac`, and the + YUV convert `K0`–`K3` (the chroma-key alpha compare is wired above). ### Changed — the frontend now presents the accurate VI scan-out (ledger R-5) diff --git a/crates/rustyn64-rdp/src/lib.rs b/crates/rustyn64-rdp/src/lib.rs index 2428f791..7d82412a 100644 --- a/crates/rustyn64-rdp/src/lib.rs +++ b/crates/rustyn64-rdp/src/lib.rs @@ -597,6 +597,35 @@ fn clamp_9bit(color: i32) -> u8 { special_expand(color).clamp(0, 0xFF) as u8 } +/// The combiner equation's **pre-`>>8` 17-bit** result — what the chroma-key alpha +/// compare consumes (Angrylion `color_combiner_equation`). Same terms as +/// [`combine_channel`] but returning `((A − B) * C + (D << 8) + 0x80) & 0x1ffff` +/// rather than the `>> 8`'d colour. +const fn combine_channel_17bit(a: i32, b: i32, c: i32, d: i32) -> i32 { + (((special_expand(a) - special_expand(b)) * sext9(c)) + (special_expand(d) << 8) + 0x80) + & 0x1_FFFF +} + +/// The chroma-key alpha (Angrylion `chroma_key_min`): per channel, fold the sign of +/// the 17-bit combined value into a distance, offset by the programmed half-width, +/// take the minimum across R/G/B, and clamp to `[0, 0xff]`. `col17` is the pre-`>>8` +/// combined colour ([`combine_channel_17bit`]); `width` is the 12-bit `Set Key` width +/// per channel. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn chroma_key_min(col17: [i32; 3], width: [u16; 3]) -> u8 { + let mut keyalpha = i32::MAX; + for ch in 0..3 { + // Sign-extend the 17-bit value (mask first so an unmasked caller is safe). + let mut k = ((col17[ch] & 0x1_FFFF) << 15) >> 15; // SIGN(col, 17) + if k > 0 { + k = if (k & 0xf) == 8 { -k + 0x10 } else { -k }; + } + k += i32::from(width[ch]) << 4; + keyalpha = keyalpha.min(k); + } + keyalpha.clamp(0, 0xff) as u8 +} + /// The combiner's "1" input — `0x100` in the internal `.8` representation, not /// `0xFF` (N64brew *…/Commands* §0x3C). const COMBINER_ONE: i16 = 0x100; @@ -869,6 +898,9 @@ pub struct OtherModes { pub z_mode: u8, /// Alpha-compare enable (gates the pixel write; R-11). pub alpha_compare_en: bool, + /// Chroma-key enable (bit 40): the combiner outputs the sub-A colour and derives + /// the pixel alpha from the key window (`chroma_key_min`). R-10. + pub key_en: bool, /// Perspective-correct texturing (bit 51): divide the interpolated `S`/`T` by `W`. pub persp_tex_en: bool, /// Anti-aliasing enable (bit 3): sub-pixel edge coverage governs which edge @@ -1384,13 +1416,15 @@ pub struct Rdp { /// `Set Convert` `K5` (see `k4`). pub k5: i16, /// Chroma-key **centre** per channel `[r, g, b]` (`Set Key R` 0x2B / `Set Key GB` - /// 0x2A) — the combiner RGB sub-B input (select 6). `0..=255`. The key **width** - /// (used only by the deferred chroma-key alpha compare, not the combiner mux) is - /// not stored — it has no consumer yet, so it lands with that path. R-10. + /// 0x2A) — the combiner RGB sub-B input (select 6). `0..=255`. R-10. pub key_center: [u8; 3], /// Chroma-key **scale** per channel `[r, g, b]` (`Set Key R`/`GB`) — the combiner /// RGB mul input (select 6). `0..=255`. R-10. pub key_scale: [u8; 3], + /// Chroma-key **width** per channel `[r, g, b]` (`Set Key R`/`GB`, 12-bit) — the + /// half-width of the key window, consumed by the `key_en` chroma-key alpha compare + /// (`chroma_key_min`), not the combiner mux. R-10. + pub key_width: [u16; 3], /// The environment colour, RGBA8888 (`Set Env Color`, 0x3B). pub env_color: u32, /// The blend colour, RGBA8888 (`Set Blend Color`, 0x39). @@ -1608,18 +1642,20 @@ impl Rdp { self.k5 = (lo & 0x1FF) as i16; } OP_SET_KEY_GB => { - // word 1 (lo): centre_g[31:24], scale_g[23:16], centre_b[15:8], - // scale_b[7:0] (Angrylion `rdp_set_key_gb`). The widths — word 0 (hi): - // width_g[23:12], width_b[11:0] — drive only the deferred chroma-key - // alpha compare, not the combiner mux, so they are not stored. + // word 0 (hi): width_g[23:12], width_b[11:0]; word 1 (lo): + // centre_g[31:24], scale_g[23:16], centre_b[15:8], scale_b[7:0] + // (Angrylion `rdp_set_key_gb`). Widths feed the `key_en` alpha compare. + self.key_width[1] = ((hi >> 12) & 0xFFF) as u16; + self.key_width[2] = (hi & 0xFFF) as u16; self.key_center[1] = (lo >> 24) as u8; self.key_scale[1] = (lo >> 16) as u8; self.key_center[2] = (lo >> 8) as u8; self.key_scale[2] = lo as u8; } OP_SET_KEY_R => { - // word 1 (lo): width_r[31:16] (deferred), centre_r[15:8], scale_r[7:0] - // (Angrylion `rdp_set_key_r`). + // word 1 (lo): width_r[27:16] (12-bit), centre_r[15:8], scale_r[7:0] + // (Angrylion `rdp_set_key_r`, `(args[1] >> 16) & 0xfff`). + self.key_width[0] = ((lo >> 16) & 0xFFF) as u16; self.key_center[0] = (lo >> 8) as u8; self.key_scale[0] = lo as u8; } @@ -2687,7 +2723,26 @@ impl Rdp { // before cycle 1 (`combiner_2cycle_cycle1`, R-13). core::mem::swap(&mut inp.texel0, &mut inp.texel1); } - Self::combine_cycle(self.combine.cyc1, &inp) + let cyc = self.combine.cyc1; + if self.other_modes.key_en { + // Chroma-key alpha compare (Angrylion `combiner_1cycle` key_en path, R-10): + // the RGB output is the sub-A "chromabypass" colour (clamped), and the pixel + // alpha is derived from the key window over the pre-`>>8` 17-bit combined + // colour. Gated on `key_en` so the common path (below) is byte-identical. + let mut col17 = [0i32; 3]; + let mut rgb = [0u8; 3]; + for (ch, (c17, out)) in col17.iter_mut().zip(rgb.iter_mut()).enumerate() { + let a = i32::from(rgb_input_a(cyc.rgb_a, &inp, ch)); + let b = i32::from(rgb_input_b(cyc.rgb_b, &inp, ch)); + let c = i32::from(rgb_input_c(cyc.rgb_c, &inp, ch)); + let d = i32::from(rgb_input_d(cyc.rgb_d, &inp, ch)); + *c17 = combine_channel_17bit(a, b, c, d); + *out = clamp_9bit(a); // chromabypass = sub-A input, clamped + } + let keyalpha = chroma_key_min(col17, self.key_width); + return [rgb[0], rgb[1], rgb[2], keyalpha]; + } + Self::combine_cycle(cyc, &inp) } /// Decode `Set Other Modes` (0x2F) into [`OtherModes`]. The blend selects and @@ -2723,10 +2778,11 @@ impl Rdp { z_update_en: (lo >> 5) & 1 != 0, z_mode: ((lo >> 10) & 0x3) as u8, alpha_compare_en: lo & 1 != 0, - persp_tex_en: (hi >> 19) & 1 != 0, // command bit 51 - aa_enable: (lo >> 3) & 1 != 0, // command bit 3 + key_en: (hi >> 8) & 1 != 0, // command bit 40 + persp_tex_en: (hi >> 19) & 1 != 0, // command bit 51 + aa_enable: (lo >> 3) & 1 != 0, // command bit 3 rgb_dither_mode: ((hi >> 6) & 0x3) as u8, // command bits 39:38 - sample_type: (hi >> 13) & 1 != 0, // command bit 45 + sample_type: (hi >> 13) & 1 != 0, // command bit 45 }; } @@ -3750,19 +3806,23 @@ mod tests { assert_eq!(rdp.color_image, 0x0010_0000); } - /// **`Set Key GB`/`Set Key R` decode the chroma-key centre/scale per channel - /// (R-10).** Pins the bit-layout ported from Angrylion `rdp_set_key_gb`/`_r`: - /// GB word-1 is `centre_g[31:24] scale_g[23:16] centre_b[15:8] scale_b[7:0]`, and - /// R word-1 is `width_r[31:16] centre_r[15:8] scale_r[7:0]` (width unused). Distinct - /// per-channel values so a field-swap in the decode is caught. + /// **`Set Key GB`/`Set Key R` decode the chroma-key centre/scale/width per channel + /// (R-10).** Pins the bit-layout ported from Angrylion `rdp_set_key_gb`/`_r`: GB + /// word-0 is `width_g[23:12] width_b[11:0]`, word-1 `centre_g[31:24] scale_g[23:16] + /// centre_b[15:8] scale_b[7:0]`; R word-1 is `width_r[27:16] centre_r[15:8] + /// scale_r[7:0]`. Distinct per-channel values (incl. distinct 12-bit widths) so a + /// field-swap or wrong extraction in the decode is caught. #[test] - fn set_key_decodes_centre_and_scale_per_channel() { + fn set_key_decodes_centre_scale_and_width_per_channel() { let (rdp, _) = run_commands(&[ - (0x2A00_0000, 0x4080_60C0), // Set Key GB: cg=0x40 sg=0x80 cb=0x60 sb=0xC0 - (0x2B00_0000, 0x0000_2040), // Set Key R: wr=0 cr=0x20 sr=0x40 + // Set Key GB: wg=0x111 wb=0x222; cg=0x40 sg=0x80 cb=0x60 sb=0xC0. + (0x2A11_1222, 0x4080_60C0), + // Set Key R: wr=0x333; cr=0x20 sr=0x40. + (0x2B00_0000, 0x0333_2040), ]); assert_eq!(rdp.key_center, [0x20, 0x40, 0x60], "centre [r, g, b]"); assert_eq!(rdp.key_scale, [0x40, 0x80, 0xC0], "scale [r, g, b]"); + assert_eq!(rdp.key_width, [0x333, 0x111, 0x222], "width [r, g, b]"); } /// **`Set Fill Color` and `Set Scissor` store their values.** @@ -5155,6 +5215,30 @@ mod tests { assert_eq!(Rdp::combine_cycle(cfg, &inp), [56, 96, 120, 255]); } + /// **`chroma_key_min` folds each channel, offsets by the width, and takes the + /// minimum (R-10).** Per channel `k = SIGN(col17, 17)`; if `k > 0`, `k = -k` + /// (or `-k + 0x10` when the low nibble is 8); then `k = (width << 4) + k`; the + /// result is `min(kr, kg, kb)` clamped to `[0, 0xff]`. Hand-computed cases: + #[test] + fn chroma_key_min_folds_and_takes_the_minimum() { + // Positive col17, low nibble != 8: k = (width<<4) - col17. + // r: 128 - 0x10 = 112; g: 128 - 0x20 = 96; b: 128 - 0x30 = 80 -> min 80. + assert_eq!(chroma_key_min([0x10, 0x20, 0x30], [8, 8, 8]), 80); + // Low-nibble == 8 special fold: k = -col17 + 0x10. col17 0x18 -> -0x18+0x10 = -8; + // r: 128 - 8 = 120; the wider g/b (256) leave r the minimum. + assert_eq!(chroma_key_min([0x18, 0x300, 0x300], [8, 0x40, 0x40]), 120); + // A large col17 drives k negative -> the minimum clamps to 0. + assert_eq!(chroma_key_min([0x400, 0x10, 0x10], [8, 8, 8]), 0); + // Bit 16 set: SIGN(col17, 17) is NEGATIVE (col17 - 0x20000), so the `k > 0` + // fold is skipped. 0x1FFF0 -> -16; r: (8<<4) - 16 = 112, the minimum vs the + // wider g/b. This exercises the signed-fold branch (a broken sign-extend that + // read 0x1FFF0 as positive would fold to a large negative and clamp to 0). + assert_eq!( + chroma_key_min([0x1_FFF0, 0x300, 0x300], [8, 0x40, 0x40]), + 112 + ); + } + /// **Two-cycle mode chains cycle 0 into cycle 1's `Combined` input.** Cycle 0 /// passes texel0 through; cycle 1 selects `Combined` for A (with C=One-ish), /// so the final output reflects cycle 0's result — not cycle 1 reading a stale diff --git a/crates/rustyn64-test-harness/tests/rdp_conformance.rs b/crates/rustyn64-test-harness/tests/rdp_conformance.rs index 7d65b5cd..a3b5b89d 100644 --- a/crates/rustyn64-test-harness/tests/rdp_conformance.rs +++ b/crates/rustyn64-test-harness/tests/rdp_conformance.rs @@ -459,6 +459,26 @@ fn tex_tri_chromakey_16_matches_angrylion() { ); } +/// **The chroma-key alpha compare (`key_en`, Set Other Modes bit 40) — R-10.** With +/// `key_en`, the combiner outputs the sub-A "chromabypass" colour (Shade) and derives +/// the pixel alpha from `chroma_key_min` over the pre-`>>8` 17-bit combined colour + the +/// `Set Key` widths. The **key alpha is made observable** by enabling alpha-compare +/// (bit 0) with a `Set Blend Color` threshold of `0x80`: the Shade triangle is written +/// only where the key alpha `>= 0x80`. The combine (`rgb_a=rgb_b=Shade` → 17-bit `0x80`, +/// `width_r=0x10`) yields key alpha `0x80` exactly, so the triangle IS drawn — and a +/// broken `chroma_key_min` shifting it below `0x80` makes the triangle vanish (a −1 +/// mutation is verified to fail), while clearing `key_en` outputs the combined colour +/// (black) instead of Shade. Pins the `key_en`/`key_width` decode, the 17-bit combined +/// value, and `chroma_key_min` end-to-end against Angrylion (the `chroma_key_min` fold +/// itself is additionally unit-tested with hand-computed values). +#[test] +fn tex_tri_chromakey_alpha_16_matches_angrylion() { + assert_matches( + "tex_tri_chromakey_alpha_16", + include_bytes!("vectors/tex_tri_chromakey_alpha_16.rvec"), + ); +} + /// A **COPY-mode Texture Rectangle** (16-bit) — the first texture path validated /// against Angrylion. Copy mode blits texels straight from TMEM to the colour image /// (no combiner, no 1-cycle texel pipeline), so it sidesteps the gaps `tex_tri_16` diff --git a/crates/rustyn64-test-harness/tests/vectors/tex_tri_chromakey_alpha_16.rvec b/crates/rustyn64-test-harness/tests/vectors/tex_tri_chromakey_alpha_16.rvec new file mode 100644 index 00000000..baa49057 Binary files /dev/null and b/crates/rustyn64-test-harness/tests/vectors/tex_tri_chromakey_alpha_16.rvec differ diff --git a/crates/rustyn64-test-harness/vectors-gen/driver.c b/crates/rustyn64-test-harness/vectors-gen/driver.c index 13c7fa7d..be58ba1e 100644 --- a/crates/rustyn64-test-harness/vectors-gen/driver.c +++ b/crates/rustyn64-test-harness/vectors-gen/driver.c @@ -953,6 +953,31 @@ static const uint32_t V29_TEX_TRI_CHROMAKEY_16[] = { SHADE_BLOCK_FLAT(0x40, 0x40, 0x40, 0xFF), // flat shade (ignored by the combine) }; +// V30: the chroma-key ALPHA compare (key_en, Set Other Modes bit 40) — ledger R-10. +// The key alpha (`chroma_key_min`) must be OBSERVABLE, so alpha-compare is enabled +// (bit 0) with a Set Blend Color threshold of 0x80: the pixel is written only if the +// key alpha >= 0x80. With rgb_a=rgb_b=Shade the 17-bit combined colour is 0x80 (A−B=0 +// → 0x80 constant), so per channel `SIGN(0x80)=128` folds to `-128`, then `+width<<4`: +// width_r=0x10 → 256−128 = 128 = 0x80 (the min across r/g/b), which meets the 0x80 +// threshold, so the Shade chromabypass triangle IS drawn. A broken `chroma_key_min` +// (wrong sign/fold/width/min) shifts the key alpha below 0x80 → the triangle vanishes; +// clearing key_en outputs the *combined* colour (black, A−B=0) instead of Shade. Both +// make this non-vacuous — the golden's drawn Shade pixels observe the key alpha. +static const uint32_t V30_TEX_TRI_CHROMAKEY_ALPHA_16[] = { + 0x2F0001F0u, 0x00000001u, // Set Other Modes: 1-cycle, KEY_EN (bit 40), ALPHA_COMPARE (bit 0) + 0x39000000u, 0x00000080u, // Set Blend Color: alpha threshold = 0x80 + 0x2A014018u, 0x00000000u, // Set Key GB: wg=0x14 wb=0x18 (centre/scale unused here) + 0x2B000000u, 0x00100000u, // Set Key R: wr=0x10 (centre/scale unused) + 0x3C000080u, 0x041C01C6u, // Set Combine: rgb_a=Shade rgb_b=Shade rgb_c=Combined rgb_d=Zero; a=One + 0x3F100007u, 0x00001000u, // Set Color Image: 16-bit, width 8, addr 0x1000 + 0x2D000000u, 0x00020020u, // Set Scissor: (0,0)-(8,8) + 0x0C800020u, 0x00200000u, // op=0x0C (shade), lft=1, yl=32, ym=32, yh=0 + 0x00000000u, 0x00000000u, // XL, DxLDy + 0x00020000u, 0x00000000u, // XH = 2.0 + 0x00020000u, 0x00010000u, // XM = 2.0, DxMDy = 1.0 + SHADE_BLOCK_FLAT(0x40, 0x60, 0x80, 0xFF), // flat shade -> chromabypass + col17 source +}; + // ---- Seeded fuzz generator (SplitMix64) ---- // // A reproducible pseudo-random corpus: the seed and this generator's source fully @@ -1622,6 +1647,11 @@ int main(int argc, char **argv) { 0, 0, NULL}; if (emit_vector(&v29, out_dir)) return 1; + Vector v30 = {"tex_tri_chromakey_alpha_16", 0x2000, 0x1000, 8, 8, 2, + sizeof(V30_TEX_TRI_CHROMAKEY_ALPHA_16) / 4, V30_TEX_TRI_CHROMAKEY_ALPHA_16, + 0, 0, NULL}; + if (emit_vector(&v30, out_dir)) return 1; + if (emit_vi_vectors(out_dir)) return 1; return 0; diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 59ef5183..1d8b0323 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -388,7 +388,7 @@ labelled as one until something reads the register on hardware. | R-1 | **RESOLVED** — see C-21. The failing instruction was `ADD.S $1, $29, $30`, not the `ADD.S $0` the assertion names; a correlated capture separated cause from visible effect by exactly the pipeline depth | — | absolute | Closed | | R-2 | **RESOLVED** — `BC1` implemented, and the compare forwarded to it (C-25) | — | absolute | Closed | | R-6 | **[HISTORICAL BASELINE — the "only NTSC is modelled" claim is SUPERSEDED by the PAL 50 Hz RESOLUTION in the disposition column; retained per the append-mostly rule.]** The VI scan cadence (T-31-004) *was* anchored to a **nominal 60 Hz field rate** (`VI_FIELD_HZ`) with only NTSC modelled — the per-half-line period was `MASTER_HZ / 60 / (VI_V_TOTAL + 1)`, and `VI_V_CURRENT` / the `VI_V_INTR` interrupt derive from that | The VI dot clock is off a separate crystal the N64brew wiki gives only *roughly* (*Video Interface* §Clocks: "roughly 12.3 megapixels/sec", ×4 ≈ 49 MHz VI clock; the exact NTSC value is not stated). Rather than fit an imprecise dot-clock frequency, the field rate is anchored to the standard NTSC 60 Hz and the half-line count taken from the software-programmed `VI_V_TOTAL` — so the cadence is correct to the field, and only the sub-field phase (which `H_TOTAL`/`H_TOTAL_LEAP` set exactly) is nominal. The interlace `VI_V_INTR` bit-0 quirk (§VI_V_INTR) is also not modelled | absolute — a clock-rate anchor, not a fitted per-ROM constant | **Open.** Correct to the field: `VI_V_CURRENT` advances and wraps at `VI_V_TOTAL + 1`, and the VI interrupt fires once per field at `VI_V_INTR` (pinned by the `vi` unit tests and a scheduler integration test). Deferred: the exact `H_TOTAL`/leap sub-field timing, PAL's 50 Hz field rate, and the interlace `V_INTR` quirk. To be validated against n64-systemtest's `timing`/VI groups when they are run. **PARTIALLY RESOLVED (2026-07-25) — PAL 50 Hz field rate.** `Vi::field_hz` now selects PAL **50 Hz** (`VI_FIELD_HZ_PAL`) when the field is PAL-length (`VI_V_TOTAL > 550`) and NTSC 60 Hz otherwise, so `ticks_per_halfline = MASTER_HZ / field_hz / (VI_V_TOTAL + 1)` scans PAL games at the right cadence — the same `ispal` split the scan-out geometry (R-5 slice 4a) already uses, so cadence and region agree — the `> 550` boundary is now the shared `VI_PAL_V_TOTAL_THRESHOLD` constant (one definition for both `Vi::field_hz` and `bus::scanout_scaled`). **Provenance:** 50 Hz is the documented PAL broadcast standard (N64brew *Video Interface*; counterpart to the anchored NTSC 60 Hz), not a fitted value; the `> 550` split is a wide-margin discriminator between NTSC's ~525-half-line and PAL's ~625-half-line fields (N64brew *Video Interface* §Clocks region field lengths), not a measured edge. Pinned by `a_pal_length_field_scans_at_50hz` (a PAL field advances one half-line in `MASTER_HZ/50/625 = 6000` ticks, distinct from the 60 Hz `5000`; mutation forcing always-60 fails it) and `the_pal_threshold_is_exactly_550` (V_TOTAL 550 = NTSC, 551 = PAL — pins the exact `>` boundary), with NTSC fields unaffected. **n64-systemtest impact: not measured** — the suite runs NTSC and has no VI field-timing group, so the PAL cadence is unreachable by it (the count is unchanged for that reason, not measured against it). **Still deferred:** the exact `H_TOTAL`/leap sub-field timing and the interlace/serrate `V_INTR` bit-0 quirk | -| R-10 | The colour combiner (T-33-002) models the common inputs (combined, texel0/1, primitive, shade, environment, one, zero, and the C-slot alpha taps); the **exotic inputs** — noise, LOD fraction / prim-LOD-fraction, the chroma-key centre/scale, and the convert (`K4`/`K5`) constants — are not modelled and read as **zero** | These inputs need the LOD pipeline (mip level fraction), the key/convert registers (`Set Key`/`Set Convert`), and a noise source, none of which exist yet; they appear in a small minority of combine modes. Reading them as zero is a bounded, documented gap, not a fabricated value | absolute — a coverage boundary, not a fitted constant | **Partially resolved (2026-07-25) — see the RESOLUTION below.** The `(A − B) * C + D` arithmetic (the `special_expand` asymmetric 9-bit fold, the `+0x80`-before-`>>8` rounding, D added unscaled) and the clamp are validated bit-for-bit against hand-computed values; the 16-field decode, the input mux, and the 2-cycle chaining are unit-tested. The remaining exotic inputs land with the LOD/key/noise state and are validated against the ParaLLEl-RDP conformance vectors (T-33-005). **RESOLUTION (2026-07-25) — the register-sourced exotic inputs are wired.** `PRIM_LOD_FRAC` (RGB mul-select 14, alpha mul-select 6, extracted from `Set Prim Color` word-0 low byte; `min_level`, bits 12:8, stays deferred — it is not stored, and lands with its LOD consumer rather than as unread state) and the `Set Convert` (`0x2C`) constants `K4` (RGB sub-B select 7) and `K5` (RGB mul-select 15, both raw 9-bit from `lo[17:9]`/`lo[8:0]`) now route through the combiner instead of reading zero. Validated byte-for-byte against Angrylion by three new non-vacuous conformance vectors — `tex_tri_primlodfrac_16` (`One * prim_lod_frac`, golden `0x8421` gray vs black if unwired), `tex_tri_convert_k45_16` (`(One − K4) * K5` with bit-8-clear K4/K5, golden `0x94a5` vs black if unwired), and `tex_tri_convert_kneg_16` (a **negative** `K4 = 0x1C0` = −64, golden `0x5295` gray — correct only if the raw 448 is `special_9bit`-expanded to −64; a raw-positive read clamps black) — plus two mutation-checked unit tests (`combine_cycle_routes_prim_lod_frac`, `combine_cycle_routes_convert_k4_k5`). The sign path is faithful to Angrylion, which likewise stores K4/K5 **raw 0..511** (`rdp_set_convert`) and sign-extends in the equation: sub-B via `special_9bit_exttable` (`combiner.c:481`, reproduced bit-for-bit by RustyN64's `special_expand`) and the mul via `SIGNF(c, 9)` (RustyN64's `sext9`). **n64-systemtest impact: none** — the suite has no RDP-combiner coverage, so its failing-assertion count is unchanged (90); R-10 is validated only by the Angrylion conformance vectors. **RESOLUTION (2026-07-25) — the chroma-key combiner inputs are wired.** `Set Key GB` (`0x2A`) and `Set Key R` (`0x2B`) now decode the per-channel key **centre** and **scale** (bit-layout ported from Angrylion `rdp_set_key_gb`/`rdp_set_key_r`: GB `lo` = `centre_g[31:24] scale_g[23:16] centre_b[15:8] scale_b[7:0]`, R `lo` = `width_r[31:16] centre_r[15:8] scale_r[7:0]`), and they route through the combiner as **KeyCentre** (RGB sub-B select 6) and **KeyScale** (RGB mul-select 6) instead of reading zero — matching Angrylion `combiner.c` cases 6. The key **width** is not stored: it drives only the deferred chroma-key alpha compare, not the combiner mux, so it lands with that consumer (the `min_level` precedent). Validated by two mutation-checked unit tests — `set_key_decodes_centre_and_scale_per_channel` (distinct per-channel values pin the decode field positions) and `combine_cycle_routes_chroma_key` (`(One − centre) * scale >> 8` with per-channel centre `[32,64,96]`/scale `[64,128,192]` → `[56,96,120]`; unwiring either input changes the result) — **and byte-for-byte against Angrylion** by the end-to-end conformance vector `tex_tri_chromakey_16` (centre `[0x20,0x40,0x60]`, scale `[0x40,0x80,0xC0]` → RGBA5551 `0x3b1f`, black if unwired), which exercises the decode + mux through the real RDP pipeline. **n64-systemtest impact: none** — no RDP-combiner coverage; count unchanged (90). **Still open** (genuinely need machinery that does not exist yet, read as zero): **noise** (RGB sub-A select 7 — needs a per-pixel noise source), the **derivative-computed `lod_frac`** (RGB mul-select 13 / alpha mul-select 0 — needs the LOD/mip pipeline, pairs with R-13's mip tile selection), the **chroma-key alpha compare** (the `key_en` keying path that consumes the key *width*, distinct from the now-wired combiner inputs), and the **YUV convert `K0`–`K3`** coefficients (the `Set Convert` hi word, for the YUV texture path) | +| R-10 | The colour combiner (T-33-002) models the common inputs (combined, texel0/1, primitive, shade, environment, one, zero, and the C-slot alpha taps); the **exotic inputs** — noise, LOD fraction / prim-LOD-fraction, the chroma-key centre/scale, and the convert (`K4`/`K5`) constants — are not modelled and read as **zero** | These inputs need the LOD pipeline (mip level fraction), the key/convert registers (`Set Key`/`Set Convert`), and a noise source, none of which exist yet; they appear in a small minority of combine modes. Reading them as zero is a bounded, documented gap, not a fabricated value | absolute — a coverage boundary, not a fitted constant | **Partially resolved (2026-07-25) — see the RESOLUTION below.** The `(A − B) * C + D` arithmetic (the `special_expand` asymmetric 9-bit fold, the `+0x80`-before-`>>8` rounding, D added unscaled) and the clamp are validated bit-for-bit against hand-computed values; the 16-field decode, the input mux, and the 2-cycle chaining are unit-tested. The remaining exotic inputs land with the LOD/key/noise state and are validated against the ParaLLEl-RDP conformance vectors (T-33-005). **RESOLUTION (2026-07-25) — the register-sourced exotic inputs are wired.** `PRIM_LOD_FRAC` (RGB mul-select 14, alpha mul-select 6, extracted from `Set Prim Color` word-0 low byte; `min_level`, bits 12:8, stays deferred — it is not stored, and lands with its LOD consumer rather than as unread state) and the `Set Convert` (`0x2C`) constants `K4` (RGB sub-B select 7) and `K5` (RGB mul-select 15, both raw 9-bit from `lo[17:9]`/`lo[8:0]`) now route through the combiner instead of reading zero. Validated byte-for-byte against Angrylion by three new non-vacuous conformance vectors — `tex_tri_primlodfrac_16` (`One * prim_lod_frac`, golden `0x8421` gray vs black if unwired), `tex_tri_convert_k45_16` (`(One − K4) * K5` with bit-8-clear K4/K5, golden `0x94a5` vs black if unwired), and `tex_tri_convert_kneg_16` (a **negative** `K4 = 0x1C0` = −64, golden `0x5295` gray — correct only if the raw 448 is `special_9bit`-expanded to −64; a raw-positive read clamps black) — plus two mutation-checked unit tests (`combine_cycle_routes_prim_lod_frac`, `combine_cycle_routes_convert_k4_k5`). The sign path is faithful to Angrylion, which likewise stores K4/K5 **raw 0..511** (`rdp_set_convert`) and sign-extends in the equation: sub-B via `special_9bit_exttable` (`combiner.c:481`, reproduced bit-for-bit by RustyN64's `special_expand`) and the mul via `SIGNF(c, 9)` (RustyN64's `sext9`). **n64-systemtest impact: none** — the suite has no RDP-combiner coverage, so its failing-assertion count is unchanged (90); R-10 is validated only by the Angrylion conformance vectors. **RESOLUTION (2026-07-25) — the chroma-key combiner inputs are wired.** `Set Key GB` (`0x2A`) and `Set Key R` (`0x2B`) now decode the per-channel key **centre** and **scale** (bit-layout ported from Angrylion `rdp_set_key_gb`/`rdp_set_key_r`: GB `lo` = `centre_g[31:24] scale_g[23:16] centre_b[15:8] scale_b[7:0]`, R `lo` = `width_r[31:16] centre_r[15:8] scale_r[7:0]`), and they route through the combiner as **KeyCentre** (RGB sub-B select 6) and **KeyScale** (RGB mul-select 6) instead of reading zero — matching Angrylion `combiner.c` cases 6. The key **width** is not stored: it drives only the deferred chroma-key alpha compare, not the combiner mux, so it lands with that consumer (the `min_level` precedent). Validated by two mutation-checked unit tests — `set_key_decodes_centre_and_scale_per_channel` (distinct per-channel values pin the decode field positions) and `combine_cycle_routes_chroma_key` (`(One − centre) * scale >> 8` with per-channel centre `[32,64,96]`/scale `[64,128,192]` → `[56,96,120]`; unwiring either input changes the result) — **and byte-for-byte against Angrylion** by the end-to-end conformance vector `tex_tri_chromakey_16` (centre `[0x20,0x40,0x60]`, scale `[0x40,0x80,0xC0]` → RGBA5551 `0x3b1f`, black if unwired), which exercises the decode + mux through the real RDP pipeline. **n64-systemtest impact: none** — no RDP-combiner coverage; count unchanged (90). **Still open** (genuinely need machinery that does not exist yet, read as zero): **noise** (RGB sub-A select 7 — needs a per-pixel noise source), the **derivative-computed `lod_frac`** (RGB mul-select 13 / alpha mul-select 0 — needs the LOD/mip pipeline, pairs with R-13's mip tile selection), and the **YUV convert `K0`–`K3`** coefficients (the `Set Convert` hi word, for the YUV texture path). **RESOLUTION (2026-07-26) — the chroma-key alpha compare (`key_en`) is wired.** `Set Other Modes` bit 40 (`hi >> 8 & 1`) decodes to `OtherModes.key_en`, and `Set Key GB`/`R` now also store the per-channel `key_width` (GB `hi[23:12]/[11:0]`, R `lo[31:16]`). When `key_en`, `Rdp::combine` takes the Angrylion `combiner_1cycle` key path (gated so the common path stays byte-identical — all prior 31 `.rvec` vectors unchanged): the RGB output is the sub-A **chromabypass** colour (clamped), and the pixel alpha is `chroma_key_min` over the **pre-`>>8` 17-bit** combined colour (`combine_channel_17bit` = `((A−B)*C + (D<<8) + 0x80) & 0x1ffff`, matching `color_combiner_equation`) and the key widths — per channel `SIGN(col,17)` folded (`-k`, or `-k+0x10` when the low nibble is 8), `+ (width<<4)`, `min`-of-3, clamp `[0,0xff]`. Validated byte-for-byte against Angrylion **end-to-end** by `tex_tri_chromakey_alpha_16`, which makes the key alpha **observable** via alpha-compare (bit 0) at a `Set Blend Color` threshold of `0x80`: the Shade triangle is written only where `chroma_key_min >= 0x80` (the combine yields exactly `0x80`, so it is drawn — a `chroma_key_min` off by −1 shifts it below the threshold and the triangle vanishes, mutation-verified; clearing `key_en` outputs the combined colour instead of Shade). The `chroma_key_min` fold is **additionally** unit-tested directly with hand-computed values including the bit-16-set (negative) branch (`chroma_key_min_folds_and_takes_the_minimum`). **n64-systemtest impact: none** (no RDP-combiner coverage; count 90). **Still open under R-10:** noise (un-oracled — Angrylion fakes it), the derivative `lod_frac`, and the YUV `K0`–`K3` convert | | R-13 | Triangle **texturing** (T-33-004 PR-B 2b) samples the tile per pixel via `decode_texture` + `interpolate_st` + `fetch_texel`, with **both** the non-perspective path and the **perspective divide** (the 64-entry `perspective.h` reciprocal LUT + normalisation shift + out-of-bounds saturation + `w <= 0` carry, gated on `persp_tex_en`) now implemented; the tile shift/clamp/mask, the 3-point bilinear, the mask-wrap seam, and the 2-cycle `texel1` (tile+1) are all resolved too (see the dated RESOLUTIONs below), leaving only **`mid_texel`** and the **LOD/mip tile selection** (`lod_frac`, which pairs with R-10) | The non-perspective path and the sampler wiring are the tractable first step (the flat-coordinate case is scale-independent, so a textured triangle can be validated end-to-end without the divide); the perspective LUT is a precision-critical port best hand-verified in isolation, and the triangle coordinate wrap/clamp is a combinatorial surface for the conformance fuzz | absolute — real texture-path modelling gaps, oracle-confirmed | **RESOLVED (2026-07-23) — see the RESOLUTION at the end of this cell. Retained below is the (thrice-corrected) investigation trail. Open — a REAL divergence is pinned (after two mis-diagnoses, now settled by direct instrumentation).** The `tex_tri_16` conformance vector (committed **`#[ignore]`d**) is the first to drive `interpolate_st` against Angrylion, and its golden differs from RustyN64. Two earlier revisions of this entry were **wrong** and are retracted: (a) a `v >> 16`-vs-s10.5 coordinate claim, and (b) a "malformed vector / unconfigured tile / `SSS = 0`" claim — the latter came from reading the wrong vector's debug output (a global counter had captured an earlier *shade* triangle, not this textured one). Correctly instrumented, the vector is **well-formed**: at its own sample time tile 0 is configured (`size = 2`, `format = 0`), the S coordinate advances (`SSS = 0,1,2,3,4,5` across the drawn columns), and Angrylion **fetches texel 0 = `(255,0,0,255)` = red correctly**. The mismatch is therefore a **real** RDP behaviour RustyN64 does not yet model, from two effects seen in the reference: (1) the **1-cycle TEXEL0 pipeline** — Angrylion's `combiner_1cycle` swaps `texel0_color = texel1_color` (combiner.c) before the combine, the documented hardware quirk that a `TEXEL0` reference in 1-cycle mode is *pipelined*, so a texel0-passthrough does **not** emit the just-fetched texel; and (2) the **s10.5 texel-coordinate scale** — `SSS` spans only `0..5` in `s.5` units (< one texel), so Angrylion point-samples texel 0 across the whole triangle, whereas RustyN64's `interpolate_st` (`v >> 16`) advances one texel per pixel. RustyN64 models neither. The v2 `.rvec` **preload** plumbing is independently verified (an all-white texture renders white; Angrylion's 16-bit `tmem_formatting` loads `tmemidx0 = 0xF801`). **DEFINITIVE CORRECTION (2026-07-23, source-verified — the two "effects" above are BOTH retracted, the third mis-diagnosis of this entry).** (1) The **1-cycle TEXEL0 pipeline is NOT a net offset.** `combiner_1cycle` (`combiner.c:173`) does **not** swap texels; the swap `texel0_color = texel1_color` lives in `combiner_2cycle_cycle1` (`combiner.c:348`, 2-cycle only) and, as a *lookahead*, in `render_spans_1cycle_complete` (`rasterizer.c:336`) — but there it is net-zero: `texel1` is computed at pixel *j*'s next coordinate, then reused as pixel *j+1*'s `texel0`, so `texel0` at every pixel equals that pixel's OWN sampled texel. There is no offset to model. (2) The claim that **Angrylion "fetches red texel 0 correctly"** is **wrong**: the committed `tex_tri_16` golden is uniform `0x0001` (RGB 0 + coverage) across the whole triangle — Angrylion samples a **black** texel at *every* covered pixel, not the ramp. So the real divergence is that Angrylion's texture sample yields **zero** where RustyN64 fetches the loaded texel (`0xF801`). **The coordinate scale is RULED OUT** by a decisive probe (2026-07-23): a *constant*-coordinate variant (`dsdx = dsde = 0`, so `S = T = 0` at every pixel — sampling texel 0 = red everywhere) renders the **same** all-black `0x0001` triangle in Angrylion. If the coordinate were the divergence, a fixed `S = 0` would show red; it does not. So the fault is in the **texture load / tile / sampler configuration for the triangle 1-cycle path**, not the coordinate — Angrylion's triangle sampler returns black even for texel 0. (The copy-mode path samples the loaded texels fine, but it is a *different* sampler — a direct TMEM blit, not `texture_pipeline_cycle` — so it does not prove the triangle sampler config.) Next: isolate load-vs-sampler by driving a **COPY-mode** rectangle through the *identical* 8×1 texture + `Set Tile` + `Load Tile` (if that shows the ramp, the load is fine and the triangle tile/sampler config — `Set Tile` line/mask/format, `Set Tile Size`, and the `Set Other Modes` sample/texture bits — is the fault); then build a **correct** textured-triangle reference whose Angrylion output is verified to show the ramp, and only then compare RustyN64. Do NOT implement against the current `tex_tri_16`, whose oracle output is a degenerate all-black frame. **RECONCILIATION (later same-day, source-verified — partially walks back the "coordinate ruled out" above):** the `0x0001` black is *not* caused by the coordinate, that stands — but there ARE **two separate** issues, not one. (A) A **real RustyN64 coordinate gap IS confirmed**: `texture_pipeline_cycle` (`tex.c:182`) takes `sfrac = sss1 & 0x1f` and hands `sss1` *with its 5 fractional bits* to `fetch_texel_quadro` (`tex.c:246`), which shifts `>> 5` for the integer texel — so the RDP texel coordinate is **s.5** and RustyN64's `interpolate_st` (`v >> 16` used directly as the texel index, no `>> 5`) advances 32× too fast. The original "s10.5" note was right about this; only the *tex_tri black* is unrelated to it. (B) The **black itself** is that Angrylion's texel-0 fetch returns zero even at `SSS = 0` (texel 0) with the combiner (`add_rgb1 = 1 = texel0`, `combiner.c:540`), tile (0, loaded), and render path (`render_spans_1cycle_notexel1`) all verified to match — so the fault is inside `fetch_texel`/`tmem.c` addressing (the load-vs-sample TMEM layout for the 8×1 tile), still open. Fix order: root-cause (B) against `fetch_texel_quadro`/`tcshift`/`tcmask` or a hand-built known-good textured vector, then implement (A)'s `>> 5` and validate. **RESOLUTION (2026-07-23):** both landed. (B) The all-black frame was a **vector bug, not a RustyN64 gap** — `Set Other Modes` had `bi_lerp0 = 0` (bit 11), which selects the RDP's **YUV colour-convert** texture path (`tex.c` `texture_pipeline_cycle`, the `!bilerp` branch: `TEX->r = t3.b + ((k*_tf·t3.g + 0x80) >> 8)`); with the convert coefficients unset that computes an RGBA texel's output from its chroma (`t3.b = 0` for red) → black. Setting `bi_lerp0 = 1` selects the normal RGBA fetch. (A) RustyN64's `interpolate_st` returned `v >> 16` as the texel index directly; the RDP coordinate is **s.5**, so the index is `(v >> 16) >> 5` — added the `>> 5`. With both, the corrected `tex_tri_16` (advancing S, which the `>> 5` collapses to a solid-red triangle) and a new constant-coordinate `tex_tri_fixed_16` **both pass byte-for-byte vs Angrylion** and `tex_tri_16` is un-ignored. One self-asserted unit test (`shaded_and_textured_triangle_reads_texture_past_shade`) was corrected to the s.5 convention (its `S = 1` became `S = 32`). **TILE COORDINATE TRANSFORM RESOLVED (2026-07-25).** The triangle sampler now applies the tile **shift → tile-origin subtraction → clamp → mask/mirror** to the raw `s10.5` coordinate before `fetch_texel` (`sample_coord`, wired into `combined_color`), a bit-exact port of the ParaLLEl-RDP sampler order (`tcshift_cycle` → `TRELATIVE(SL<<3)` → `tcclamp_cycle_light` → `tcmask`): clamp is active when `clamp_s \|\| mask_s == 0`, the over-`SH` test is against the **raw absolute** `SH` (pre-subtraction) and substitutes the **relative** width `(SH>>2)−(SL>>2)`, and it sits *before* the mask (masking first corrupts the over-max/negative detection). Validated byte-for-byte against Angrylion by two new vectors — `tex_tri_clamp_16` (a 4-texel tile, `clamp_s`, `S` running past `SH` → `R,G,B,W,W,W`) and `tex_tri_wrap_16` (`mask_s = 2` → `R,G,B,W,R,G`) — plus a mutation-checked `sample_coord` unit test (shift/subtract/clamp/mask/mirror/negative-clamp). `interpolate_st` now returns the pre-`>>5` `s10.5` coordinate so the shift and tile-size clamp operate on the true value; the self-asserted `shaded_and_textured_triangle_reads_texture_past_shade` was given a valid `SH`/`TH` (the clamp needs a tile size). **BILINEAR RESOLVED (2026-07-25).** The N64's characteristic **3-point (triangular)** filter is now modelled (`sample_type` bit 45 decoded; `bilinear_3point` + `sample_axis`, wired into `sample_texel`): the base coordinate runs shift → subtract → clamp (zeroing the sub-texel fraction on clamp, per `tcclamp_cycle`) → mask, then four texels `(s,t)/(s+1,t)/(s,t+1)/(s+1,t+1)` are blended by `upper = (sfrac+tfrac) & 0x20` — the lower-left triangle uses `t0,t1,t2`, the upper-right `t3,t2,t1` with inverted fractions, each channel a `+0x10 >> 5` round (a faithful port of ParaLLEl-RDP `texture_pipeline_cycle`). Validated byte-for-byte against Angrylion by `tex_tri_bilinear_16` (an 8×8 gradient sampled at 0.5 texel/pixel in **both** axes, so R interpolates `0,2,4,6,8,10` where point sampling would step `0,0,4,4,8,8`, and pixels hit **both** triangle branches), plus mutation-checked `bilinear_3point` (both triangles, hand-computed) and `sample_axis` (fraction capture + zero-on-clamp) unit tests. **MASK-WRAP SEAM RESOLVED (2026-07-25).** The bilinear neighbour is no longer a hardcoded `+1`: `mask_coupled` ports `tcmask_coupled`'s `sdiff`/`tdiff` — `+1` normally, `0` at a wrap seam (the "duplicate the last texel" quirk), `-base` at a mirror-off period end (neighbour wraps to 0), `-1` in a mirrored half; the neighbour is `base + diff`, *not* re-masked (`is_t` uses the T `-(base & 0xff)`). Validated byte-for-byte against Angrylion by `tex_tri_bilinear_wrap_16` (a 2-texel `mask_s = 1` tile whose `S = 1.5` seam column blends green+red — the wrapped texel 0 — not green+black; non-vacuous because the wrapped texel differs from the pre-fix unloaded read) plus a mutation-checked `mask_coupled` unit test. **2-CYCLE `texel1` RESOLVED (2026-07-25).** 2-cycle mode now samples a **second texel from `tile+1`** at the same coordinate (`combined_color`), and `combine` swaps `texel0`/`texel1` before cycle 1 (`combiner_2cycle_cycle1`), so cycle 1's `TEXEL0` reads `tile+1`. Validated byte-for-byte against Angrylion by `tex_tri_2cycle_16` (two 1-texel tiles red/green, both cycles output `TEXEL0` → the pixel is green because the swap brings `tile+1` into cycle 1; red without the swap) plus a mutation-checked `combine_two_cycle_swaps_texels` unit test. Still open: **`mid_texel`**, the **LOD/mip tile selection + `lod_frac`** (the derivative-based mip level, which pairs with R-10's `lod_frac`/`prim_lod_frac` combiner inputs), and threading the **primitive base tile** from the triangle command (bits 50:48) — the sampler uses tiles 0/1, a pre-existing gap shared with the 1-cycle path — targets of further vectors | | R-12 | The Z-buffer machinery — the depth **codec** and **`depth_test`** with the depth-source commands (**PR-A**), the **Z-buffer RDRAM read/write** + **hidden bits** (**PR-B part 1**), and the **per-pixel depth test + Z-write** in the triangle rasteriser (**PR-B part 2a**: z-suffix decode, `interpolate_z`, `depth_span`) — is in place; the **combiner→blender colour routing** (part 2b, the colour is still the FILL register) and the **coverage accumulator** at edges (part 2c) are not yet wired, and the `dz` derivation is a first-cut integer gradient | These land — and are tested — ahead of the pipeline integration, which is the larger, riskier surface (the flat-fill→per-pixel rewrite that also closes R-9). Splitting keeps each PR reviewable (the project's split-large-tickets rule). The hidden-bit RAM is modelled accurately (additive default-no-op `RdramBus` methods + a lazily-allocated Bus store) rather than approximated, so the exact `dz` precision the conformance gate needs is preserved | absolute — a coverage boundary, not a fitted constant | **Open.** The codec is validated by boundary values + a `z_compress ∘ z_decompress` round-trip; `depth_test` by observable occluding-vs-occluded pairs per Z mode; the storage by a Bus hidden-bit round-trip and a full-`dz` Z-buffer round-trip (nine `rdp` + one `core` unit tests). `depth_test`/`zbuffer_*` have **no runtime caller** yet, so the oracle stays **93**. The coverage and routing land in **PR-B part 2** and are validated against the ParaLLEl-RDP conformance vectors (T-33-005) | | R-11 | The blender (T-33-003) implements the divide-free `(P * a0 + M * (a1 + 1)) >> 5` with the `P/A/M/B` input muxes, both cycles, and `force_blend`; the **anti-aliased-edge divider LUT** (`uBlenderDividerLUT` — the coverage-weighted divide the RDP uses on partially-covered edge pixels), the **memory-alpha interpenetrating-Z blend-shift** path, **alpha-compare**, **dither**, the **`color_on_cvg`** early-return, and the **coverage write-back** (`cvg_dest`) are decoded but unused | These paths need the framebuffer read (`image_read_en` memory colour), the coverage accumulator, and the Z buffer — none of which reach the blender until the triangle pipeline routes combiner→blender per pixel (T-33-004). The no-divide form is the one every non-edge pixel uses, so it is the honest first target; emitting nothing for the deferred paths (rather than a fabricated divide) keeps the gap falsifiable | absolute — a coverage boundary, not a fitted constant | **Open.** The no-divide equation (the `>> 5` fold and the `+ 1` on the `M` term), the `Set Other Modes` (0x2F) field decode, the `P/A/M/B` muxes, and the 2-cycle forward chain are validated bit-for-bit against hand-computed values (four `rdp` unit tests). `blend` now **has a runtime caller** — `depth_span` (T-33-004 PR-B 2b-blend) reads the framebuffer pixel and routes the combiner colour through `blend` when the depth test enables blending, gated on `force_blend` (the reference's `!blend_en` fast-path keeps opaque pixels on the combiner colour). A translucent-triangle integration test proves a 50/50 blend of the combiner colour over a pre-filled background. **Dither is now present** (T-33-004 2c): the ordered RGB dither (magic/bayer matrix) is applied to the combined colour on both the no-Z and depth pixel paths, validated byte-for-byte against Angrylion by `dither_tri_32`. **Alpha-compare is now present on BOTH the no-Z and depth paths** (`Set Other Modes` bit 0): `alpha_compare_passes` gates the pixel write on `combiner_alpha >= Set-Blend-Color alpha`, evaluated before coverage overwrites the alpha byte — validated byte-for-byte against Angrylion by `alpha_compare_16` (no-Z) and `alpha_compare_z_16` (a z-suffixed triangle with `z_update` on / `z_compare` off) plus a boundary unit test. On the depth path the gate sits after the depth test and `continue`s before both the colour write and the z-write, which is observably equivalent to the RDP's pre-depth ordering because the compare is depth-independent (a pixel is written and its depth stored only when both depth and alpha pass). Still deferred here: the dithered-threshold variant (`dither_alpha_en`); the AA-edge divider LUT, the interpenetrating-Z blend-shift, `color_on_cvg`, and coverage write-back — these need the sub-pixel coverage accumulator (slice 2c). The oracle stays **93** (no systemtest drives the render path). The deferred paths are validated against the ParaLLEl-RDP conformance vectors (T-33-005) | diff --git a/docs/rdp.md b/docs/rdp.md index a05a722a..4da83d18 100644 --- a/docs/rdp.md +++ b/docs/rdp.md @@ -404,10 +404,12 @@ mul-select 15), each validated byte-for-byte against Angrylion (`tex_tri_primlod proving K4/K5 are stored raw `0..511` and sign-extended in the combiner, not at decode). The **chroma-key centre/scale** (`Set Key GB`/`Set Key R` → RGB sub-B / mul select 6) are likewise wired, validated by unit tests (decode + mux routing) and the `tex_tri_chromakey_16` conformance -vector byte-for-byte against Angrylion. The remaining exotic inputs — **noise**, the -derivative-computed **LOD fraction**, the **chroma-key alpha compare** (the `key_en` path that uses -the key *width*), and the **YUV convert `K0`–`K3`** — still read as zero until the LOD/key/noise -state lands. The arithmetic, the 16-field decode, the mux, and the 2-cycle +vector byte-for-byte against Angrylion. The **chroma-key alpha compare** (`key_en`, Set Other Modes bit 40) is also wired: the combiner +outputs the sub-A chromabypass colour and derives the pixel alpha from `chroma_key_min` over the +17-bit combined colour + the `Set Key` widths (gated on `key_en`, common path byte-identical; +validated by `tex_tri_chromakey_alpha_16`). The remaining exotic inputs — **noise** (un-oracled), +the derivative-computed **LOD fraction**, and the **YUV convert `K0`–`K3`** — still read as zero +until the LOD/noise/YUV state lands. The arithmetic, the 16-field decode, the mux, and the 2-cycle chaining are unit-tested against hand-computed values. `combine` now has its runtime caller — `combined_color` routes the interpolated shade and sampled texel through it per pixel (T-33-004 2b) — but no systemtest drives the render path, so the oracle stays **93**.