Reported: menu / room textures look 'interlaced' (alternating garbage rows). Root cause (docs/TEXTURE-GLITCH-ANALYSIS.md RC2): GE's texGetDepthAndSize() packs base level + every LOD mip into one gDPLoadBlock, so fast3d's upload height (size_bytes / row) runs ~1.3x taller than the base image and the mip bytes render as rows below it. GE_DTEX=1 confirmed it: 64x64 I4 uploaded 64x87, 65x65 -> 65x95, etc. Fix in import_texture(): when LOD is active (rdp.tex_lod) and the load is a plain full-width block (not a windowed gfx_dp_load_tile - the D74 sub-tile / Rare-logo case, which is lod=0), clip size_bytes to the SETTILESIZE base-tile height. GL then builds correct mips from a correct base. New knob Video.FixMipTextures (default 1; =0 restores old upload, verified byte-identical to golden -level_09 3/3). BUNKER1 with the fix on: side-wall textures visibly cleaner, geometry/HUD unchanged; Silo unregressed. -level_09/-level_20 framediff WILL flag with the fix on (textures changed for the better) - refresh golden after the user confirms the menu + Depot look right. New GE_DTEX=1 env probe logs load params for the first 64 textures.
14 KiB
Texture Glitch Analysis — PC Port (read-only investigation)
Date: 2026-08-29 · Status: diagnosis complete, fixes pending (session was read-only)
Supersedes nothing — feeds the Dxx entries proposed in §7. Companion artifacts: CAPTURES_DIR (outside repo).
1. Reported symptoms
- In general: textures repeat oddly with weird artifacting.
- Depot (
-level_30): loads "completely wrong" textures — the ground is drawn a glitched blue that is not right (visible in attract mode). - Character models: obvious texture artifacts during the credits roll.
2. Root causes (confirmed by code + ROM evidence)
| # | Cause | Location | Explains | Fix class |
|---|---|---|---|---|
| RC1 | G_SETTEX (0xc0) is a no-op in fast3d |
port/fast3d/gfx_pc.cpp:2443 (case G_NOOP) |
#3 character/model artifacts | implement the command |
| RC2 | Mip-chain contamination: base + all LODs uploaded as one GL image | src/game/tex.c:330 (texGetDepthAndSize sums all LODs) → gfx_dp_load_block/import_texture_* |
#1, #2 noise bands, wrong scale, minification garbage | upload base level only; disable GL mips |
| RC3 | Wrong wrap period: N64 wraps at 2^ceil(log2(dim)), GL wraps at uploaded image size; D74 pre-wrap block is dead (G_TX_WRAP == 0, include/PR/gbi.h:391) |
port/fast3d/gfx_pc.cpp UV math in gfx_sp_tri1 + D74 guard |
#1 "repeats oddly", squash/stretch on non-PoT dims | mask-based wrap period (fix D74 guard, extend to T axis) |
| RC4 | Palette off-by-one in palette_to_rgba32 |
port/fast3d/gfx_pc.cpp:835 |
subtle hue shift on all CI textures (grays survive; red→orange-ish, blue→cyan-ish) | 1-line-class fix per channel |
RC1 — G_SETTEX no-op (character artifacts)
- GE's
gbi_extension.hdefines opcode 0xc0 asG_SETTEX(gsSPUseTexture):w0[3:0]=type, [9:6]=shiftt, [13:10]=shifts, [17:16]=tile, [19:18]=cmt, [21:20]=cms;w1 = minlevel<<24 | detail_id<<12 | texture_id. - Full ROM scan: 508 of 512 model files contain G_SETTEX — 43,314 commands total
(top emitters:
PwalletbondZ2830,CbaronsamediZ976,GautoshotZ975). - Worked example
CheadbrosnanZ: 47 G_SETTEX commands; texture_ids (1624–1626 = 32×64, 1607 = 64×32, 1608 = 1×1 null) exactly match the model'sTextures[]table; the GDL interleaves them per mesh part. All use tile=2 (G_TX_RENDERTILE). - On N64, gmain.s dispatches 0xc0 via the jump table at offset 0xbc (top-2-bits bucket 3);
it rebinds the render tile to the texture identified by
texture_id. - Our fast3d hits
case G_NOOP: break;— every mesh part renders with stale tile state → wrong/shifted textures on characters. This is why the credits roll (many models, many texture switches) shows obvious artifacts. - The in-code comment at
gfx_pc.cpp:2443("the game never emits it") is wrong for GE — presumably inherited from PD assumptions. The PD port also no-ops 0xc0, so there is no reference implementation to copy; implement from gmain.s + gbi_extension.h.
RC2 — Mip-chain contamination (floor/room noise)
texGetDepthAndSize()(src/game/tex.c:330) sums all LOD levels, so onegDPLoadBlockcarries base + full mip chain. fast3d uploads that as a single GL image: a 65×65 floor texture becomes ~65×134, rows below the base being raw mip bytes.- GL then wraps at the uploaded size and
glGenerateMipmap+ the never-disabledGL_LINEAR_MIPMAP_LINEARmin-filter (gfx_opengl.cpp:59) sample mips of a garbage-stacked image. Distant surfaces (attract-mode camera!) minify into noise. - ~75% of Depot's textures carry full mip chains (lod 6–7), so this is maximal there.
- N64 per-LOD tile selection is already emulated by the D107 rule (always base tile, offset 0) — GL mips are pure poison in this port; disable them.
RC3 — Wrap period mismatch ("repeats oddly")
- N64 wraps at the tile mask period
2^ceil(log2(dim))(texDimensionToMask,src/game/tex.c:361). GLGL_REPEATwraps at actual image size. - Depot's large room surfaces are non-power-of-two: 65×65, 56×56, 96×48, 128×32 (I4/IA4, non-zlib). E.g. 65 wide: N64 period 128 vs GL period 65 → pattern squashed 2× horizontally; with RC2 the vertical period is ~2× too long and half of it is noise.
- The D74 pre-wrap UV block never runs: guard tests
cms & G_TX_WRAPbutG_TX_WRAP == 0(include/PR/gbi.h:391) — dead code (already parked indocs/GRAPHICS-BACKLOG.md).
RC4 — Palette off-by-one (subtle global hue shift)
port/fast3d/gfx_pc.cpp:835:
// fast3d (current): r = v>>11; g = (v>>6)&0x1f; b = (v>>1)&0x1f; a = v&1
// N64 RGBA16 spec: r = (v>>10)&0x1f; g = (v>>5)&0x1f; b = v&0x1f; a = v>>15
Channels are read one bit off (alpha treated as LSB). Grays (r≈g≈b) survive nearly intact —
which is why BUNKER1 looks "mostly right" (it's gray/brown) — but saturated hues shift.
Format mapping itself was verified correct: TEXFORMAT_RGBA16_CI8/CI4 → G_TT_RGBA16,
IA16_CI8/CI4 → G_TT_IA16 (src/game/image.c:71–104).
3. Depot-specific analysis ("why blue ground, only there?")
Depot is not special — it is the worst case of RC2+RC3+RC4.
- Ruled out with data:
- Texpool exhaustion / D85 placeholder path: a real Depot load (env-gated log) shows
342 unique texture loads, ~326 KB compressed into the 450 KB stage pool, zero
D85TEX pool-fullevents. The floor is not sampling a garbage pointer. - Broken decompression:
texInflateZlib/texInflateNonZlibare faithful decompilations; the format was independently re-derived offline (§4) and decodes byte-exact.
- Texpool exhaustion / D85 placeholder path: a real Depot load (env-gated log) shows
342 unique texture loads, ~326 KB compressed into the 450 KB stage pool, zero
- What Depot's floor actually is (decoded from ROM): non-zlib I4/IA4 grayscale with full 7-level mip chains and non-PoT dimensions (texnums incl. 85/86, 508, 618, 631, 779–782, 836, 909–911, 989–996). Exactly the class RC2+RC3 mangle hardest.
- The blue: nothing in Depot's texture set is actually blue (decoded color stats are all
industrial grays/browns, e.g. avg rgb 107/107/108, 147/117/112). The perceived blue =
garbage I4 values sampled under minification (attract camera is far → mip-filtered noise)
- Depot's fog table (
src/game/bgfog.c, LEVELID_DEPOT entry) tinting distant surfaces cold. RC4 then shifts whatever hue survives.
- Depot's fog table (
- BUNKER1 "renders recognisably" because its surfaces hit the same bugs less severely (PoT dims / fewer visible garbage rows at typical camera distances).
4. Texture pipeline reference (learned during this investigation)
Useful ground truth for whoever implements the fixes:
- ROM layout:
g_Textures[N]= segment offset; texture N occupies[g_Textures[N], g_Textures[N+1])in the images segment (_imagesSegmentRomStart, ROM file offset 0x8F7DF0 in the NTSC dump).assets/images.defcumulative sizes match the logged runtime offsets exactly (0/342 mismatch) — verified against the D66 log. texLoad(src/game/image.c:~2450) copies from(thisoffset & ~15)and re-pointscompptr += (thisoffset & 7); empirically every blob decodes at the listed offset (+0).- Blob header byte:
u(1) z(1) lod(6).z=1→ zlib path, else per-image format(4)/w(8)/h(8)/comp(4) + Huffman/RLE/lookup payloads. - Zlib path (
texInflateZlib,image.c:169): MSB-first bitstream — format(8), numcolours(8)+1, palette nc×16; per image: width(8), height(8), then a rarezip stream = 2-byte header0x11 0x72+ raw deflate (GE'ssrc/game/zlib.cis stock gzip-1.2.4 inflate). Output rows are stored tight;texAlignIndices(image.c:340) repacks to 8-byte-aligned rows (CI8: 1 B/px; CI4: 1 B/2 px, high nibble first). texSwapAltRowBytes(image.c:2191) is applied to every level of every texture on every code path (calls at 260, 300, 312, 316): odd rows get adjacent u32 groups pairwise swapped. The post-swap RAM buffer is what gets uploaded — any offline tool or dump must account for this.- Palette is appended to the buffer big-endian after all images;
G_LOADTLUTuploads it. u=0+ lod≥2: mips are generated at load bytexShrinkPaletted(2×2 palette average).
5. Open questions / hypotheses
- H-A (high confidence): Depot blue ground = RC2+RC3+RC4+fog, per §3. Confirm in-game after fixes; the before/after capture is the test.
- H-B (high confidence): credits-roll model artifacts = RC1. Confirm by implementing G_SETTEX and re-capturing the roll.
- H-C (unresolved, non-blocking): the offline contact sheet "still looks off" to the
reviewer despite stage-by-stage verification of the decoder (§4; exact byte-count matches,
verbatim function copies, clean odd/even row-boundary statistics). Possible explanations:
32×32 CI8 textures are inherently noisy at native res; small-cell presentation; or a
residual misunderstanding of the final layout. Fallback if it ever matters: a standalone C
harness running GE's actual
texInflateZlibon the ROM, byte-diffed against the Python decoder. Not on the critical path — the in-game fixes don't depend on it.
6. Fix plan (when read-only lifts), in priority order
- G_SETTEX (
gfx_pc.cppdispatch): rebind render tile to the texpool entry fortexture_idper gmain.s semantics. Biggest single visual win (characters). - Mip contamination: upload base level only in
gfx_dp_load_block/import_texture_*; dropglGenerateMipmapand theMIPMAP_LINEARmin-filter default. Fixes Depot floor noise. - Wrap period: wrap at the N64 mask period (
texDimensionToMask) — fix the dead D74 guard (cms == G_TX_WRAP), extend to the T axis and non-PoT dims. - Palette off-by-one (
gfx_pc.cpp:835): one-line-class fix per channel. - Verification hook: env-gated dump of the actual uploaded GL image + tile setup for the first N texLoads (~10 lines in fast3d) → one Depot run gives true ground truth and a before/after reference (also settles H-C if it ever resurfaces).
Each item gets its own Dxx entry in docs/PCPortResearch.md §F/§H at commit time
(proposed: next free labels after D126 — check the §F index first; a stray "D160" appears
once in the doc and may need reconciling).
6b. M-30 follow-up — implementation notes (before you touch RC2)
Re-checked against the current tree while chasing the user's "interlaced
textures" screenshot (screenshots/interlaced texture example.jpg — main menu,
wallet-Bond photo + right panel show alternating-row tearing).
- RC1 (G_SETTEX) is not a simple
caseadd.src/game/tex.c texLoadFromGdlalready expands every G_SETTEX (opcode 0xc0 ==G_NOOPslot) in a model GDL into a full standard load sequence (texLoadFromTextureNum+texFindInPool+texWriteTextureCmd) written into a replacement GDL, andsrc/game/objecthandler_2.c(~line 82) drives that per display list at model load +modelNodeReplaceGdls the node. It is#ifdef PORT-ported. So on PC the RSP/fast3d should normally not see raw G_SETTEX. The user's crash log showed fast3d walking into0xfafbfbfc…garbage (opcode 0x00, not 0xc0) — i.e. the model GDL pointer / expansion is broken for the front-end wallet models, not "an opcode is missing." This is the D75 front-end-model family (D80/D82/D83 model-GDL relocation,objecthandler_2.c), not a fast3d opcode gap. Implementing G_SETTEX in fast3d would be a fallback that only helps models that reach it with an otherwise-intact DL. - RC2 (mip contamination) — the naive height clamp was already tried and
reverted.
gfx_pc.cpp import_texture()~line 917 has a comment: a prior branch that set height =line * tile.heightwas removed because it "dropped mip chains" and "truncated sub-tiled textures" (Rare-logoD_02005FF020×3 tile uploaded as 32×3). So the fix must distinguish "extra rows are mip levels" (drop) from "tile is a window into a larger image" (keep). The signal isrdp.tex_lod(passed to everyimport_texture_*asgen_mipmaps): when LOD is on andsize_bytes/row > tile_height, the excess is mip data → clamp height to((lrt-ult)>>2)+1fromrdp.texture_tile[tile]and passgen_mipmaps=false(don't let GL build mips from the stacked image). Whenrdp.tex_lodis off, keep today's behaviour. Verify against the Rare logo (sub-tile case) + Depot floor (mip case) + BUNKER1 (regression). - The alternating-row look specifically may also be
texSwapAltRowBytes(image.c:2191, §4: applied to every level of every texture on every path — odd rows get adjacent u32 groups pairwise swapped). The game swaps the RAM buffer before upload, so fast3d should read the swapped bytes — confirm fast3d isn't reading a pre-swap copy, or double-swapping. This is the RenderDoc / §6-item-5 dump task; do that before editing.
Bottom line (M-30): #3 is not a grind-fix. Highest-leverage next step is
§6-item-5 (env-gated dump of the actual uploaded GL image + tile setup for the
first ~10 texLoads on the main menu and on Depot) to see whether the base image
is wrong (decode/swap) or just over-tall (RC2 height). Then RC2 per the
rdp.tex_lod-gated plan above. G_SETTEX / D75 front-end models are a separate,
larger track.
7. Artifacts (in CAPTURES_DIR, outside repo)
| File | What it is |
|---|---|
depot_d85.log |
Full Depot run with GE_D63=1 GE_D85TEX=1 — 342 texLoad records (texnum/thisoff/nextoff/size), zero pool-full events |
decode_depot_tex.py |
Offline rarezip/bitstream decoder (verified against §4) |
depot_texsheet.png |
Contact sheet, 342 tiles, labeled by texnum (zlib-decoded only; non-zlib I4/IA4 cells blank) |
depot_texstats.txt |
Per-texture format/size/lod/color stats for all 342 |
tiles_big.png, tile_*.png |
Individual full-size decodes (760, 313, 1283, 233, 1082, 21, 451, 7) |