From 7c2de6f987103b03cb7c96c36bca0a5bace656c1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 25 Jul 2026 03:39:22 -0400 Subject: [PATCH 1/3] test(rdp): oracle-validate the 4-bit texture path (ledger R-7) Studying the Angrylion oracle source (tex.c:526-533) corrected a misconception behind ledger R-7. There is NO 4-bit texel LOAD on the N64: a 4-bit texture-image load is invalid and crashes the RDP pipeline. Games load 4-bit textures by lying about the format -- an 8-bit texture image + 8-bit LOAD tile loads the packed bytes raw, then a SEPARATE 4-bit render tile extracts nibbles at fetch. RustyN64 already implements both halves (8-bit load + 4-bit fetch_texel for I4/IA4/CI4), so that canonical path already works -- R-7's "4-bit texels not loaded, needs nibble addressing" framing was wrong. New conformance vector tex_tri_i4_16 proves it: eight I4 texels packed two-per-byte (0xFD 0xB9 0x75 0x31), an 8-bit Load Tile, then a 4-bit (format I, size 0) render tile sampled across the triangle. RustyN64 reproduces Angrylion BYTE-FOR-BYTE. The eight intensities are descending and non-zero (texel 0 = white 0xFFFF, not black), so the test is non-vacuous: an all-zero (no-op) load would render black and fail. Since the load is format-agnostic (a raw byte copy), this validates the load mechanism for all 4-bit formats; the per-format decoders are separately unit-tested. No production code change (the bail on a 4-bit texture image already matches the hardware's invalid-load behaviour). R-7's genuinely-remaining gaps are now precisely characterized and stay open: the 32-bit Load Block split, and a direct 4-bit LOAD tile paired with an 8-bit texture image (the ti_size-vs-tile.size load granularity). - vectors-gen/driver.c: V20 tex_tri_i4_16 generator (+ Angrylion golden .rvec). - rdp_conformance.rs: the assertion. - Ledger R-7, docs/rdp.md, CHANGELOG corrected to the accurate hardware model. Gates: full rdp_conformance suite (22 pass), fmt --check, clippy -D warnings, markdownlint on the touched docs, check_no_roms clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 16 +++++++ .../tests/rdp_conformance.rs | 16 +++++++ .../tests/vectors/tex_tri_i4_16.rvec | Bin 0 -> 352 bytes .../vectors-gen/driver.c | 41 ++++++++++++++++++ docs/accuracy-ledger.md | 2 +- docs/rdp.md | 13 +++--- 6 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 crates/rustyn64-test-harness/tests/vectors/tex_tri_i4_16.rvec diff --git a/CHANGELOG.md b/CHANGELOG.md index a8404b6f..96236210 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). +### Added — 4-bit (I4) textures oracle-validated (gap-analysis Stage D, ledger R-7) + +- **The hardware-canonical 4-bit texture path is validated against Angrylion.** + Studying the oracle's source corrected a misconception: **there is no 4-bit + texel *load*** — a 4-bit texture-image load is invalid on hardware (Angrylion + crashes the RDP pipeline). Games load 4-bit textures by setting an 8-bit texture + image + 8-bit LOAD tile (raw packed bytes) and rendering with a separate 4-bit + tile that extracts nibbles at fetch. New conformance vector `tex_tri_i4_16` + drives that idiom — eight I4 texels packed two-per-byte, an 8-bit `Load Tile`, + a 4-bit render tile — and RustyN64's existing 8-bit load + 4-bit `fetch_texel` + reproduce Angrylion **byte-for-byte** (six distinct non-zero texels, so an + all-zero no-op load would render black and fail). No new "4-bit load" code — the + bail on a 4-bit texture image already matches hardware. R-7's remaining gaps + (the 32-bit `Load Block` split; a direct 4-bit LOAD tile with an 8-bit texture + image) are now precisely characterized and stay open. + ### Added — memory-access latency `M` charged (gap-analysis Stage D, ledger C-1) - **Uncached RCP-register `M` = 22 PClocks — MEASURED.** Derived from the diff --git a/crates/rustyn64-test-harness/tests/rdp_conformance.rs b/crates/rustyn64-test-harness/tests/rdp_conformance.rs index 49475108..49d7ec36 100644 --- a/crates/rustyn64-test-harness/tests/rdp_conformance.rs +++ b/crates/rustyn64-test-harness/tests/rdp_conformance.rs @@ -318,6 +318,22 @@ fn tex_tri_fixed_16_matches_angrylion() { ); } +/// A **4-bit (I4) textured triangle** — the hardware-canonical 4-bit texture path. +/// There is no 4-bit texel *load* on the N64 (a 4-bit texture-image load is invalid; +/// Angrylion crashes the pipeline, ledger R-7): games load 4-bit textures by setting +/// an 8-bit texture image + 8-bit LOAD tile, loading half as many texels raw, then +/// rendering with a SEPARATE 4-bit tile that extracts nibbles at fetch. This vector +/// drives that path — eight I4 texels packed two-per-byte, an 8-bit `Load Tile`, then +/// a 4-bit render tile — and proves RustyN64's existing 8-bit load + 4-bit `fetch_texel` +/// reproduce Angrylion byte-for-byte (no new "4-bit load" code, matching hardware). +#[test] +fn tex_tri_i4_16_matches_angrylion() { + assert_matches( + "tex_tri_i4_16", + include_bytes!("vectors/tex_tri_i4_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_i4_16.rvec b/crates/rustyn64-test-harness/tests/vectors/tex_tri_i4_16.rvec new file mode 100644 index 0000000000000000000000000000000000000000..0cb7ffbc34778aedb260c99e2722c8d686d9cd42 GIT binary patch literal 352 zcmWFvb9H85U|?ck5CBpP98e78D}eYLfVhEy!2rZ&`Ma~!P@jS01Bhb-A{ZDPZ8;d2 zLGq>?ObqNm0V5!0XE?xM0;NqGfJOlofW#RX4=~sZfRqU^=z{o63<_KgK#LSW94G)Q z2eLslBUB7TD?r&)p#J|yHRS*Qd*6{oK>WS$5h4)&oaZnR7{7QfL> 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) | | R-9 | The triangle rasteriser now interpolates **depth** (T-33-004 PR-B 2a), **shade** (2b), and **texture** (`S/T/W` → `fetch_texel`, 2b-texture) per pixel, and routes the combiner colour through the **memory-read blender** when the depth test enables blending (2b-blend, gated on `force_blend`) — depth-tested, Gouraud-shaded, textured, and translucent triangles render; what remains is the bit-exact **sub-pixel coverage** (ParaLLEl-RDP's `quantize_x` sticky-bit edge rounding and the `do_offset` last-subpixel latch) and the coverage-driven AA blend, so each edge is still reduced to whole pixels (`>> 16`), and the `dz` derivation is a first-cut gradient | Landing the edge-walk, then the depth and shade interpolators (each hand-verified), then texture and the sub-pixel coverage is the tractable order; the coverage rule and the full combiner→blender→memory surface are a combinatorial space best pinned by the conformance fuzz (T-33-005) | absolute — a coverage boundary, not a fitted constant | **Open.** The flat fill, the depth test (occluding-triangle pairs), the shade interpolation (hand-computed base colour + a combiner-routed shaded triangle), the texture sample, and the memory-read blender (a 50/50 translucent triangle over a pre-filled background) are each unit/integration-tested. The sub-pixel coverage **primitives** — `compute_coverage` (the 4×2 diamond-sample 8-bit mask) and `quantize_x` (the `s.16`→`s.3` sticky-bit snap) — are now a bit-exact port of parallel-rdp `coverage.h`/`span_setup.comp`, pinned by hand-computed unit tests derived from the oracle's arithmetic (full/partial/empty masks, the sticky bit). The primitives are now **wired into the 1-/2-cycle rasteriser**: the edge-walk builds per-Y-subpixel `s.3` edges, `pixel_coverage` gates each pixel (AA-off top-left-sample rule) and stores the coverage count in the pixel alpha (`(count − 1) & 7`, the `cvg_dest` clamp write-back). FILL/COPY mode keeps the whole-pixel span (correct — FILL renders "without subpixel accuracy"). This is validated against Angrylion: `fill_tri_frac_16` (FILL rounds fractional edges to whole pixels) and `shade_tri_frac_16` (a 1-cycle shaded triangle excluding a fractional-edge column and leaving a partially-covered column at reduced coverage) pass byte-for-byte. The **depth path** applies the same coverage (`shade_depth_tri_frac_16` — a z-suffixed fractional triangle — renders identically to `shade_tri_frac_16`, byte-exact vs Angrylion). **Ordered RGB dither is now wired** (T-33-004 2c): the combined RGB is dithered per pixel by the magic/bayer 4×4 matrix before write-back (`apply_rgb_dither`, a bit-exact port of Angrylion `dither.c` `rgb_dither`), validated byte-for-byte by `dither_tri_32` (the RDP default magic dither over a `0x112233` shade). **Alpha-compare and `cvg_dest = full` are now wired**: `pixel_coverage` stores full coverage (7) when `cvg_dest = 2` (validated by `cvg_dest_full_16` — a fractional triangle whose partial edge column stores `0xf801` instead of the clamp `0xf800`), and the alpha-compare write gate is on both pixel paths (R-11). Remaining slice-2c residual: the coverage-weighted **interpenetration Z** path, the **AA-edge blend**, and the **wrap/save `cvg_dest`** modes (these need the memory-read coverage accumulator) are not wired — each to be pinned by further conformance vectors | | R-8 | Copy-mode `Texture Rectangle` (T-32-004) is wired for a **16-bit tile → 16-bit colour image** (the first-picture path), including the **4-pixels-per-cycle sub-texel selection** under non-1:1 `DsDx`; `Texture Rectangle Flip` (0x25), the 8/32-bit and TLUT copy paths, and the copy alpha-compare are not modelled — an unsupported configuration draws nothing | The full copy pipeline (per-format `dx_shift`/`s_offset` 64-bit-group fetch, the 8-bit high-word replication quirk, the RGBA5551 alpha-on-LSB test) is a combinatorial surface best pinned by the bit-exact fuzz rather than by hand. The 16-bit 1:1 path is the one a first textured frame needs, and its horizontal step is scaled by `>> (5 + dx_shift)` so a canonical `DsDx = 4.0` advances one texel per pixel | absolute — a coverage boundary, not a fitted constant | **Open — but the 16-bit 1:1 copy is now ORACLE-VALIDATED.** Beyond the internal **round-trip identity** test (`Load Tile` loads a 4×2 texture and `Texture Rectangle` blits it back byte-for-byte; load and fetch share the odd-row swap) and the `wrap_coord` unit test, the `tex_rect_copy_16` conformance vector (T-33-005) now drives the full command sequence — Set Texture Image → Set Tile → Set Tile Size → Load Tile → COPY-mode Texture Rectangle — through **Angrylion** and RustyN64 matches the golden byte-for-byte. This is the **first texture path checked against the oracle** (copy mode bypasses the combiner and the 1-cycle texel pipeline, so it is clean where the `tex_tri_16` triangle path is not — R-13). **The 4-pixels-per-cycle sub-texel selection under non-1:1 `DsDx` is now modelled and oracle-validated too:** COPY mode reads a 64-bit TMEM word (4 consecutive texels) per cycle, so the base texel is evaluated at each cycle's first column (advancing `DsDx × 4` texels/cycle) and the within-cycle offset is a direct `+0..3` TMEM increment, **not** a per-pixel step. The `tex_rect_mag_16` vector (a 2× magnify, `DsDx = 2.0`) reads texels `0,1,2,3,2,3,4,5` and RustyN64 matches Angrylion byte-for-byte; the 1:1 case (`DsDx = 4.0`) is the special case `s = col` and the round-trip + `tex_rect_copy_16`/`_offset_16`/`_8x8_16` vectors still pass. **n64-systemtest impact: not measured** — the copy path has no runtime driver in the suite (nothing calls `texture_rectangle` during a systemtest run), so it is unreachable by n64-systemtest and the oracle count stays **93**. Still deferred (each an unsupported config that draws nothing): Flip 0x25, 8/32-bit, TLUT copy, and copy alpha-compare — validated against further conformance vectors as they land | -| R-7 | The TMEM loads (T-32-002) cover **8/16/32-bit** texels for `Load Tile` and **8/16-bit** for `Load Block`; **4-bit** texels (both loads) and the **32-bit split** path of `Load Block` are not yet loaded — an unsupported size writes nothing rather than guessing | 4-bit loading needs nibble addressing, which pairs naturally with the CI4/I4 texel-format decoders in T-32-003; the 32-bit `Load Block` split path (which iterates twice per 64-bit word) is rare in practice (games stream 16-bit textures via `Load Block` and use `Load Tile` for 32-bit). Writing nothing for an unsupported size keeps the gap falsifiable rather than emitting fabricated texels | absolute — a coverage boundary, not a fitted constant | **Open.** The supported sizes are byte-exact against hand-computed expectations, including the odd-row 32-bit-word swap, the 32-bit `Load Tile` R/G-low, B/A-high split, and the `Load Block` dxt line-parity swap (five `rdp` unit tests). The deferred paths land with T-32-003 (4-bit) and Sprint 3, validated against the ParaLLEl-RDP fuzz suite | +| R-7 | The TMEM loads (T-32-002) cover **8/16/32-bit** texels for `Load Tile` and **8/16-bit** for `Load Block`. **There is no 4-bit texel *load*** — a 4-bit *texture-image* load is invalid on hardware (Angrylion sets `rdp_pipeline_crashed` and bails, `tex.c:526-533`); games load 4-bit textures by setting an **8-bit** texture image + 8-bit LOAD tile, loading half as many texels raw, then rendering with a **separate 4-bit tile** that extracts nibbles at fetch. That canonical path already works. Genuinely deferred: the **32-bit split** path of `Load Block`, and a *direct* 4-bit LOAD tile paired with an 8-bit texture image (the `ti_size`-vs-`tile.size` load granularity — Angrylion keys the copy stride off `ti_size` and the destination short-index off `tile.size` via `sshorts = s >> 2`) | The earlier "4-bit loading needs nibble addressing" framing was a **misconception**: 4-bit texels are never DMA'd as nibbles — the load is a raw byte stream sized by the texture image, and the nibble semantics live only in `fetch_texel` (already implemented for I4/IA4/CI4). RustyN64's bail on a 4-bit texture image thus *matches* the hardware's invalid-load behaviour. Writing nothing for the two genuinely-deferred cases keeps them falsifiable rather than emitting fabricated texels | absolute — a coverage boundary, not a fitted constant | **The canonical 4-bit path is now ORACLE-VALIDATED (2026-07-25).** `tex_tri_i4_16` (T-33-005) drives the full hardware idiom — 8 I4 texels packed two-per-byte, an **8-bit** `Load Tile`, then a **4-bit** (format I, size 0) render tile sampled across the triangle — and RustyN64's existing 8-bit load + 4-bit `fetch_texel` reproduce **Angrylion byte-for-byte**. Non-vacuous by construction: the eight intensities are DESCENDING and non-zero (texel 0 = white `0xFFFF`, not black), so an all-zero (no-op) load would render black and fail. Since the load is format-agnostic (a raw byte copy), this validates the load mechanism for **all** 4-bit formats; the I4/IA4/CI4 decoders are separately unit-tested. The two remaining gaps above are still `Open` (no runtime driver in n64-systemtest; the count stays **90**), to be pinned by further conformance vectors. The supported sizes remain byte-exact against hand-computed expectations, including the odd-row 32-bit-word swap, the 32-bit `Load Tile` split, and the `Load Block` dxt line-parity swap (five `rdp` unit tests) | | R-5 | VI scan-out (T-31-004) is a **1:1 copy** — `VI_X_SCALE`/`VI_Y_SCALE` resampling and the AA / divot / de-dither post-filters are not applied, and the height is derived directly from `VI_V_VIDEO`'s active half-lines rather than from the scale-accumulated framebuffer walk | The framebuffer→RGBA8 pixel conversion is exact and cited: the pixel *format* is selected by `VI_CTRL.TYPE[1:0]` (N64brew *Video Interface* §VI_CTRL — 2 = RGBA5551, 3 = RGBA8888), the RGBA5551 bit layout (R[15:11] G[10:6] B[5:1] A[0]) is the N64 16-bit colour format (N64brew *Reality Display Processor/Commands* §Set Color Image, texture/format enum; *Video DAC*), and the 5→8-bit widening by high-bit replication is the standard N64 convention (the value the VI DAC emits). What is **deferred**: the geometric resampling — the VI accumulates a sub-pixel step of `VI_X_SCALE`/`VI_Y_SCALE` per pixel/line (N64brew *Video Interface* §VI_X_SCALE, §VI_Y_SCALE) — and the analog post-filters `AA_MODE`/`DIVOT_ENABLE`/de-dither (§VI_CTRL), which only matter once scaled or anti-aliased content is scanned | absolute — a resampling/filter geometry choice, not a timing interval | **Open.** Byte-exact for a 1:1, unfiltered scan of a framebuffer whose width matches `VI_WIDTH` — which is what the FILL pipeline produces and the T-31-004 unit tests pin. Scaling and the post-filters **will be** validated bit-for-bit against Angrylion via the ParaLLEl-RDP fuzz suite / VI golden frames (Sprint 3), and superseded here if they diverge — this entry stays open until then. **n64-systemtest impact: not measured** — `Bus::scanout` has no runtime driver (nothing in the run loop calls it), so it is unreachable by the suite and cannot change the count, which stands at 93 | | R-4 | The VI register file (T-31-004) stores the **full 32-bit value** written to each register; the per-register write masks the hardware enforces (`VI_ORIGIN` 24-bit, `VI_WIDTH` 12-bit, `VI_V_INTR` 10-bit, the multi-field `VI_CTRL`/`VI_H_VIDEO`/scale registers, …) are not applied | The masks are documented as *field widths* in N64brew *Video Interface* per register, but the exact discard behaviour on write (which reserved bits read back 0 vs. retain) is what n64-systemtest's VI-register group actually pins, and that has not been run against a masked implementation | absolute — a register-decode fact, not a timing interval | **Open.** In-range writes (every value the register's own fields can hold) round-trip correctly, which the T-31-004 unit tests pin; out-of-range bits are retained rather than dropped. To be measured against n64-systemtest's VI group and masked per register when that group is exercised (measure, don't guess). No assertion currently exercises it (count unchanged at 93) | | R-3 | FILL-mode `Fill Rectangle` (T-31-003) rasterises the rectangle with an **inclusive lower-right pixel** — floor the upper-left, and draw through the pixel that *contains* the lower-right coordinate; in FILL/COPY mode the low two bits of `yl` are forced set before the shift (`yl \| 3`) so the final scanline fills whole. The **scissor** clips separately with an **asymmetric** lower-right (inclusive X, exclusive Y — see R-15) | The N64brew wiki says only "upper-left rounded down, lower-right rounded up"; the exact edge behaviour is what the Angrylion oracle pins, and it is *inclusive of the lower-right pixel* (a rect `(0,3)-(1,4)` draws columns 0 **and** 1), plus the FILL/COPY `yl \| 3` (Angrylion `rasterizer.c` `rdp_fill_rect`). The earlier `(coord + 3) >> 2` half-open span dropped the boundary row/column — a realisation of the cited prose that disagreed with the hardware. Read from the oracle's output, not computed | absolute — a rasterisation geometry rule, not a timing interval, so the differential/re-phasing test is N/A | **Closed for the integer-coordinate FILL rect (oracle-validated).** The inclusive lower-right + `yl \| 3` rule is now pinned bit-for-bit against Angrylion by the **seeded-fuzz corpus** (`tests/vectors/fuzz/`, 48 random FILL rectangles sweeping colour/size/position with a full-image scissor, all byte-exact) and a mutation-checked unit test (`fill_rectangle_lower_right_edge_is_inclusive`). The fuzz gate **found** the pre-fix off-by-one. Still unverified: **sub-pixel** (fractional-coordinate) rect edges, which the whole-pixel fuzz does not exercise; the **scissor** lower-right rounding is resolved separately (**R-15**, asymmetric X/Y). No n64-systemtest assertion drives the render path (count unchanged at 93) | diff --git a/docs/rdp.md b/docs/rdp.md index 7eef007f..0e5beebf 100644 --- a/docs/rdp.md +++ b/docs/rdp.md @@ -279,11 +279,14 @@ cross-verified against the N64brew wiki (*…/Commands*) and the ParaLLEl-RDP re unsupported path, rather than iterating a wrapped bogus width. Scope (**open residual R-7**): `Load Tile` covers 8/16/32-bit texels and `Load Block` -covers 8/16-bit. **4-bit** texels (nibble addressing, pairs with the CI4/I4 decoders in -T-32-003) and the **32-bit `Load Block` split** are deferred; an unsupported size writes -nothing. The supported paths are byte-exact against hand-computed expectations (five unit -tests). The oracle count stays **93** — a load is observable only once the sampler -(T-32-004) reads TMEM. +covers 8/16-bit. There is **no 4-bit texel *load*** — a 4-bit texture-image load is +invalid on hardware (it crashes the RDP pipeline). Games load 4-bit textures by lying +about the format: an 8-bit texture image + 8-bit LOAD tile loads the packed bytes raw, +then a **separate 4-bit render tile** extracts nibbles at fetch. That canonical path is +**oracle-validated** (`tex_tri_i4_16` matches Angrylion byte-for-byte). Still deferred: +the **32-bit `Load Block` split** and a *direct* 4-bit LOAD tile with an 8-bit texture +image (the `ti_size`-vs-`tile.size` load granularity); an unsupported size writes nothing. +The supported paths are byte-exact against hand-computed expectations (five unit tests). ### The sampler and copy-mode Texture Rectangle (T-32-004) From d5edd58c060650fbbc4bf63bae66348f7863c69a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 25 Jul 2026 03:44:04 -0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(rdp):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20clarify=20line=20units,=20point=20count=20at=20STATUS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt Antigravity review: - Nitpick: clarify the LOAD tile's line=1 is one 64-bit word (not a byte count). - Suggestion (R-7 count 90 vs neighbours' 93): the reviewer flagged a real inconsistency but the fix direction is inverted — 90 is authoritative (docs/STATUS.md, after Phase 5 dropped 93->90); the surrounding 93s are stale. Resolve by pointing the R-7 narrative at STATUS.md rather than citing a number, per the docs-as-spec rule, avoiding both the stale 93 and a ledger-wide count sweep in this focused PR. Rejected: the uint8_t-vs-uint16_t preload suggestion — the .rvec byte emit is explicitly big-endian and host-independent (documented in driver.c), the u16 array is the established Vector.texels pattern, and the byte-for-byte pass proves the byte order is correct. Co-Authored-By: Claude Opus 4.8 --- crates/rustyn64-test-harness/vectors-gen/driver.c | 2 +- docs/accuracy-ledger.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rustyn64-test-harness/vectors-gen/driver.c b/crates/rustyn64-test-harness/vectors-gen/driver.c index 4e9eb5a2..e6f3c5bd 100644 --- a/crates/rustyn64-test-harness/vectors-gen/driver.c +++ b/crates/rustyn64-test-harness/vectors-gen/driver.c @@ -684,7 +684,7 @@ static const uint32_t V20_TEX_TRI_I4_16[] = { 0x2F0008F0u, 0x00000000u, // Set Other Modes: 1-cycle, bi_lerp0=1, persp off 0x3C000000u, 0x00000041u, // Set Combine Mode: rgb_d=1 / a_d=1 (texel0 passthrough) 0x3D080003u, 0x00003000u, // Set Texture Image: 8-bit (size=1), width 4, addr 0x3000 - 0x35080200u, 0x07000000u, // Set Tile 7 (LOAD): 8-bit, line=1, tmem 0 + 0x35080200u, 0x07000000u, // Set Tile 7 (LOAD): 8-bit, line=1 (one 64-bit word), tmem 0 0x32000000u, 0x0700C000u, // Set Tile Size 7: SL0 TL0 SH3 TH0 (4 8-bit bytes) 0x34000000u, 0x0700C000u, // Load Tile 7: SL0 TL0 SH3 TH0 0x35800200u, 0x00000030u, // Set Tile 0 (RENDER): format I(4), size 0(4bit), line 1, mask_s=3 diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index bd499bd9..5711869f 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -394,7 +394,7 @@ labelled as one until something reads the register on hardware. | 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) | | R-9 | The triangle rasteriser now interpolates **depth** (T-33-004 PR-B 2a), **shade** (2b), and **texture** (`S/T/W` → `fetch_texel`, 2b-texture) per pixel, and routes the combiner colour through the **memory-read blender** when the depth test enables blending (2b-blend, gated on `force_blend`) — depth-tested, Gouraud-shaded, textured, and translucent triangles render; what remains is the bit-exact **sub-pixel coverage** (ParaLLEl-RDP's `quantize_x` sticky-bit edge rounding and the `do_offset` last-subpixel latch) and the coverage-driven AA blend, so each edge is still reduced to whole pixels (`>> 16`), and the `dz` derivation is a first-cut gradient | Landing the edge-walk, then the depth and shade interpolators (each hand-verified), then texture and the sub-pixel coverage is the tractable order; the coverage rule and the full combiner→blender→memory surface are a combinatorial space best pinned by the conformance fuzz (T-33-005) | absolute — a coverage boundary, not a fitted constant | **Open.** The flat fill, the depth test (occluding-triangle pairs), the shade interpolation (hand-computed base colour + a combiner-routed shaded triangle), the texture sample, and the memory-read blender (a 50/50 translucent triangle over a pre-filled background) are each unit/integration-tested. The sub-pixel coverage **primitives** — `compute_coverage` (the 4×2 diamond-sample 8-bit mask) and `quantize_x` (the `s.16`→`s.3` sticky-bit snap) — are now a bit-exact port of parallel-rdp `coverage.h`/`span_setup.comp`, pinned by hand-computed unit tests derived from the oracle's arithmetic (full/partial/empty masks, the sticky bit). The primitives are now **wired into the 1-/2-cycle rasteriser**: the edge-walk builds per-Y-subpixel `s.3` edges, `pixel_coverage` gates each pixel (AA-off top-left-sample rule) and stores the coverage count in the pixel alpha (`(count − 1) & 7`, the `cvg_dest` clamp write-back). FILL/COPY mode keeps the whole-pixel span (correct — FILL renders "without subpixel accuracy"). This is validated against Angrylion: `fill_tri_frac_16` (FILL rounds fractional edges to whole pixels) and `shade_tri_frac_16` (a 1-cycle shaded triangle excluding a fractional-edge column and leaving a partially-covered column at reduced coverage) pass byte-for-byte. The **depth path** applies the same coverage (`shade_depth_tri_frac_16` — a z-suffixed fractional triangle — renders identically to `shade_tri_frac_16`, byte-exact vs Angrylion). **Ordered RGB dither is now wired** (T-33-004 2c): the combined RGB is dithered per pixel by the magic/bayer 4×4 matrix before write-back (`apply_rgb_dither`, a bit-exact port of Angrylion `dither.c` `rgb_dither`), validated byte-for-byte by `dither_tri_32` (the RDP default magic dither over a `0x112233` shade). **Alpha-compare and `cvg_dest = full` are now wired**: `pixel_coverage` stores full coverage (7) when `cvg_dest = 2` (validated by `cvg_dest_full_16` — a fractional triangle whose partial edge column stores `0xf801` instead of the clamp `0xf800`), and the alpha-compare write gate is on both pixel paths (R-11). Remaining slice-2c residual: the coverage-weighted **interpenetration Z** path, the **AA-edge blend**, and the **wrap/save `cvg_dest`** modes (these need the memory-read coverage accumulator) are not wired — each to be pinned by further conformance vectors | | R-8 | Copy-mode `Texture Rectangle` (T-32-004) is wired for a **16-bit tile → 16-bit colour image** (the first-picture path), including the **4-pixels-per-cycle sub-texel selection** under non-1:1 `DsDx`; `Texture Rectangle Flip` (0x25), the 8/32-bit and TLUT copy paths, and the copy alpha-compare are not modelled — an unsupported configuration draws nothing | The full copy pipeline (per-format `dx_shift`/`s_offset` 64-bit-group fetch, the 8-bit high-word replication quirk, the RGBA5551 alpha-on-LSB test) is a combinatorial surface best pinned by the bit-exact fuzz rather than by hand. The 16-bit 1:1 path is the one a first textured frame needs, and its horizontal step is scaled by `>> (5 + dx_shift)` so a canonical `DsDx = 4.0` advances one texel per pixel | absolute — a coverage boundary, not a fitted constant | **Open — but the 16-bit 1:1 copy is now ORACLE-VALIDATED.** Beyond the internal **round-trip identity** test (`Load Tile` loads a 4×2 texture and `Texture Rectangle` blits it back byte-for-byte; load and fetch share the odd-row swap) and the `wrap_coord` unit test, the `tex_rect_copy_16` conformance vector (T-33-005) now drives the full command sequence — Set Texture Image → Set Tile → Set Tile Size → Load Tile → COPY-mode Texture Rectangle — through **Angrylion** and RustyN64 matches the golden byte-for-byte. This is the **first texture path checked against the oracle** (copy mode bypasses the combiner and the 1-cycle texel pipeline, so it is clean where the `tex_tri_16` triangle path is not — R-13). **The 4-pixels-per-cycle sub-texel selection under non-1:1 `DsDx` is now modelled and oracle-validated too:** COPY mode reads a 64-bit TMEM word (4 consecutive texels) per cycle, so the base texel is evaluated at each cycle's first column (advancing `DsDx × 4` texels/cycle) and the within-cycle offset is a direct `+0..3` TMEM increment, **not** a per-pixel step. The `tex_rect_mag_16` vector (a 2× magnify, `DsDx = 2.0`) reads texels `0,1,2,3,2,3,4,5` and RustyN64 matches Angrylion byte-for-byte; the 1:1 case (`DsDx = 4.0`) is the special case `s = col` and the round-trip + `tex_rect_copy_16`/`_offset_16`/`_8x8_16` vectors still pass. **n64-systemtest impact: not measured** — the copy path has no runtime driver in the suite (nothing calls `texture_rectangle` during a systemtest run), so it is unreachable by n64-systemtest and the oracle count stays **93**. Still deferred (each an unsupported config that draws nothing): Flip 0x25, 8/32-bit, TLUT copy, and copy alpha-compare — validated against further conformance vectors as they land | -| R-7 | The TMEM loads (T-32-002) cover **8/16/32-bit** texels for `Load Tile` and **8/16-bit** for `Load Block`. **There is no 4-bit texel *load*** — a 4-bit *texture-image* load is invalid on hardware (Angrylion sets `rdp_pipeline_crashed` and bails, `tex.c:526-533`); games load 4-bit textures by setting an **8-bit** texture image + 8-bit LOAD tile, loading half as many texels raw, then rendering with a **separate 4-bit tile** that extracts nibbles at fetch. That canonical path already works. Genuinely deferred: the **32-bit split** path of `Load Block`, and a *direct* 4-bit LOAD tile paired with an 8-bit texture image (the `ti_size`-vs-`tile.size` load granularity — Angrylion keys the copy stride off `ti_size` and the destination short-index off `tile.size` via `sshorts = s >> 2`) | The earlier "4-bit loading needs nibble addressing" framing was a **misconception**: 4-bit texels are never DMA'd as nibbles — the load is a raw byte stream sized by the texture image, and the nibble semantics live only in `fetch_texel` (already implemented for I4/IA4/CI4). RustyN64's bail on a 4-bit texture image thus *matches* the hardware's invalid-load behaviour. Writing nothing for the two genuinely-deferred cases keeps them falsifiable rather than emitting fabricated texels | absolute — a coverage boundary, not a fitted constant | **The canonical 4-bit path is now ORACLE-VALIDATED (2026-07-25).** `tex_tri_i4_16` (T-33-005) drives the full hardware idiom — 8 I4 texels packed two-per-byte, an **8-bit** `Load Tile`, then a **4-bit** (format I, size 0) render tile sampled across the triangle — and RustyN64's existing 8-bit load + 4-bit `fetch_texel` reproduce **Angrylion byte-for-byte**. Non-vacuous by construction: the eight intensities are DESCENDING and non-zero (texel 0 = white `0xFFFF`, not black), so an all-zero (no-op) load would render black and fail. Since the load is format-agnostic (a raw byte copy), this validates the load mechanism for **all** 4-bit formats; the I4/IA4/CI4 decoders are separately unit-tested. The two remaining gaps above are still `Open` (no runtime driver in n64-systemtest; the count stays **90**), to be pinned by further conformance vectors. The supported sizes remain byte-exact against hand-computed expectations, including the odd-row 32-bit-word swap, the 32-bit `Load Tile` split, and the `Load Block` dxt line-parity swap (five `rdp` unit tests) | +| R-7 | The TMEM loads (T-32-002) cover **8/16/32-bit** texels for `Load Tile` and **8/16-bit** for `Load Block`. **There is no 4-bit texel *load*** — a 4-bit *texture-image* load is invalid on hardware (Angrylion sets `rdp_pipeline_crashed` and bails, `tex.c:526-533`); games load 4-bit textures by setting an **8-bit** texture image + 8-bit LOAD tile, loading half as many texels raw, then rendering with a **separate 4-bit tile** that extracts nibbles at fetch. That canonical path already works. Genuinely deferred: the **32-bit split** path of `Load Block`, and a *direct* 4-bit LOAD tile paired with an 8-bit texture image (the `ti_size`-vs-`tile.size` load granularity — Angrylion keys the copy stride off `ti_size` and the destination short-index off `tile.size` via `sshorts = s >> 2`) | The earlier "4-bit loading needs nibble addressing" framing was a **misconception**: 4-bit texels are never DMA'd as nibbles — the load is a raw byte stream sized by the texture image, and the nibble semantics live only in `fetch_texel` (already implemented for I4/IA4/CI4). RustyN64's bail on a 4-bit texture image thus *matches* the hardware's invalid-load behaviour. Writing nothing for the two genuinely-deferred cases keeps them falsifiable rather than emitting fabricated texels | absolute — a coverage boundary, not a fitted constant | **The canonical 4-bit path is now ORACLE-VALIDATED (2026-07-25).** `tex_tri_i4_16` (T-33-005) drives the full hardware idiom — 8 I4 texels packed two-per-byte, an **8-bit** `Load Tile`, then a **4-bit** (format I, size 0) render tile sampled across the triangle — and RustyN64's existing 8-bit load + 4-bit `fetch_texel` reproduce **Angrylion byte-for-byte**. Non-vacuous by construction: the eight intensities are DESCENDING and non-zero (texel 0 = white `0xFFFF`, not black), so an all-zero (no-op) load would render black and fail. Since the load is format-agnostic (a raw byte copy), this validates the load mechanism for **all** 4-bit formats; the I4/IA4/CI4 decoders are separately unit-tested. The two remaining gaps above are still `Open` (no runtime driver in n64-systemtest, so they do not change the suite-wide failing count — `docs/STATUS.md` is authoritative for it), to be pinned by further conformance vectors. The supported sizes remain byte-exact against hand-computed expectations, including the odd-row 32-bit-word swap, the 32-bit `Load Tile` split, and the `Load Block` dxt line-parity swap (five `rdp` unit tests) | | R-5 | VI scan-out (T-31-004) is a **1:1 copy** — `VI_X_SCALE`/`VI_Y_SCALE` resampling and the AA / divot / de-dither post-filters are not applied, and the height is derived directly from `VI_V_VIDEO`'s active half-lines rather than from the scale-accumulated framebuffer walk | The framebuffer→RGBA8 pixel conversion is exact and cited: the pixel *format* is selected by `VI_CTRL.TYPE[1:0]` (N64brew *Video Interface* §VI_CTRL — 2 = RGBA5551, 3 = RGBA8888), the RGBA5551 bit layout (R[15:11] G[10:6] B[5:1] A[0]) is the N64 16-bit colour format (N64brew *Reality Display Processor/Commands* §Set Color Image, texture/format enum; *Video DAC*), and the 5→8-bit widening by high-bit replication is the standard N64 convention (the value the VI DAC emits). What is **deferred**: the geometric resampling — the VI accumulates a sub-pixel step of `VI_X_SCALE`/`VI_Y_SCALE` per pixel/line (N64brew *Video Interface* §VI_X_SCALE, §VI_Y_SCALE) — and the analog post-filters `AA_MODE`/`DIVOT_ENABLE`/de-dither (§VI_CTRL), which only matter once scaled or anti-aliased content is scanned | absolute — a resampling/filter geometry choice, not a timing interval | **Open.** Byte-exact for a 1:1, unfiltered scan of a framebuffer whose width matches `VI_WIDTH` — which is what the FILL pipeline produces and the T-31-004 unit tests pin. Scaling and the post-filters **will be** validated bit-for-bit against Angrylion via the ParaLLEl-RDP fuzz suite / VI golden frames (Sprint 3), and superseded here if they diverge — this entry stays open until then. **n64-systemtest impact: not measured** — `Bus::scanout` has no runtime driver (nothing in the run loop calls it), so it is unreachable by the suite and cannot change the count, which stands at 93 | | R-4 | The VI register file (T-31-004) stores the **full 32-bit value** written to each register; the per-register write masks the hardware enforces (`VI_ORIGIN` 24-bit, `VI_WIDTH` 12-bit, `VI_V_INTR` 10-bit, the multi-field `VI_CTRL`/`VI_H_VIDEO`/scale registers, …) are not applied | The masks are documented as *field widths* in N64brew *Video Interface* per register, but the exact discard behaviour on write (which reserved bits read back 0 vs. retain) is what n64-systemtest's VI-register group actually pins, and that has not been run against a masked implementation | absolute — a register-decode fact, not a timing interval | **Open.** In-range writes (every value the register's own fields can hold) round-trip correctly, which the T-31-004 unit tests pin; out-of-range bits are retained rather than dropped. To be measured against n64-systemtest's VI group and masked per register when that group is exercised (measure, don't guess). No assertion currently exercises it (count unchanged at 93) | | R-3 | FILL-mode `Fill Rectangle` (T-31-003) rasterises the rectangle with an **inclusive lower-right pixel** — floor the upper-left, and draw through the pixel that *contains* the lower-right coordinate; in FILL/COPY mode the low two bits of `yl` are forced set before the shift (`yl \| 3`) so the final scanline fills whole. The **scissor** clips separately with an **asymmetric** lower-right (inclusive X, exclusive Y — see R-15) | The N64brew wiki says only "upper-left rounded down, lower-right rounded up"; the exact edge behaviour is what the Angrylion oracle pins, and it is *inclusive of the lower-right pixel* (a rect `(0,3)-(1,4)` draws columns 0 **and** 1), plus the FILL/COPY `yl \| 3` (Angrylion `rasterizer.c` `rdp_fill_rect`). The earlier `(coord + 3) >> 2` half-open span dropped the boundary row/column — a realisation of the cited prose that disagreed with the hardware. Read from the oracle's output, not computed | absolute — a rasterisation geometry rule, not a timing interval, so the differential/re-phasing test is N/A | **Closed for the integer-coordinate FILL rect (oracle-validated).** The inclusive lower-right + `yl \| 3` rule is now pinned bit-for-bit against Angrylion by the **seeded-fuzz corpus** (`tests/vectors/fuzz/`, 48 random FILL rectangles sweeping colour/size/position with a full-image scissor, all byte-exact) and a mutation-checked unit test (`fill_rectangle_lower_right_edge_is_inclusive`). The fuzz gate **found** the pre-fix off-by-one. Still unverified: **sub-pixel** (fractional-coordinate) rect edges, which the whole-pixel fuzz does not exercise; the **scissor** lower-right rounding is resolved separately (**R-15**, asymmetric X/Y). No n64-systemtest assertion drives the render path (count unchanged at 93) | From 30e345b9e5abba42506db02bce680f75f3faa9f0 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sat, 25 Jul 2026 03:45:43 -0400 Subject: [PATCH 3/3] docs(rdp): reconcile the two remaining 4-bit-loading mentions (CodeRabbit) Two later spots in rdp.md still said generic '4-bit loading (nibble Load Tile/ Load Block) pending R-7', conflicting with the corrected model earlier in the same file. Reconcile both to the accurate statement: there is no 4-bit load; 4-bit textures load as 8-bit + render with a 4-bit tile (validated by tex_tri_i4_16), and only the 32-bit Load Block split + a direct 4-bit LOAD tile remain deferred. Co-Authored-By: Claude Opus 4.8 --- docs/rdp.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/rdp.md b/docs/rdp.md index 0e5beebf..6712db39 100644 --- a/docs/rdp.md +++ b/docs/rdp.md @@ -344,8 +344,11 @@ matched to the ParaLLEl-RDP read layout (`texture.h`, MIT). **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 ParaLLEl-RDP applies to its host-word storage are intentionally absent on both the load and -fetch sides. **YUV16** decode is deferred (no oracle test needs it this sprint); **4-bit -loading** (nibble `Load Tile`/`Load Block`) remains R-7, though 4-bit *fetch* is done. The +fetch sides. **YUV16** decode is deferred (no oracle test needs it this sprint). There is +**no 4-bit texel *load*** (a 4-bit texture-image load is invalid on hardware; 4-bit textures +load as 8-bit and render with a separate 4-bit tile — validated against Angrylion by +`tex_tri_i4_16`, R-7); only the 32-bit `Load Block` split and a direct 4-bit LOAD tile with an +8-bit texture image are still deferred. 4-bit *fetch* is done. The oracle count stays **93** — `fetch_texel` now has runtime callers (the texture rectangle, T-32-004, and the textured triangle, T-33-004 2b-texture), but no systemtest drives the render path. @@ -602,8 +605,8 @@ the rest is still marked TODO: **loaded** by `Load Tile` / `Load Block` (T-32-002) with the odd-row swap and the 32-bit split, its palettes by `Load TLUT` (T-32-003) into the upper half, and **decoded** to RGBA8888 by `fetch_texel` (T-32-003): RGBA16/32, IA16/8/4, I8/4, - CI8/4 (via TLUT). Formats per `ref-docs/research-report.md` §4. YUV16 and 4-bit - loading pending (R-7). + CI8/4 (via TLUT). Formats per `ref-docs/research-report.md` §4. YUV16 decode + pending; 4-bit textures load as 8-bit and render with a 4-bit tile (validated, R-7). - **8 tile descriptors** — format, size, line stride, TMEM address, palette, clamp/mirror + mask/shift per S/T axis, and the tile-size coords (**present**, T-32-001). Set by `Set Tile` (0x35) and `Set Tile Size` (0x32).