diff --git a/CHANGELOG.md b/CHANGELOG.md index 2701106d..2f51fb79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to RustyN64 are documented here. The format is based on Work toward `v0.8.0 "Breadth"` — the accuracy battery (Phase 7). +### Fixed — the TLUT lookup is gated on `tlut_en`, not the tile format (R-18) + +- **`Set Other Modes.tlut_en` (bit 47) was not decoded at all**, so the palette + lookup keyed off the tile's *format* field. That is wrong in both directions: a + CI tile with `tlut_en` clear was still palette-mapped, and a non-CI tile with it + set was not. +- The oracle settled the `tlut_en = 0` behaviour instead of it being guessed: + `ci4_tlut_disabled_16` is byte-identical to `tex_tri_ci4_tlut_16` apart from + that one bit, and the goldens are **the full palette versus all black**. +- `tlut_en` and `tlut_type` (bit 46) are now decoded and the lookup is gated. + IA16 palettes remain **deferred** — the flag is decoded so it is no longer + silently ignored, but the lookup still assumes RGBA16 and implementing IA16 + without a vector would be inventing behaviour. +- Battery is now **53 probes** (40 RDP + 13 VI); mutation-checked by removing the + gate. + ### Added — first `Load Block` oracle coverage; its count is inclusive (R-18) - **`Load Block` (0x33) had zero vector coverage** — every texture vector loaded diff --git a/crates/rustyn64-rdp/src/lib.rs b/crates/rustyn64-rdp/src/lib.rs index 98494706..3a6f2a0c 100644 --- a/crates/rustyn64-rdp/src/lib.rs +++ b/crates/rustyn64-rdp/src/lib.rs @@ -1117,6 +1117,31 @@ pub struct OtherModes { /// Detail-texture enable (bit 50): keeps the LOD fraction live when /// magnifying (R-13). pub detail_tex_en: bool, + /// **TLUT enable** (bit 47). N64brew *…/Commands* §0x2F: *"`tlut_en`: Enables + /// Texture Look-Up Table (TLUT) sampling. Texels are first fetched from low + /// TMEM that are then used to index a palette in high TMEM to find the final + /// color values."* + /// + /// This is the flag that decides whether a palette lookup happens — **not** + /// the tile's format field. Keying off the format alone is wrong in two + /// directions, and **only one of them is fixed**: + /// + /// - **Implemented:** a CI tile with `tlut_en` clear is no longer + /// palette-mapped. Pinned by `ci4_tlut_disabled_16`, whose golden is all + /// black where the `tlut_en`-set twin renders the full palette. + /// - **Deferred:** a **non-CI** tile with `tlut_en` set is still not + /// palette-mapped, though hardware would sample it through the TLUT. No + /// vector covers that case, and the RGBA/IA/I formats index the palette + /// differently enough that implementing it from the prose alone would be + /// inventing behaviour. It stays wrong-but-honest until a vector defines it, + /// the same posture as `tlut_type`'s IA16 palettes below. + pub tlut_en: bool, + /// TLUT texel format (bit 46): `false` = RGBA16, `true` = IA16 + /// (N64brew *…/Commands* §0x2F). Decoded so it is available and so the flag + /// is not silently ignored; **IA16 palettes are still deferred** — the lookup + /// assumes RGBA16, and implementing IA16 without a vector would be inventing + /// behaviour rather than emulating it. + pub tlut_type: bool, /// Mid-texel filter (bit 44, R-13): when set and the bilinear sample lands /// exactly on the texel centre (`sfrac == tfrac == 0x10`), the four neighbours /// are averaged instead of the 3-point triangle pick (Angrylion `tex.c`, the @@ -3159,6 +3184,8 @@ impl Rdp { 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 + tlut_en: (hi >> 15) & 1 != 0, // command bit 47 + tlut_type: (hi >> 14) & 1 != 0, // command bit 46 sample_type: (hi >> 13) & 1 != 0, // command bit 45 mid_texel: (hi >> 12) & 1 != 0, // command bit 44 detail_tex_en: (hi >> 18) & 1 != 0, // command bit 50 @@ -3684,6 +3711,19 @@ impl Rdp { let v = widen4(u32::from(self.nibble_at((off4 ^ swap) as usize, s))); [v, v, v, v] } + // The colour-index formats resolve through the palette **only when + // `Set Other Modes.tlut_en` is set** (bit 47) — the format field alone + // does not enable it (N64brew *…/Commands* §0x2F). With the flag clear + // the oracle renders a CI tile entirely black, which + // `ci4_tlut_disabled_16` pins: it is byte-identical to + // `tex_tri_ci4_tlut_16` apart from that one bit, and the two goldens are + // the full palette versus all black. + // + // Zero is what the oracle produces, not a mechanism claim: what the + // hardware *does* with un-TLUT'd index data is not documented in §0x2F, + // so this reproduces the observed result rather than inventing a + // reinterpretation of the index bits. + (2, _) if !self.other_modes.tlut_en => [0, 0, 0, 0], (2, 1) => { // CI8: 8-bit index into the TLUT. let ci = self.tmem_byte(((off8 & 0x7FF) ^ swap) as usize); @@ -4949,10 +4989,16 @@ mod tests { /// **`fetch_texel` resolves CI8 and CI4 through the TLUT.** A CI index selects /// a quadrupled RGBA16 entry in the high TMEM half; CI4 folds in the tile /// palette as the high nibble. + /// + /// `tlut_en` must be set: the lookup is gated on `Set Other Modes` bit 47, not + /// on the tile format (N64brew *…/Commands* §0x2F). This test previously left + /// it clear and passed only because the gate did not exist — it was asserting + /// the palette path while describing a machine that had not asked for it. #[test] fn fetch_texel_ci_through_the_tlut() { // CI8: index 5 -> TLUT entry at 0x800 + 5*8 = 0x828 = 0xF801 (red). let mut rdp = Rdp::new(); + rdp.other_modes.tlut_en = true; rdp.tmem_write(0, 5); // the index texel rdp.tmem_write(0x828, 0xF8); rdp.tmem_write(0x829, 0x01); @@ -4965,6 +5011,7 @@ mod tests { // CI4: nibble 5 (high, even s) + palette 3 -> index 0x35 -> entry at // 0x800 + 0x35*8 = 0x9A8 = 0x07C1 (green). let mut c = Rdp::new(); + c.other_modes.tlut_en = true; c.tmem_write(0, 0x50); // high nibble 5 c.tmem_write(0x9A8, 0x07); c.tmem_write(0x9A9, 0xC1); @@ -5925,6 +5972,22 @@ mod tests { assert!(om.aa_enable); assert!(om.alpha_compare_en); assert_eq!(om.rgb_dither_mode, 1); + + // **`tlut_en` (bit 47) and `tlut_type` (bit 46) decode independently.** + // They are ADJACENT bits, so a swapped extraction is the likely error and + // would pass any test that sets both or neither. Assert each with the other + // clear, in both polarities, so a swap fails and so does dropping either. + let om = |hi: u32| { + let mut r = Rdp::new(); + r.set_other_modes(hi, 0); + r.other_modes + }; + let a = om(1 << 15); // tlut_en only + assert!(a.tlut_en, "bit 47 must set tlut_en"); + assert!(!a.tlut_type, "bit 47 must NOT set tlut_type"); + let b = om(1 << 14); // tlut_type only + assert!(!b.tlut_en, "bit 46 must NOT set tlut_en"); + assert!(b.tlut_type, "bit 46 must set tlut_type"); } /// **The magic-matrix RGB dither matches Angrylion's `rgb_dither` cell-for-cell.** diff --git a/crates/rustyn64-test-harness/src/conformance.rs b/crates/rustyn64-test-harness/src/conformance.rs index e84d2ff9..4c9a7f56 100644 --- a/crates/rustyn64-test-harness/src/conformance.rs +++ b/crates/rustyn64-test-harness/src/conformance.rs @@ -402,6 +402,10 @@ pub const RDP_VECTORS: &[(&str, &[u8])] = &[ "load_block_count_16", include_bytes!("../tests/vectors/load_block_count_16.rvec"), ), + ( + "ci4_tlut_disabled_16", + include_bytes!("../tests/vectors/ci4_tlut_disabled_16.rvec"), + ), ]; /// Look up a committed vector's bytes by name. diff --git a/crates/rustyn64-test-harness/tests/rdp_conformance.rs b/crates/rustyn64-test-harness/tests/rdp_conformance.rs index 695e0cba..37a63506 100644 --- a/crates/rustyn64-test-harness/tests/rdp_conformance.rs +++ b/crates/rustyn64-test-harness/tests/rdp_conformance.rs @@ -125,6 +125,22 @@ fn load_block_count_16_matches_angrylion() { assert_matches("load_block_count_16"); } +/// **A CI tile with `tlut_en` CLEAR gets no palette lookup (R-18).** +/// +/// Byte-identical to `tex_tri_ci4_tlut_16` except for `Set Other Modes` bit 47, +/// so any difference is attributable to that one bit. N64brew *…/Commands* §0x2F +/// makes `tlut_en` — not the tile's format field — the flag that enables the +/// lookup, and the oracle agrees emphatically: with the flag set the eight +/// columns render the full palette; with it clear they render **all black**. +/// +/// This matters because keying the lookup off the format alone is wrong in *both* +/// directions — it palette-maps a CI tile that asked not to be, and fails to +/// palette-map a non-CI tile that asked to be. +#[test] +fn ci4_tlut_disabled_16_matches_angrylion() { + assert_matches("ci4_tlut_disabled_16"); +} + /// **A flat Fill Triangle matches Angrylion (regression guard for R-14).** /// /// This left-major triangle (a vertical left edge at x=2, the hypotenuse widening diff --git a/crates/rustyn64-test-harness/tests/vectors/ci4_tlut_disabled_16.rvec b/crates/rustyn64-test-harness/tests/vectors/ci4_tlut_disabled_16.rvec new file mode 100644 index 00000000..91ec1635 Binary files /dev/null and b/crates/rustyn64-test-harness/tests/vectors/ci4_tlut_disabled_16.rvec differ diff --git a/crates/rustyn64-test-harness/vectors-gen/driver.c b/crates/rustyn64-test-harness/vectors-gen/driver.c index 8ad889e0..d59fd709 100644 --- a/crates/rustyn64-test-harness/vectors-gen/driver.c +++ b/crates/rustyn64-test-harness/vectors-gen/driver.c @@ -1178,6 +1178,45 @@ static const uint16_t TEX_CI4_TLUT[12] = { 0xF801u, 0x07C1u, 0x003Fu, 0xFFFFu, // 0x3008: TLUT 0-3 red, green, blue, white 0xFFC1u, 0x07FFu, 0xF83Fu, 0x8421u, // TLUT 4-7 yellow, cyan, magenta, grey }; +// V39 (probe): the SAME CI4 tile as V37 but with **`tlut_en` CLEAR**. +// +// N64brew §0x2F makes `tlut_en` (bit 47) the flag that enables the palette +// lookup — not the tile's format field. RustyN64 keyed the lookup off the format +// alone, which is wrong in both directions. This vector asks the oracle what a CI +// tile does when the flag is clear, rather than guessing at it: everything else is +// byte-identical to V37, so any difference in the golden is attributable to the +// one bit. +static const uint32_t V39_CI4_TLUT_DISABLED_16[] = { + // Set Other Modes: 1-cycle, bi_lerp0, persp off, **tlut_en** (bit 47 = word-0 + // bit 15; N64brew §0x2F "tlut_en: Enables Texture Look-Up Table (TLUT) sampling"). + 0x2F0008F0u, 0x00000000u, // tlut_en CLEAR (bit 47 = word-0 bit 15) + 0x3C000000u, 0x00000041u, // Set Combine: rgb_d=1 / a_d=1 — pure TEXEL0 passthrough + // --- palette -> TMEM high --- + 0x3D100003u, 0x00003008u, // Set Texture Image: **16-bit**, width 4, addr 0x3008 + 0x35400100u, 0x07000000u, // Set Tile 7 (TLUT): fmt CI(2), size 0 (4-bit), tmem word 0x100 + 0x30000000u, 0x0701C000u, // Load Tlut 7: uls=0 ult=0 lrs=0x1C (8 entries) lrt=0 + // --- CI4 texel bytes -> TMEM low --- + 0x3D080003u, 0x00003000u, // Set Texture Image: 8-bit, width 4, addr 0x3000 + 0x35080200u, 0x06000000u, // Set Tile 6 (LOAD): 8-bit, line 1, tmem 0 + 0x32000000u, 0x0600C000u, // Set Tile Size 6: uls0 ult0 lrs3 lrt0 (4 bytes) + 0x34000000u, 0x0600C000u, // Load Tile 6 + // --- render tile: format CI(2), size 0 (4-bit), palette 0 --- + 0x35400200u, 0x00000030u, // Set Tile 0 (RENDER): fmt CI, 4-bit, line 1, mask_s=3 + 0x32000000u, 0x0001C000u, // Set Tile Size 0: uls0 ult0 lrs7 lrt0 (8 CI4 texels) + 0x3F100007u, 0x00001000u, // Set Color Image: 16-bit, width 8, addr 0x1000 + 0x2D000000u, 0x00020020u, // Set Scissor: (0,0)-(8,8) + 0x0A800020u, 0x00200000u, // op=0x0A (tex), lft=1, yl=32 ym=32 yh=0, tile 0 + 0x00000000u, 0x00000000u, // XL, DxLDy + 0x00000000u, 0x00000000u, // XH = 0.0 — geometry identical to V37 + 0x00000000u, 0x00020000u, // XM = 0.0, DxMDy = 2.0 + // The geometry spans all eight columns, as V37's does — but do NOT read that as + // eight-entry coverage here. With `tlut_en` clear every column renders black + // whatever its index, so this vector proves the **gate**, not per-index palette + // resolution. V37 is what proves the resolution; this one proves the flag + // suppresses it. Identical geometry is the point: it makes the two goldens + // differ by exactly one bit of input. + TEX_BLOCK(0, 0, 1, 0x20, 0, 0, 0, 0, 0), +}; static const uint32_t V37_TEX_TRI_CI4_TLUT_16[] = { // Set Other Modes: 1-cycle, bi_lerp0, persp off, **tlut_en** (bit 47 = word-0 // bit 15; N64brew §0x2F "tlut_en: Enables Texture Look-Up Table (TLUT) sampling"). @@ -1988,6 +2027,11 @@ int main(int argc, char **argv) { 0x3000, sizeof(TEX_BLOCK_COUNT_TEXELS) / sizeof(uint16_t), TEX_BLOCK_COUNT_TEXELS}; if (emit_vector(&v38, out_dir)) return 1; + Vector v39 = {"ci4_tlut_disabled_16", 0x2000, 0x1000, 8, 8, 2, + sizeof(V39_CI4_TLUT_DISABLED_16) / 4, V39_CI4_TLUT_DISABLED_16, + 0x3000, sizeof(TEX_CI4_TLUT) / sizeof(uint16_t), TEX_CI4_TLUT}; + if (emit_vector(&v39, out_dir)) return 1; + if (emit_vi_vectors(out_dir)) return 1; return 0; diff --git a/docs/STATUS.md b/docs/STATUS.md index 9ffe1192..dac0547d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -161,7 +161,7 @@ category `Failed: 0`; 90 suite-wide). The **golden-log** gate (`--test golden_log`) replays 50,027 retired records at 0 diff against ares. A fourth, the **synthetic visual golden** (`--test golden_frame`, T-31-005), executes the FILL → VI scan-out path against a committed frame hash. The **accuracy battery now executes** -(`AccuracyScorer::default_battery`, 52 probes, 100% — see the table above); the +(`AccuracyScorer::default_battery`, 53 probes, 100% — see the table above); the rest of the corpus (the real-ROM krom/240p visual goldens and the commercial ROMs) is staged only — an oracle on disk that no gate executes yet. @@ -213,7 +213,7 @@ prompted the change. | AI audio DMA double-buffer | **done** — registers, FIFO, derived DAC rate, IRQ-on-start, delayed-carry bug (Sprint 1); the real mixer microcode produces PCM on the RSP (Sprint 2); the frontend drain + resampler landed in Phase 6 | Phase 4 | | PI/SI DMA, PIF/CIC boot, FlashRAM machine, saves | **done** (v0.6.0) — PI/SI DMA, the CIC handshake, all four save backends incl. the FlashRAM command machine, and both HLE and real-PIF boot | Phase 5 | | Frontend egui shell | **done** (v0.7.0) — presents the real machine (VI scan-out, AI drain, SI input) with save-states / rewind / run-ahead; a wasm browser demo | Phase 6 | -| Accuracy battery / breadth / reach | **battery wired** — it scores the 52 committed Angrylion vectors, RDP + VI (100%); the commercial-corpus breadth and the reach features are Phases 7–8 | Phases 7–8 | +| Accuracy battery / breadth / reach | **battery wired** — it scores the 53 committed Angrylion vectors, RDP + VI (100%); the commercial-corpus breadth and the reach features are Phases 7–8 | Phases 7–8 | ## Chip → crate map @@ -255,7 +255,7 @@ entropy, threads and unordered collections anywhere in the core. | n64-systemtest, **CPU/COP0/TLB/COP1** categories (Phase 1's criterion) | **yes** — ROM committed, and the runner with it | **MET: `Failed: 0`**, across 917 tests started. Reproduce with `cargo test -p rustyn64-test-harness --release --test systemtest -- --ignored`. **90** assertions still fail suite-wide, down from 413 (and from 93 before the Phase 5 cart/PIF/SI work); **none are RSP-prefixed** (the RSP category is Phase 2's criterion and is now 0), leaving the RDP rasterizer (Phase 3), the MI's RDRAM repeat mode, and the remaining cart/PIF corners | | n64-systemtest, **RSP** category (Phase 2's criterion) | **yes** — same runner | **MET: `Failed: 0`** across 917 tests started — every RSP-prefixed test passes (verified by dumping per-test failures; 0 begin with `RSP`). The full VU ISA, vector load/store, reserved opcodes, `BREAK`-in-delay-slot, and the DPC registers landed in #41–#44 | | ParaLLEl-RDP fuzz suite (RDP bit-exactness) | source cloned, suite not set up | not started | -| Accuracy battery (`AccuracyScorer::default_battery`) | **yes** — 52 probes across two oracle suites: 39 Angrylion RDP rasteriser vectors + 13 Angrylion VI scan-out vectors (expected values are the oracle's, never our own output; RDP probes are byte-for-byte, VI probes are RGB-only since the 4th byte is coverage) | **100% (52/52)** — asserted by `default_battery_matches_the_oracle`; both suites are asserted to contribute, and an empty battery now scores 0%, not a vacuous 100% | +| Accuracy battery (`AccuracyScorer::default_battery`) | **yes** — 53 probes across two oracle suites: 40 Angrylion RDP rasteriser vectors + 13 Angrylion VI scan-out vectors (expected values are the oracle's, never our own output; RDP probes are byte-for-byte, VI probes are RGB-only since the 4th byte is coverage) | **100% (53/53)** — asserted by `default_battery_matches_the_oracle`; both suites are asserted to contribute, and an empty battery now scores 0%, not a vacuous 100% | | Visual golden / screenshots | **yes** — krom + 240p + commercial staged | **first frame MET** (T-31-005) — a synthetic RDP FILL list rendered through the full command-decode → FILL → VI scan-out path is pinned byte-exact against a committed golden hash (`--test golden_frame`). Real-ROM krom/240p goldens await cartridge boot (Phase 5) | The distinction matters: "oracle available" means the ROM is on disk; it says diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 8376c4da..201524df 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -410,7 +410,7 @@ labelled as one until something reads the register on hardware. | R-21 | `Fill Rectangle` (0x36) always writes the **`SET_FILL_COLOR` register**, whatever the cycle type. On hardware only FILL mode does that — in 1-/2-cycle mode the rectangle is rasterised through the **combiner/blender** like any other primitive | `fill_rectangle` calls `fill_pixel` unconditionally; the cycle type is never consulted. Every committed conformance vector that exercises `Fill Rectangle` sets FILL mode, so the non-FILL path has no coverage in either direction and the gap was invisible | absolute — a coverage boundary, not a fitted constant | **Open (found 2026-07-28, not yet oracled).** Surfaced *accidentally* while building the microcode end-to-end test: that test's queue initially mis-packed `SET_OTHER_MODES` (the cycle type went into word1 instead of word0 bits 21:20, so the RDP never entered FILL mode) and **the picture was still correct**, which is only possible because `Fill Rectangle` ignores the cycle type. A reviewer flagged the mis-packing; chasing it found this. The queue is fixed and the test now asserts the *emitted* `SET_OTHER_MODES` carries FILL, so the two are no longer confounded. **RESOLVED 2026-07-28 (oracle-confirmed).** Vector `fill_rect_1cycle_16` settles it: a 1-cycle rectangle with prim `(0x22,0x44,0x66)`, a combine selecting prim for RGB and alpha, and a *deliberately different* green fill register `0x07C1` renders **`0x2219` in all 64 pixels** — the prim colour in RGBA5551, not the fill register. So hardware routes a non-FILL rectangle through the combiner and never reads the fill register. `fill_rectangle` now takes the combiner path (with alpha-compare and dither, as the triangle path does) unless the cycle type is `CYCLE_TYPE_COPY` or `CYCLE_TYPE_FILL`. Mutation-checked: reverting the guard reproduces `07 C1` against golden `22 19` at pixel (0,0), and *only* that vector regresses. A second vector `fill_rect_2cycle_16` pins the **2-cycle** branch, which the 1-cycle vector leaves untested because `combine()` takes a distinct path there (cycle 0 runs first and feeds cycle 1 as the `COMBINED` input): its combine makes cycle 0 emit the env colour and cycle 1 select `COMBINED`, so env `0x8D73` means the chain ran, black means cycle 0 was skipped, and green `0x07C1` means the cycle type was ignored. Angrylion renders **`0x8D73`** and we already match. **Fallout worth recording:** six existing tests — the five `fill_rectangle_*` unit tests and the `golden_frame` end-to-end test — were named for FILL-mode behaviour but **never selected FILL mode**, so they were passing on this bug. They now emit a `Set Other Modes` with `cycle_type = FILL` and test what their names claim. **Still open:** the same question for a *flat* `Fill Triangle` (0x08) with no shade/texture block, which likewise takes the fill register unconditionally (the presence of a shade/texture block selects the combiner there, not the cycle type); no vector exercises it yet. **n64-systemtest impact: not measured** — the suite has no RDP render-path coverage | | R-17 | The AI DMA models the sample **rate** exactly (`MASTER_HZ / (video_clock / (DACRATE + 1))` per sample-pair) but charges **no DMA setup/arbitration latency and no RDRAM bank-state cost**: the transfer begins, and the start-interrupt fires, at the derived sample boundary rather than after the real DMA-engine delay. The underrun behaviour is a defined **hold-and-decay** (integer `× 63/64` per sample) rather than the analog decay curve | The AI DMA is "directly connected to the DAC" and "progresses as samples are physically put through the DAC" (wiki §DMA), so the per-sample rate is the dominant timing term and is exact; the fixed setup latency `M` and the RDRAM bank costs are the same unmeasured constants flagged for the CPU/PI (they belong in this ledger with provenance when measured, never tuned). The decay shape is deterministic and no-`std`-friendly; ares uses `exp(-1/(freq·0.003))` | absolute — an unmeasured latency, not a differential re-phasing | **Open.** The rate and the FIFO/interrupt sequencing are unit- and integration-tested; the setup latency stays unmeasured (measure, don't guess) and the decay is defined-but-unpinned. No AI-timing oracle exists in the committed suites, so nothing gates it yet — to be validated against the project64 `DoubleShot` PCM ROM (Sprint 2) and any AI-timing capture that surfaces. **n64-systemtest impact: not measured** — no AI test drives the DMA-timing path, so the oracle count is **unchanged at 93** *(as-at — see the note above this table)* | | R-15 | The **scissor** lower-right bound in FILL mode is **asymmetric**: the **X** bound is **inclusive** of its boundary pixel while the **Y** bound is **exclusive**, and a rectangle lying entirely at or past the scissor's right edge draws nothing (`allover`). `fill_rectangle` previously clipped both bounds exclusively (`(coord + 3) >> 2`) | Isolated cleanly against the Angrylion oracle by a scissor-clip fuzz batch (rectangles extending past the scissor on each edge). The X clip keeps the pixel containing `scissor.xl` (a rect spanning past `xl = 8.0` fills column 8), but the Y clip drops row `scissor.yl >> 2` (a scissor `yl = 5.0` fills up to row 4). The asymmetry is `edgewalker_for_prims`: the rectangle's `yl` is `\| 3`'d (FILL/COPY) so its own last scanline fills, but the scissor's raw `clip.yl` makes `invaly = k >= yllimit` drop that boundary row; the horizontal clip (`curover = xlsc >= clip.xl << 1`, `allover` ⇒ `!validline`) keeps the boundary column unless the whole span is over it. Read from the oracle's output, not computed | absolute — a rasterisation geometry rule, oracle-confirmed | **Closed for the integer-coordinate FILL scissor (oracle-validated).** `fill_rectangle` now clips X inclusive with the `allover` guard (`rect_xh >= scissor.xl` ⇒ nothing) and Y exclusive, plus a hard width clamp. Pinned by a 48-vector scissor-clip fuzz family (`tests/vectors/fuzz/fz_scis_*`, all byte-exact) and the reconciled `fill_rectangle_is_clipped_to_the_scissor` unit test (which previously asserted an unverified exclusive X edge). Sub-pixel (fractional-coordinate) scissor edges remain unexercised. No n64-systemtest driver (count 93 *(as-at — see the note above this table)*) | -| R-18 | A **commercial ROM boots and executes real code but does not reach video** (Phase 5 capstone). Through the retail HLE boot (`rom::hle_boot`) the game's own IPL3 runs, the CPU fetches the cartridge's instruction stream, and the PC advances through hundreds of millions of retired instructions across varied routines — but no frame is scanned out: over ~10 s of emulated time `VI_CTRL` stays 0, `VI_ORIGIN` is never set, and **no interrupt of any kind fires** (SM64 witnessed at `retired ≈ 9.4×10⁸`, all MI interrupt lines clear) | The retail OS-boot runtime the game waits on is not yet modelled. A commercial title's boot is interrupt-driven: after its OS initialises, its main loop blocks on the **VI vblank interrupt**, which the emulator only raises once the game programs `VI_CTRL`/`VI_V_INTR` — and the game does not reach that programming, indicating an earlier dependency (the **RI/RDRAM interface** registers used for RDRAM sizing, and/or the OS thread/interrupt setup). This is a cross-subsystem gap spanning the VI vblank loop, the RI registers, and the F3DEX graphics microcode — all **outside the Phase 5 cart/boot/saves boundary** (ADR 0003; the cart phase delivers PI/SI/PIF/CIC + saves, not the OS runtime) | absolute — a coverage boundary across subsystems, not a fitted constant or a timing interval | **Open — characterised, not a regression.** The committable Phase 5 gate (n64-systemtest cart/PIF/SI, save round-trips, homebrew boot) is met; the commercial capstone is asserted at its honest achievable level — `a_commercial_rom_boots_and_executes` (local, `#[ignore]`d) proves the ROM boots and retires ≥ 10⁶ real instructions without panicking, and *reports* the lit-pixel count (0) rather than asserting it. Reaching a title frame is deferred to the VI/RI/F3DEX work of a later phase and validated then. This gap was surfaced by the capstone exactly as the plan's escalation gate intended: **ship v0.6.0 on the committable gates + an honest "boots and executes" capstone, not a faked pass or an unbounded chase.** n64-systemtest impact: none — the boot/video path has no systemtest driver; the suite-wide count is **90** (see C-32) **SUBSTANTIALLY RESOLVED 2026-07-29 — the root cause was NOT the theory above.** It was `hle_boot` never seeding **`sp`**. IPL1 sets it before handing off (N64brew *IPL2* §IPL1 listing, `0xBFC000D0`: `ORI sp, sp, 0x1FF0 # sp = 0xA4001FF0`), and `hle_boot` skips IPL1/IPL2 without standing in for it. With `sp = 0`, IPL3's opening `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue stored to `0xFFFF_FFE8` — KSEG3, TLB-mapped, no entries — taking a TLB-refill exception to `0x8000_0000` in empty RDRAM and executing a **NOP sled to the end of memory**. That is why the game never programmed `VI_CTRL`: it never ran at all. The symptom hid perfectly behind the capstone's own metric, because a sledding machine still retires ~180 million instructions. Found by tracing the instruction stream rather than the state (`docs/engineering-lessons.md`), and by noticing `retired` was *identical across four different games* — the 'same value regardless of input' signature. With `sp` seeded (and the **RI register block** decoded so IPL3's `RI_SELECT` read is coherent), retail titles now boot into their own code: Super Mario 64 reaches `pc=0x80246ddc` with 928 KiB of RDRAM populated, Star Fox 64 submits **122** RDP commands, World Driver Championship **45**, and Super Mario 64, Star Fox 64 and World Driver Championship upload graphics microcode into IMEM. **"Lit pixels" has been RETIRED as evidence — it never meant what earlier revisions of this row implied.** It counts non-black scanned-out pixels, and *uninitialised RDRAM is non-black*. Rogue Squadron and Jet Force Gemini score 68 527 and 69 479 of 75 840 (90-92%) with 4 790 and 6 203 distinct colours, which sounds like a picture; **rendered to PNG and looked at, both are pure noise** — RDRAM garbage scanned out, no rendered content whatever, and neither title even runs microcode. This is the `retired > 1_000_000` failure again: a metric a broken machine satisfies exactly as easily as a working one, cited for weeks because nobody opened the image. Only two things are evidence of video now: **(a)** a byte-comparison against a committed golden frame, and **(b)** a human or oracle actually viewing the output. The capstone still *reports* the count, but as a diagnostic, never as a pass condition. **Where the video gap actually is — localised, and NOT where an earlier revision said.** That revision blamed the "RDP → VI presentation path"; **wrong**. Ocarina of Time's framebuffer at `VI_ORIGIN` is uniformly `0x0001` — RGBA5551 with R=G=B=0 and the coverage bit set — so **the VI is faithfully presenting a genuinely black buffer** and `scanout` is correct. Nor is submission at fault: a DPC-seam opcode census over 300 frames shows the real F3DEX stream arriving — **7,412 `TRIANGLE` (0x0F: shade + texture + Z)**, 1,630 `TEXTURE_RECTANGLE`, 4,374 `LOAD_BLOCK`, 7,705 `SET_TILE`, 1,589 `SET_COMBINE`, of 74,508 commands. So geometry, textures and tile state all reach the RDP, and the frame still ends as the clear colour: **the RDP rasterises real geometry to black**. That is the gap. **Narrowed again, by inspecting the RDP state Ocarina actually leaves.** The **Z path is refuted**: `z_compare_en = false` and `z_update_en = false`, so nothing is depth-rejected (`SET_Z_IMAGE` *is* issued, `z_image = 0x12c700`, but the compare is off). The **combiner is refuted as a *collapse***, and is in fact the key evidence: `cyc1` decodes to `rgb_a=15, rgb_b=15, rgb_c=31, rgb_d=1` = `(0 - 0) x 0 + TEXEL0`, a pure **texture pass-through** — so the pixel colour *is* the texel, and a black frame means **the texel fetch resolves to 0**. The tiles are `fmt=2, size=0` = **CI4**, 4-bit colour-indexed textures resolved through a **TLUT**. Both TMEM halves are populated after 4,374 `LOAD_BLOCK`s — 1,508/2,048 non-zero bytes in the texture half, 760/2,048 in the TLUT half — and TLUT entry 0 reads `0x0000` (black), with entries alternating `0000 ffff 0000 ffff` at the 8-byte stride `tlut_lookup` uses. So the remaining gap is the **CI4 + TLUT texel path**: real indices and a real palette are present, and the resolution yields black. **That probe has been run. Its result is PROVISIONAL and is recorded as such.** A CI4-with-TLUT vector was authored (eight indices 0..7 against eight distinct non-zero palette entries) and replayed: RustyN64 produced `f801 07c1 003f ffff ffc1 07ff` — red, green, blue, white, yellow, cyan, exactly the authored TLUT — while **Angrylion produced mostly `0x0001`** from the same command list. Angrylion is the oracle, so the disagreement means the authored **`Load Tlut` encoding is wrong**, not that our decoder is right: Angrylion loaded a near-empty palette and we were more permissive about where the entries came from. The vector was therefore **deliberately not committed** — committing that golden would pin an authoring error as the spec. **What this does and does not establish:** it shows the CI4 index→palette mapping resolves against whatever TMEM *we* loaded, which makes a totally-dead CI4 decoder unlikely; it does **not** establish that the load path is correct, because the probe never exercised a verified `Load Tlut`. So CI4 is *weakly* de-prioritised as R-18's cause, not eliminated. **A separate, independently real defect surfaced:** `Set Other Modes.tlut_en` — **bit 47**, N64brew *Reality Display Processor/Commands* §0x2F, *"tlut_en: Enables Texture Look-Up Table (TLUT) sampling"*, with `tlut_type` at bit 46 selecting RGBA16 vs IA16 — is **not decoded at all**. Our TLUT lookup is driven purely by the tile's *format* field, so a CI tile with `tlut_en` clear still gets a palette lookup and a non-CI tile with it set does not: wrong in both directions. **SETTLED 2026-07-29.** The `Load Tlut` encoding was verified against N64brew *…/Commands* §0x30 and the first attempt had **two** errors: `lower_right.s` is command bits **23:12** (`lo >> 12`, so an 8-entry palette is `(8-1) << 2 = 0x1C` shifted by 12 — I had shifted by 14, decoding as 29 entries), and the section's own *Hazards* require the TLUT tile to be **4-bit** and neither RGBA nor YUV (I had set it 16-bit; only the *texture image* is 16-bit). Re-authored correctly, Angrylion now renders the intended palette — `f801 07c1 003f ffff ffc1 07ff` — and **RustyN64 matches it byte-for-byte**. The vector `tex_tri_ci4_tlut_16` is committed (38 RDP vectors, 51 battery probes) and mutation-checked: changing `tlut_lookup`'s stride from the quadrupled 8 to 4 turns it red at pixel (3,2). So the **CI4 + TLUT path is now genuinely ELIMINATED** as R-18's cause — not weakly de-prioritised — and the colour-indexed path finally has oracle coverage it never had. **`tlut_en` remains undecoded** (above), still a real defect but demonstrably not this one: the vector passes because our lookup keys off the CI format, which happens to coincide with `tlut_en` being set here. **R-18's video cause is therefore still open**, and the remaining texture suspects are the ones this vector does *not* cover — `Load Block` vs `Load Tile` addressing at real texture sizes, the odd-line swap, and mip/LOD tile selection under a live command stream. **`Load Block` (0x33) has ZERO oracle coverage**, which is the sharpest of those: every committed texture vector loads through `Load Tile` (0x34), while Ocarina issues **4,374 `Load Block`s** of 74,508 commands — it is the dominant texture-load path in a live retail stream and is entirely unpinned. Reading the implementation, `load_block` *does* handle the two details §0x33 calls out — coordinates as **u12.0** (not `Load Tile`'s u10.2) and the **dxt-driven odd-line 32-bit word swap** (`line = (word * dxt) >> 11`, `swap = (line & 1) << 2`) — so a static read finds nothing wrong. **Two attempts to author a `Load Block` vector were made and both DISCARDED (not committed).** Recording the evidence so the next attempt starts from data rather than from my summary of it. Candidate `load_block_odd_line_16`: 16 distinct non-zero RGBA16 texels at `0x3000`; `Set Texture Image` `0x3D100007 0x00003000` (16-bit, width 8); load tile 7 `0x35100400 0x07000000` (16-bit, line 2 words, tmem 0); `Load Block` `0x33000000 0x0700F800` (uls=0, ult=0, lrs=15, dxt=0x800); render tile 0 `0x35100402 0x00000030` (16-bit, line 2 words, tmem word 2, mask_s=3). **Observed:** Angrylion's row 7 came back `003f 0001 0001 0001 f83f 8421 ffc1 07ff` — containing `0001` fill (unwritten TMEM) interleaved with line-0 texels — and at pixel (0,1) Angrylion gave `003f` where RustyN64 gave `0841` (line 1's first texel). **What that establishes:** the golden is unusable as an oracle, because part of it reflects TMEM the load never wrote. **What it does NOT establish:** *why*. The plausible **hypothesis** is that the authored layout is wrong — tile `line`, `tmem_addr`, or the texel count's inclusivity — but that is unverified, and the divergence is therefore **not attributable to RustyN64 either**. **That question is now SETTLED, and `Load Block` finally has coverage.** §0x33's prose says *"`lower_right.s - upper_left.s` determines the number of texels"* (no `+1`) while `Load Tile` is inclusive, so the readings disagreed and prose could not decide it. A **minimal** vector could: `load_block_count_16` uses `uls = 0, lrs = 1` — a load of either one texel or two — on a single line with `dxt = 0`, so neither the odd-line swap nor any multi-line layout can confound it (precisely what sank the two discarded attempts). **Angrylion loads two.** The count is therefore **inclusive** and `load_block`'s `shi - slo + 1` is correct; RustyN64 matches the golden byte-for-byte, and the vector is committed (39 RDP vectors, 52 battery probes) and mutation-checked — dropping the `+1` turns it red. So this hypothesis joins the others as **refuted**, and `Load Block` is no longer wholly unpinned, though the coverage it now has is minimal: multi-line loads, the odd-line swap under a real `dxt`, and non-16-bit texel sizes all remain untested. **CORRECTION (same day).** This row briefly claimed "the RSP is never started — no title unhalts it, so those RDP commands come from the CPU driving the DPC directly". **That was wrong**, and wrong for an instructive reason: it was measured off `Rsp::halted` / `Rsp::pc`, two `pub` struct fields that are **never written**. The authoritative state is `SP_STATUS` (`sp.halted()` / `sp.pc()`), which is what `su_step` itself gates on. Sampling the dead fields reports "halted forever at PC 0" for a *running* RSP — the inert-API hazard of `docs/engineering-lessons.md` §3.2, and it produced two confident wrong conclusions in one session. The fields are now private with `Rsp::halted()` / `Rsp::pc()` accessors delegating to `SP_STATUS`; they are kept in the struct only because removing them would change the save-state layout (ADR 0005). Measured correctly, **retail microcode executes**: Castlevania Legacy of Darkness visits 805 distinct RSP PCs, 007 TWINE 459, Beetle Adventure Racing 356, Star Fox 64 331, Super Mario 64 236, Mega Man 64 229, World Driver Championship 148 — see T-71-003's witness, `tests/game_microcode.rs`. **Provenance for `T-71-003`'s witness parameters** (recorded because a threshold without it is a fitted constant). `MIN_DISTINCT_PCS = 32` separates two **measured** populations more than an order of magnitude apart: titles whose RSP never runs measure **0** distinct PCs (Blast Corps, Bomberman 64, Donkey Kong 64, Jet Force Gemini, Rogue Squadron), while titles whose microcode runs measure **148-815** (World Driver Championship 148, Mega Man 64 229, Super Mario 64 236, Star Fox 64 331, Beetle Adventure Racing 356, 007 TWINE 459, Castlevania Legacy of Darkness 805). `SAMPLE_TICKS = 24` is a sampling cadence, not a hardware value: it is 8 RCP steps (the RCP advances every 3 master ticks, ADR 0006), sampling can only **under**-count so every figure is a lower bound, and re-running at the finest possible cadence of 3 (8× finer) leaves the verdict unchanged — 805→815, 459→463, 356→356, 229→258, same four witnesses. `FRAMES = 90` doubled to 180 yields an **identical** witness set with unchanged counts. **Corpus evidence for the capstone's `MIN_RDRAM_NONZERO` floor** (recorded here because a threshold with no provenance is a fitted constant): a booted title leaves **787 KiB - 1.23 MiB** of RDRAM non-zero (Mega Man 64 539 KiB at the low end, World Driver Championship 1.23 MiB at the high), while a machine that faults out of IPL3 leaves **exactly 0**. The capstone's floor is therefore derived as `IPL3_COPY_BYTES / 4` (256 KiB) from the documented 1 MiB IPL3 copy, comfortably between the two populations. **R-18 remainder — diagnosed to a point, and the limits of that diagnosis stated.** Five staged titles still do not run microcode, in three groups. (1) **CIC-6103/6106** — Banjo-Kazooie, 1080 Snowboarding, F-Zero X — *do* boot and execute game code (Banjo-Kazooie reaches `pc=0x80268fcc` with 1.2 MiB of RDRAM populated) and fill IMEM to 4094 bytes, but never leave `SP_STATUS.halt` even over **600 frames / 10 s emulated**, so this is a stall, not a slow init. (2) **Blast Corps, Bomberman 64** boot but never load microcode at all (IMEM stays 0). (3) **Rogue Squadron** loads no microcode; its high lit-pixel count is **noise**, not output (see the lit-pixel retirement above), so it renders nothing at all. **Three hypotheses have now been tested and REFUTED; record them so they are not re-chased.** (a) *KSEG0 under 64-bit addressing* — the segment map returns `Direct` for `0xFFFF_FFFF_8028_4C78` in wide kernel mode, pinned by `r18_kseg0_is_direct_in_wide_kernel_mode`. (b) *"These titles sit in the exception vector"* — a **correlated** capture (armed on first entry to `0x8000_0000..0x8000_0200`, per `docs/engineering-lessons.md`) showed **no exception at all**: `ExcCode=0`, `EPC=0`, `Cause=0`. The instruction stream shows IPL3 executing `LUI t4,0x8000` / `ADDIU t4,t4,0` / `JR t4` — for **CIC-6103/6106 the game's entry point simply IS `0x8000_0000`**, so PCs like `0x8000_018c` are game code, not a vector. An earlier revision of this row read those PCs as an exception loop; that was wrong, and it was wrong because the first reading sampled `Cause`/`EPC` uncorrelated, long after the fact. (c) *R-18's original "the VI vblank interrupt never fires" theory* — F-Zero X programs the VI (`VI_CTRL=0x3102`, `VI_V_INTR=2`), and the interrupt **does** fire and reach the CPU: 1228 `MI_INTR.vi` and 3304 `Cause.IP2` samples over 120 frames with `MI_MASK=0x3f`, comparable to Super Mario 64's 1024/7219 — and SM64 runs microcode fine. The VI interrupt path is working. **What the evidence does still show:** Banjo-Kazooie alternates between game code (`0x80268fxx`) and `0x8000_0184` at *identical* sample counts, which is the signature of a tight fault-and-return loop; 1080 spins at one PC; F-Zero X spins while receiving interrupts. So the remaining cause is per-title and downstream of boot, VI and addressing — not one shared subsystem gap. **Genuinely still open:** several titles (Blast Corps, Bomberman 64, Donkey Kong 64, Jet Force Gemini) never load microcode into IMEM at all, and Rogue Squadron never starts the RSP, so their boots stall earlier than the RSP seam. n64-systemtest is unchanged at 90 (it uses the ELF load path, not IPL3). | +| R-18 | A **commercial ROM boots and executes real code but does not reach video** (Phase 5 capstone). Through the retail HLE boot (`rom::hle_boot`) the game's own IPL3 runs, the CPU fetches the cartridge's instruction stream, and the PC advances through hundreds of millions of retired instructions across varied routines — but no frame is scanned out: over ~10 s of emulated time `VI_CTRL` stays 0, `VI_ORIGIN` is never set, and **no interrupt of any kind fires** (SM64 witnessed at `retired ≈ 9.4×10⁸`, all MI interrupt lines clear) | The retail OS-boot runtime the game waits on is not yet modelled. A commercial title's boot is interrupt-driven: after its OS initialises, its main loop blocks on the **VI vblank interrupt**, which the emulator only raises once the game programs `VI_CTRL`/`VI_V_INTR` — and the game does not reach that programming, indicating an earlier dependency (the **RI/RDRAM interface** registers used for RDRAM sizing, and/or the OS thread/interrupt setup). This is a cross-subsystem gap spanning the VI vblank loop, the RI registers, and the F3DEX graphics microcode — all **outside the Phase 5 cart/boot/saves boundary** (ADR 0003; the cart phase delivers PI/SI/PIF/CIC + saves, not the OS runtime) | absolute — a coverage boundary across subsystems, not a fitted constant or a timing interval | **Open — characterised, not a regression.** The committable Phase 5 gate (n64-systemtest cart/PIF/SI, save round-trips, homebrew boot) is met; the commercial capstone is asserted at its honest achievable level — `a_commercial_rom_boots_and_executes` (local, `#[ignore]`d) proves the ROM boots and retires ≥ 10⁶ real instructions without panicking, and *reports* the lit-pixel count (0) rather than asserting it. Reaching a title frame is deferred to the VI/RI/F3DEX work of a later phase and validated then. This gap was surfaced by the capstone exactly as the plan's escalation gate intended: **ship v0.6.0 on the committable gates + an honest "boots and executes" capstone, not a faked pass or an unbounded chase.** n64-systemtest impact: none — the boot/video path has no systemtest driver; the suite-wide count is **90** (see C-32) **SUBSTANTIALLY RESOLVED 2026-07-29 — the root cause was NOT the theory above.** It was `hle_boot` never seeding **`sp`**. IPL1 sets it before handing off (N64brew *IPL2* §IPL1 listing, `0xBFC000D0`: `ORI sp, sp, 0x1FF0 # sp = 0xA4001FF0`), and `hle_boot` skips IPL1/IPL2 without standing in for it. With `sp = 0`, IPL3's opening `ADDIU sp, sp, -24` / `SW s3, 0(sp)` prologue stored to `0xFFFF_FFE8` — KSEG3, TLB-mapped, no entries — taking a TLB-refill exception to `0x8000_0000` in empty RDRAM and executing a **NOP sled to the end of memory**. That is why the game never programmed `VI_CTRL`: it never ran at all. The symptom hid perfectly behind the capstone's own metric, because a sledding machine still retires ~180 million instructions. Found by tracing the instruction stream rather than the state (`docs/engineering-lessons.md`), and by noticing `retired` was *identical across four different games* — the 'same value regardless of input' signature. With `sp` seeded (and the **RI register block** decoded so IPL3's `RI_SELECT` read is coherent), retail titles now boot into their own code: Super Mario 64 reaches `pc=0x80246ddc` with 928 KiB of RDRAM populated, Star Fox 64 submits **122** RDP commands, World Driver Championship **45**, and Super Mario 64, Star Fox 64 and World Driver Championship upload graphics microcode into IMEM. **"Lit pixels" has been RETIRED as evidence — it never meant what earlier revisions of this row implied.** It counts non-black scanned-out pixels, and *uninitialised RDRAM is non-black*. Rogue Squadron and Jet Force Gemini score 68 527 and 69 479 of 75 840 (90-92%) with 4 790 and 6 203 distinct colours, which sounds like a picture; **rendered to PNG and looked at, both are pure noise** — RDRAM garbage scanned out, no rendered content whatever, and neither title even runs microcode. This is the `retired > 1_000_000` failure again: a metric a broken machine satisfies exactly as easily as a working one, cited for weeks because nobody opened the image. Only two things are evidence of video now: **(a)** a byte-comparison against a committed golden frame, and **(b)** a human or oracle actually viewing the output. The capstone still *reports* the count, but as a diagnostic, never as a pass condition. **Where the video gap actually is — localised, and NOT where an earlier revision said.** That revision blamed the "RDP → VI presentation path"; **wrong**. Ocarina of Time's framebuffer at `VI_ORIGIN` is uniformly `0x0001` — RGBA5551 with R=G=B=0 and the coverage bit set — so **the VI is faithfully presenting a genuinely black buffer** and `scanout` is correct. Nor is submission at fault: a DPC-seam opcode census over 300 frames shows the real F3DEX stream arriving — **7,412 `TRIANGLE` (0x0F: shade + texture + Z)**, 1,630 `TEXTURE_RECTANGLE`, 4,374 `LOAD_BLOCK`, 7,705 `SET_TILE`, 1,589 `SET_COMBINE`, of 74,508 commands. So geometry, textures and tile state all reach the RDP, and the frame still ends as the clear colour: **the RDP rasterises real geometry to black**. That is the gap. **Narrowed again, by inspecting the RDP state Ocarina actually leaves.** The **Z path is refuted**: `z_compare_en = false` and `z_update_en = false`, so nothing is depth-rejected (`SET_Z_IMAGE` *is* issued, `z_image = 0x12c700`, but the compare is off). The **combiner is refuted as a *collapse***, and is in fact the key evidence: `cyc1` decodes to `rgb_a=15, rgb_b=15, rgb_c=31, rgb_d=1` = `(0 - 0) x 0 + TEXEL0`, a pure **texture pass-through** — so the pixel colour *is* the texel, and a black frame means **the texel fetch resolves to 0**. The tiles are `fmt=2, size=0` = **CI4**, 4-bit colour-indexed textures resolved through a **TLUT**. Both TMEM halves are populated after 4,374 `LOAD_BLOCK`s — 1,508/2,048 non-zero bytes in the texture half, 760/2,048 in the TLUT half — and TLUT entry 0 reads `0x0000` (black), with entries alternating `0000 ffff 0000 ffff` at the 8-byte stride `tlut_lookup` uses. So the remaining gap is the **CI4 + TLUT texel path**: real indices and a real palette are present, and the resolution yields black. **That probe has been run. Its result is PROVISIONAL and is recorded as such.** A CI4-with-TLUT vector was authored (eight indices 0..7 against eight distinct non-zero palette entries) and replayed: RustyN64 produced `f801 07c1 003f ffff ffc1 07ff` — red, green, blue, white, yellow, cyan, exactly the authored TLUT — while **Angrylion produced mostly `0x0001`** from the same command list. Angrylion is the oracle, so the disagreement means the authored **`Load Tlut` encoding is wrong**, not that our decoder is right: Angrylion loaded a near-empty palette and we were more permissive about where the entries came from. The vector was therefore **deliberately not committed** — committing that golden would pin an authoring error as the spec. **What this does and does not establish:** it shows the CI4 index→palette mapping resolves against whatever TMEM *we* loaded, which makes a totally-dead CI4 decoder unlikely; it does **not** establish that the load path is correct, because the probe never exercised a verified `Load Tlut`. So CI4 is *weakly* de-prioritised as R-18's cause, not eliminated. **A separate, independently real defect surfaced:** `Set Other Modes.tlut_en` — **bit 47**, N64brew *Reality Display Processor/Commands* §0x2F, *"tlut_en: Enables Texture Look-Up Table (TLUT) sampling"*, with `tlut_type` at bit 46 selecting RGBA16 vs IA16 — is **not decoded at all**. Our TLUT lookup was driven purely by the tile's *format* field, so a CI tile with `tlut_en` clear still got a palette lookup and a non-CI tile with it set did not: wrong in both directions. **FIXED 2026-07-29.** `tlut_en` (bit 47) and `tlut_type` (bit 46) are now decoded, and the colour-index lookup is gated on `tlut_en` rather than on the format. The oracle settled the `tlut_en = 0` behaviour rather than it being guessed: `ci4_tlut_disabled_16` is byte-identical to `tex_tri_ci4_tlut_16` apart from that single bit, and the two goldens are **the full palette versus all black** — so an un-TLUT'd CI tile renders black. That is reproduced as the observed result, *not* as a mechanism claim: §0x2F does not document what the hardware does with un-TLUT'd index data, so no reinterpretation of the index bits is invented. `tlut_type`'s **IA16** palettes remain deferred (the lookup assumes RGBA16) — decoded so the flag is no longer silently ignored, but unimplemented until a vector exists. Battery is 53 probes (40 RDP + 13 VI); mutation-checked by removing the gate. **Only one of the two directions is fixed:** a CI tile with `tlut_en` clear is no longer palette-mapped, but a **non-CI tile with `tlut_en` set is still not** palette-mapped though hardware would sample it through the TLUT. No vector covers that case and the RGBA/IA/I formats index the palette differently enough that implementing it from prose would be inventing behaviour, so it stays wrong-but-recorded until a vector defines it. **SETTLED 2026-07-29.** The `Load Tlut` encoding was verified against N64brew *…/Commands* §0x30 and the first attempt had **two** errors: `lower_right.s` is command bits **23:12** (`lo >> 12`, so an 8-entry palette is `(8-1) << 2 = 0x1C` shifted by 12 — I had shifted by 14, decoding as 29 entries), and the section's own *Hazards* require the TLUT tile to be **4-bit** and neither RGBA nor YUV (I had set it 16-bit; only the *texture image* is 16-bit). Re-authored correctly, Angrylion now renders the intended palette — `f801 07c1 003f ffff ffc1 07ff` — and **RustyN64 matches it byte-for-byte**. The vector `tex_tri_ci4_tlut_16` is committed (38 RDP vectors, 51 battery probes) and mutation-checked: changing `tlut_lookup`'s stride from the quadrupled 8 to 4 turns it red at pixel (3,2). So the **CI4 + TLUT path is now genuinely ELIMINATED** as R-18's cause — not weakly de-prioritised — and the colour-indexed path finally has oracle coverage it never had. **`tlut_en` remains undecoded** (above), still a real defect but demonstrably not this one: the vector passes because our lookup keys off the CI format, which happens to coincide with `tlut_en` being set here. **R-18's video cause is therefore still open**, and the remaining texture suspects are the ones this vector does *not* cover — `Load Block` vs `Load Tile` addressing at real texture sizes, the odd-line swap, and mip/LOD tile selection under a live command stream. **`Load Block` (0x33) has ZERO oracle coverage**, which is the sharpest of those: every committed texture vector loads through `Load Tile` (0x34), while Ocarina issues **4,374 `Load Block`s** of 74,508 commands — it is the dominant texture-load path in a live retail stream and is entirely unpinned. Reading the implementation, `load_block` *does* handle the two details §0x33 calls out — coordinates as **u12.0** (not `Load Tile`'s u10.2) and the **dxt-driven odd-line 32-bit word swap** (`line = (word * dxt) >> 11`, `swap = (line & 1) << 2`) — so a static read finds nothing wrong. **Two attempts to author a `Load Block` vector were made and both DISCARDED (not committed).** Recording the evidence so the next attempt starts from data rather than from my summary of it. Candidate `load_block_odd_line_16`: 16 distinct non-zero RGBA16 texels at `0x3000`; `Set Texture Image` `0x3D100007 0x00003000` (16-bit, width 8); load tile 7 `0x35100400 0x07000000` (16-bit, line 2 words, tmem 0); `Load Block` `0x33000000 0x0700F800` (uls=0, ult=0, lrs=15, dxt=0x800); render tile 0 `0x35100402 0x00000030` (16-bit, line 2 words, tmem word 2, mask_s=3). **Observed:** Angrylion's row 7 came back `003f 0001 0001 0001 f83f 8421 ffc1 07ff` — containing `0001` fill (unwritten TMEM) interleaved with line-0 texels — and at pixel (0,1) Angrylion gave `003f` where RustyN64 gave `0841` (line 1's first texel). **What that establishes:** the golden is unusable as an oracle, because part of it reflects TMEM the load never wrote. **What it does NOT establish:** *why*. The plausible **hypothesis** is that the authored layout is wrong — tile `line`, `tmem_addr`, or the texel count's inclusivity — but that is unverified, and the divergence is therefore **not attributable to RustyN64 either**. **That question is now SETTLED, and `Load Block` finally has coverage.** §0x33's prose says *"`lower_right.s - upper_left.s` determines the number of texels"* (no `+1`) while `Load Tile` is inclusive, so the readings disagreed and prose could not decide it. A **minimal** vector could: `load_block_count_16` uses `uls = 0, lrs = 1` — a load of either one texel or two — on a single line with `dxt = 0`, so neither the odd-line swap nor any multi-line layout can confound it (precisely what sank the two discarded attempts). **Angrylion loads two.** The count is therefore **inclusive** and `load_block`'s `shi - slo + 1` is correct; RustyN64 matches the golden byte-for-byte, and the vector is committed (39 RDP vectors, 52 battery probes) and mutation-checked — dropping the `+1` turns it red. So this hypothesis joins the others as **refuted**, and `Load Block` is no longer wholly unpinned, though the coverage it now has is minimal: multi-line loads, the odd-line swap under a real `dxt`, and non-16-bit texel sizes all remain untested. **CORRECTION (same day).** This row briefly claimed "the RSP is never started — no title unhalts it, so those RDP commands come from the CPU driving the DPC directly". **That was wrong**, and wrong for an instructive reason: it was measured off `Rsp::halted` / `Rsp::pc`, two `pub` struct fields that are **never written**. The authoritative state is `SP_STATUS` (`sp.halted()` / `sp.pc()`), which is what `su_step` itself gates on. Sampling the dead fields reports "halted forever at PC 0" for a *running* RSP — the inert-API hazard of `docs/engineering-lessons.md` §3.2, and it produced two confident wrong conclusions in one session. The fields are now private with `Rsp::halted()` / `Rsp::pc()` accessors delegating to `SP_STATUS`; they are kept in the struct only because removing them would change the save-state layout (ADR 0005). Measured correctly, **retail microcode executes**: Castlevania Legacy of Darkness visits 805 distinct RSP PCs, 007 TWINE 459, Beetle Adventure Racing 356, Star Fox 64 331, Super Mario 64 236, Mega Man 64 229, World Driver Championship 148 — see T-71-003's witness, `tests/game_microcode.rs`. **Provenance for `T-71-003`'s witness parameters** (recorded because a threshold without it is a fitted constant). `MIN_DISTINCT_PCS = 32` separates two **measured** populations more than an order of magnitude apart: titles whose RSP never runs measure **0** distinct PCs (Blast Corps, Bomberman 64, Donkey Kong 64, Jet Force Gemini, Rogue Squadron), while titles whose microcode runs measure **148-815** (World Driver Championship 148, Mega Man 64 229, Super Mario 64 236, Star Fox 64 331, Beetle Adventure Racing 356, 007 TWINE 459, Castlevania Legacy of Darkness 805). `SAMPLE_TICKS = 24` is a sampling cadence, not a hardware value: it is 8 RCP steps (the RCP advances every 3 master ticks, ADR 0006), sampling can only **under**-count so every figure is a lower bound, and re-running at the finest possible cadence of 3 (8× finer) leaves the verdict unchanged — 805→815, 459→463, 356→356, 229→258, same four witnesses. `FRAMES = 90` doubled to 180 yields an **identical** witness set with unchanged counts. **Corpus evidence for the capstone's `MIN_RDRAM_NONZERO` floor** (recorded here because a threshold with no provenance is a fitted constant): a booted title leaves **787 KiB - 1.23 MiB** of RDRAM non-zero (Mega Man 64 539 KiB at the low end, World Driver Championship 1.23 MiB at the high), while a machine that faults out of IPL3 leaves **exactly 0**. The capstone's floor is therefore derived as `IPL3_COPY_BYTES / 4` (256 KiB) from the documented 1 MiB IPL3 copy, comfortably between the two populations. **R-18 remainder — diagnosed to a point, and the limits of that diagnosis stated.** Five staged titles still do not run microcode, in three groups. (1) **CIC-6103/6106** — Banjo-Kazooie, 1080 Snowboarding, F-Zero X — *do* boot and execute game code (Banjo-Kazooie reaches `pc=0x80268fcc` with 1.2 MiB of RDRAM populated) and fill IMEM to 4094 bytes, but never leave `SP_STATUS.halt` even over **600 frames / 10 s emulated**, so this is a stall, not a slow init. (2) **Blast Corps, Bomberman 64** boot but never load microcode at all (IMEM stays 0). (3) **Rogue Squadron** loads no microcode; its high lit-pixel count is **noise**, not output (see the lit-pixel retirement above), so it renders nothing at all. **Three hypotheses have now been tested and REFUTED; record them so they are not re-chased.** (a) *KSEG0 under 64-bit addressing* — the segment map returns `Direct` for `0xFFFF_FFFF_8028_4C78` in wide kernel mode, pinned by `r18_kseg0_is_direct_in_wide_kernel_mode`. (b) *"These titles sit in the exception vector"* — a **correlated** capture (armed on first entry to `0x8000_0000..0x8000_0200`, per `docs/engineering-lessons.md`) showed **no exception at all**: `ExcCode=0`, `EPC=0`, `Cause=0`. The instruction stream shows IPL3 executing `LUI t4,0x8000` / `ADDIU t4,t4,0` / `JR t4` — for **CIC-6103/6106 the game's entry point simply IS `0x8000_0000`**, so PCs like `0x8000_018c` are game code, not a vector. An earlier revision of this row read those PCs as an exception loop; that was wrong, and it was wrong because the first reading sampled `Cause`/`EPC` uncorrelated, long after the fact. (c) *R-18's original "the VI vblank interrupt never fires" theory* — F-Zero X programs the VI (`VI_CTRL=0x3102`, `VI_V_INTR=2`), and the interrupt **does** fire and reach the CPU: 1228 `MI_INTR.vi` and 3304 `Cause.IP2` samples over 120 frames with `MI_MASK=0x3f`, comparable to Super Mario 64's 1024/7219 — and SM64 runs microcode fine. The VI interrupt path is working. **What the evidence does still show:** Banjo-Kazooie alternates between game code (`0x80268fxx`) and `0x8000_0184` at *identical* sample counts, which is the signature of a tight fault-and-return loop; 1080 spins at one PC; F-Zero X spins while receiving interrupts. So the remaining cause is per-title and downstream of boot, VI and addressing — not one shared subsystem gap. **Genuinely still open:** several titles (Blast Corps, Bomberman 64, Donkey Kong 64, Jet Force Gemini) never load microcode into IMEM at all, and Rogue Squadron never starts the RSP, so their boots stall earlier than the RSP seam. n64-systemtest is unchanged at 90 (it uses the ELF load path, not IPL3). | | R-22 | The **RI register block** (`0x0470_0000..0x0470_0020`) is modelled as **plain storage**: writes stick, reads return them. Hardware read behaviour differs for at least three of the eight — N64brew *RDRAM Interface* documents `RI_CURRENT_LOAD` as intended write-only, its read returning "a collection of bits from other registers" (`RI_ERROR` Ack, `RI_MODE` STOP_R, `RI_SELECT` TSEL[0], and two bits marked TOVERIFY), while `RI_ERROR` and `RI_BANK_STATUS` reflect controller state rather than the last write | Storage is enough for the only consumer today: IPL3 reads `RI_SELECT` and branches on it. Modelling the readback quirks would mean inventing the parts the wiki itself marks TOVERIFY | absolute — a coverage boundary | **Open, deliberately.** n64-systemtest has **no RI group**, so there is no oracle in the vendored set; per measure-don't-tune these stay honest storage rather than fabricated behaviour. Decoding the block at all is what R-18 needed. The separate **RDRAM device registers** (`0x03F0_0000`) remain undecoded | | R-23 | **CIC-6105 titles do not boot through `hle_boot`** (Banjo-Tooie, Ocarina of Time, Majora's Mask, and the rest of the 4.5% 6105 share). They boot correctly through `real_pif_boot` | The 6105 IPL3 is a *different program*: it opens with a self-descrambling XOR loop — `LW t0, -0xFF0(t1)` / `LW t2, 0x44(t3)` / `XOR t2, t2, t0` / `SW t2, -0xFF0(t1)` — over `t1`/`t3` that only the **real IPL2** leaves set. `hle_boot` seeds `sp` and `s3`-`s7` but not those, so the first load faults to KSEG3 and the machine sleds exactly as R-18 did. The values are not in the wiki's IPL1/IPL2 listing, so seeding them would be inventing a constant | absolute — a coverage boundary | **RESOLVED 2026-07-29.** Closed without inventing anything: the missing registers were **measured** by running the console's real IPL1/IPL2 out of a PIF ROM dump via `real_pif_boot`, capturing the register file at IPL3's entry (`0xA400_0040`), and keeping only the values **identical across ROMs of different CIC variants** (Banjo-Tooie/6105 vs Super Mario 64/6102): `at=1`, `a2=0xA400_1F0C`, `a3=0xA400_1F08`, `t0=0xC0`, `t2=0x40`, `t3=0xA400_0000`, `s4=1`, `ra=0xA400_1550`. `v0`/`v1`/`a0`/`a1`/`t4`-`t9` are deliberately **excluded** — they carry IPL2's running checksum of that cartridge's IPL3 and differ per ROM, so freezing them would fabricate a value the boot computes. `t3` is the decisive one: 6105's IPL3 descrambles itself by reading `0x44(t3)` = DMEM + 0x40, its own image. Corroborated independently — Banjo-Tooie under HLE now halts at **`pc=0x800329a8`, the exact PC `real_pif_boot` reaches**, with retired counts within 0.3%. All four staged 6105 titles boot (Banjo-Tooie, Donkey Kong 64, Ocarina of Time, Majora's Mask), the capstone's 6105 skip is deleted, and the T-71-003 witness set **doubled from 4 titles to 8** — Ocarina of Time alone now executes 733 distinct RSP instructions and submits **17 900 RDP commands**. n64-systemtest unchanged at 90. **Superseded scope note:** detects 6105 from the cartridge header and skips those titles with a message naming this row, rather than passing quietly; the real-PIF capstone boots them and asserts on them. Closing this means either deriving the IPL2 exit state or preferring `real_pif_boot` when a PIF ROM is available | | R-19 | **The emulator hangs on the n64-systemtest test `TLB: Execute mapped branch with a non-mapped delay slot`** — a mapped branch whose delay slot lies in a page not currently in the TLB. Both the committed **base** ROM and the `--features timing` ROM stop dead there: `started = 917`, `emux_exited = false`, no test after it ever starts, at an 8×10⁹-tick budget (~2× a normal base completion). It is a genuine loop, not slowness. | **The committed `systemtest` gate masks it**: `tests/systemtest.rs` asserts Phase-1 *category* `Failed: 0` (those results are captured before test 917) and witnesses `started > 0`, but never requires the ROM to run to `xioctl(EXIT)` — so a mid-suite hang is invisible (the failure mode engineering-lessons §2.2 warns about, one level up). **Fully traced 2026-07-24 — every architectural field is CORRECT, so the defect is NOT in the delivered exception state.** The loop oscillates between `pc = 0x1234_5000` (the non-mapped delay-slot fetch) and `pc = 0x8000_0180` (the general vector), sustaining `EXL = 1`, with: `BadVAddr = 0x1234_5000` (✓ the delay slot), `EPC = 0x1234_4FFC` (✓ the branch, `pc − 4`), `Cause.BD = 1` (✓), `Cause.ExcCode = 2` = `TLBL` (✓), `Context`/`XContext` `BadVPN2 = 0x0_91A2` (✓ `= BadVAddr >> 13`), `EntryHi` VPN2 `= 0x1234_4000` + ASID (✓), 32-bit mode (`Status.KX/SX/UX = 0`). The general vector is correct *given* `EXL = 1` (a refill with `EXL` set uses `0x180`, S-3). `ERET` clears `EXL` correctly (tested). **So `EPC`, `BD`, `Cause`, `BadVAddr`, `Context`, `XContext`, `EntryHi`, the vector, and `ERET` are all right** — the earlier "vector/EPC/EXL is off" guess is disproven. The remaining suspects are in the finer *sequencing* the trace hasn't yet caught: (a) the **`EXL = 0` first fault** (does it reach the refill vector `0x8000_0000` and n64-systemtest's *test* handler, or does the refill handler's own page-table load fault nested straight to the general/"unexpected-exception spin" handler?); and (b) whether n64-systemtest's handler **maps the page and ERETs** expecting the fetch to now hit — in which case a stale **micro-ITLB** (not refilled from the JTLB after the map) would keep the fetch missing. | absolute — a hang is a coverage boundary, not a fitted constant | **Open — fully characterised, not yet root-caused. Blocks the `timing` suite from completing (so it blocks the clean `M` measurement, C-1).** **The test + handler are now understood** (`tlb/exceptions.rs:388` + `exception_handler.rs:247`): the JALR is the last instruction of the mapped page, its delay slot is the first of the next (unmapped) page; the test runs it under `expect_exception(TLBL, -4, …)`, which sets `EXCEPTION_SKIP = -4`, so the handler resumes at `return_to = exceptpc + skip*4 = EPC − 16` — back inside the mapped block, expecting the block's own code there to escape back to the `0x80…` test. n64-systemtest asserts `exceptpc == fault_address − 4` (line 436), and **our `EPC = 0x1234_4FFC` matches that exactly** — a third confirmation the exception state is right. So the loop is not a wrong `EPC`/vector; it is that after the skip-return our CPU re-reaches the JALR and re-faults instead of escaping. **RESOLVED 2026-07-24 (root-caused by a full pipeline-latch trace, not by reasoning).** The defect was NOT in the exception state (all correct, as characterised) but in the **branch-redirect vs. exception-vector race** in `ex_stage`. Sequence: the delay-slot fetch (`0x1234_5000`) faults and the exception is dispatched at the end of the cycle, setting `next_pc = 0x8000_0180` — but the JALR is still sitting **unexecuted** in `rf_ex`. On the next active cycle the JALR reaches EX and unconditionally applied its redirect (`*next_pc = r.target`), and this JALR's target is its **own address** (`v1 = 0x1234_4FFC`), so it clobbered the vector, re-fetched itself, re-faulted its delay slot, and looped forever — exactly the two-state oscillation the latch trace showed (JALR + aborted delay slot circulating, never retiring). Fix (`pipeline.rs::resolve_branch_control`): a branch whose delay slot has aborted (its `ic_rf` latch carries `in_delay_slot` + an abort) **still writes its link** — from the architectural `pc + 8`, since `next_pc` now holds the vector — but its **redirect is suppressed**, so the exception PC wins. This is hardware-accurate: the older branch retires and links (n64-systemtest asserts `RA == fault_address + 4`) while the precise exception on the younger delay slot takes over control flow. With the fix the delay-slot test passes and **the full suite runs to `xioctl(EXIT)` for the first time (950 tests, ~30 s), so `emux_exited` is now `true`.** No regression: golden-log 0-diff, determinism, residue-invariant, and all workspace tests stay green. Completing the run unmasked a distinct pre-existing cluster the hang had hidden — see **R-20**. Discovered + traced + fixed 2026-07-24 during the Stage-C/D timing work | diff --git a/docs/rdp.md b/docs/rdp.md index e45794a4..06acac38 100644 --- a/docs/rdp.md +++ b/docs/rdp.md @@ -354,6 +354,18 @@ matched to the ParaLLEl-RDP read layout (`texture.h`, MIT). RGBA32 (from the split TMEM: R,G low half, B,A high half), IA16/IA8/IA4, I8/I4 (alpha = intensity), and CI8/CI4 through the TLUT (CI4 folds `tile.palette` in as the high nibble of the index). The 4-bit formats select the high nibble for even `s`, the low for odd. +- **The palette lookup is gated on `Set Other Modes.tlut_en` (bit 47), not on the tile's + format** (N64brew *…/Commands* §0x2F). A CI tile with `tlut_en` clear is **not** + palette-mapped and renders black — pinned by `ci4_tlut_disabled_16`, which is + byte-identical to `tex_tri_ci4_tlut_16` apart from that one bit and whose golden is all + black where the other renders the full palette. + + Two limits are recorded rather than implied away. A **non-CI** tile with `tlut_en` **set** + is still not palette-mapped, though hardware would sample it through the TLUT: no vector + covers it and the RGBA/IA/I formats index the palette differently enough that deriving it + from prose would be invention. And `tlut_type` (bit 46) is decoded but **IA16 palettes are + deferred** — the lookup assumes RGBA16. Both stay wrong-but-known until a vector defines + them. **The read convention matches the loads.** TMEM is a natural big-endian byte array, so the sampler applies only the odd-row 32-bit-word swap `^= (t & 1) << 2` — the endian twiddles