Files
goldeneye-pc-port/docs/dev/TEXTURE-GLITCH-ANALYSIS.md
T
James Dansereau fa54f8fb90 docs: add operating manual, fix cross-references after doc restructure
- Commit CLAUDE.md + AGENTS.md so a fresh clone has the operating manual and
  the ABI/no-game-logic rules (trimmed of stale phase status; the personal
  pd_port path removed; points at the split docs).
- Sweep every stale doc reference across the tree after the restructure:
  PCPortResearch.md -> internals.md / dev/findings.md,
  PORT-LEARNINGS.md -> porting-notes.md,
  AGENT-WORKFLOW.md -> dev-process.md,
  docs/{GRAPHICS-BACKLOG,HANDOFF-ARCHIVE,GE-ENV-PROBES,LEVEL-*,TEXTURE-GLITCH-ANALYSIS,AUDIT-M6-*,AUDIO-PLAN}.md -> docs/dev/.
- Drop the superseded docs/PLAN-linear-level-sweep.md.
- docs/HANDOFF.md + docs/BACKLOG.md stay local-only (rolling working files);
  CLAUDE.md now handles their absence in a fresh clone.
2026-09-01 20:01:09 -04:00

21 KiB
Raw Blame History

Texture Glitch Analysis — PC Port

Original diagnosis: 2026-08-29 (read-only) · Status table refreshed: 2026-08-31 (M-31) Companion artifacts: <local-path> (outside repo).


0. Current status (M-31) — READ THIS FIRST

M-33 overlay (D168): every GE_PCDUMP / F12 screenshot this doc's analysis relied on was vertically flipped — the PPM writer never reversed glReadPixels rows. This is what the D114/D116 "HUD/text X-mirror" finding actually was (now closed, not a bug). It also contaminates the RC1 row below: the wallet-Bond photo's "180°-rotated / mis-transformed" description was made from an upside-down capture, so at least the vertical component is the capture bug, not the model transform. Re-judge RC1 from a fresh right-way-up capture before doing any more work on it. Writer fixed (port/fast3d/gfx_opengl.cpp).

The 2026-08-29 body below is the original diagnosis. Several root causes have since been fixed or retracted. Authoritative current state:

RC / bug 2026-08-29 verdict Current status (M-31) Where
RC2 — mip-chain contamination (LOD rows uploaded as image rows) open FIXED — import_texture() clips a full-width LOD block to the SETTILESIZE base height; GL builds correct mips. Knob Video.FixMipTextures (default on; =0 = old byte-identical-to-golden behaviour). port/fast3d/gfx_pc.cpp; §F RC2
RC4 — palette "off-by-one" in palette_to_rgba32 proposed 1-line fix RETRACTED — the current decode is correct RGBA5551; the "spec" line in §2 was ARGB1555. Applying the "fix" rotates every channel + moves alpha. Do not touch. gfx_pc.cpp:~835; §2 RC4, §F
D159 ("interlaced" / venetian-blind textures, esp. front-end photos) not yet identified FIXED — texSwapAltRowBytes (N64 RDP odd-line TMEM XOR compensation, which fast3d does not emulate) is a #ifdef PORT no-op. This was the user's "interlaced textures" report. src/game/image.c; §F D159
D161 — Depot (-level_30) ceiling blue speckle + radial rays (was §3 "blue ground" / B2) attributed to RC2+RC3+RC4+fog FIXED — a 16×16 CI8 tile drawn with G_TT_NONE (TLUT disabled) was palette-looked-up against a stale rdp.palette. fast3d now routes CI+G_TT_NONE → I (intensity). Not RC2/RC3/RC4/filtering. port/fast3d/gfx_pc.cpp import_texture(); §F D161, porting-notes.md §D
RC1 — wallet-Bond photo (mode-select / folder screen) garble "implement G_SETTEX" PARTIALLY OPEN. The interlace comb is gone (D159). The photo now renders as a recognisable grayscale portrait but is still 180°-rotated / mis-transformed → D75 front-end-model family (model-GDL transform, not a fast3d opcode gap — texLoadFromGdl already expands G_SETTEX on PC, see §6b). Parked, cosmetic, below crash work. §6b; GRAPHICS-BACKLOG D149; §F D75/D80/D82/D83
RC3 — wrap-period mismatch (non-PoT dims "repeat oddly") open IN PROGRESS. The dead D74 pre-wrap block is now opt-in as Video.WrapFix (M-29, not default — the naive one-liner activates never-run code and boot-crashes -level_09; needs the hoist-out-of-vertex-loop rework + per-level visual check). gfx_pc.cpp; GRAPHICS-BACKLOG B1; §F D74
RC1 (original) — G_SETTEX (0xc0) is G_NOOP in fast3d "implement the command" NOT the blocker. src/game/tex.c texLoadFromGdl already expands every G_SETTEX into a standard load sequence under #ifdef PORT; fast3d normally never sees raw 0xc0. See §6b. §6b

Everything from ## 1. down is the original 2026-08-29 text, left intact for the reasoning trail. Cross-check any "open / pending / proposed" wording there against this table.

1. Reported symptoms

  1. In general: textures repeat oddly with weird artifacting.
  2. Depot (-level_30): loads "completely wrong" textures — the ground is drawn a glitched blue that is not right (visible in attract mode).
  3. Character models: obvious texture artifacts during the credits roll.

2. Root causes (confirmed by code + ROM evidence)

M-31 status overlay (see §0): RC2 FIXED (Video.FixMipTextures), RC4 RETRACTED, RC1 superseded — the "interlaced" symptom was D159 (texSwapAltRowBytes), now FIXED; the Depot "blue ground" was D161 (CI8 + G_TT_NONE), now FIXED. RC3 in progress (Video.WrapFix, opt-in). RC1 residual = 180°-rotated front-end photo = D75 family, open.

# 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 — not the blocker: texLoadFromGdl already expands it on PC (§6b); residual = D75 model transform
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 FIXED — clip LOD block to base-tile height; Video.FixMipTextures default on
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 DONE (D167), behind Video.WrapFix default OFF — mask-based wrap period in the hoisted pre-wrap block; needs a human eyeball on Depot before default-on. See §6d
RC4 Palette off-by-one in palette_to_rgba32 — RETRACTED (M-30): current decode is correct RGBA5551 port/fast3d/gfx_pc.cpp:835 nothing — the "spec" line was ARGB1555 do not change

RC1 — G_SETTEX no-op (character artifacts)

  • GE's gbi_extension.h defines opcode 0xc0 as G_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: PwalletbondZ 2830, CbaronsamediZ 976, GautoshotZ 975).
  • Worked example CheadbrosnanZ: 47 G_SETTEX commands; texture_ids (1624–1626 = 32×64, 1607 = 64×32, 1608 = 1×1 null) exactly match the model's Textures[] 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 one gDPLoadBlock carries 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-disabled GL_LINEAR_MIPMAP_LINEAR min-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). GL GL_REPEAT wraps 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_WRAP but G_TX_WRAP == 0 (include/PR/gbi.h:391) — dead code (already parked in docs/dev/GRAPHICS-BACKLOG.md).

RC4 — Palette off-by-one — RETRACTED (M-30): the current code is correct.

The "N64 RGBA16 spec" line below is ARGB1555, which is not what the N64 uses. N64 G_IM_FMT_RGBA / G_IM_SIZ_16b is RGBA5551: RRRRR GGGGG BBBBB A (R = bits 15-11, G = 10-6, B = 5-1, A = bit 0).

// fast3d (current, CORRECT): r = v>>11; g = (v>>6)&0x1f; b = (v>>1)&0x1f; a = v&1
// the "spec" line here was wrong: r = (v>>10)&0x1f; g = (v>>5)&0x1f; b = v&0x1f; a = v>>15

Both palette_to_rgba32 (gfx_pc.cpp:~835) and import_texture_rgba16 (~651) use the same RGBA5551 decode and agree with each other. The palette is PD_BE16-swapped at load (gfx_dp_load_tlut), so palentry is already correctly ordered. Applying the "fix" would rotate every channel and move alpha to the wrong bit — do not do it. If a hue problem is ever confirmed in-game, it is upstream (TLUT byte order at the converter, or a wrong rdp.palette_fmt), not this function.

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-full events. The floor is not sampling a garbage pointer.
    • Broken decompression: texInflateZlib/texInflateNonZlib are faithful decompilations; the format was independently re-derived offline (§4) and decodes byte-exact.
  • 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.
  • 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.def cumulative 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-points compptr += (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 header 0x11 0x72 + raw deflate (GE's src/game/zlib.c is 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_LOADTLUT uploads it.
  • u=0 + lod≥2: mips are generated at load by texShrinkPaletted (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 texInflateZlib on 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 — ORIGINAL (2026-08-29); see §0 for what actually shipped

M-31 outcome: item 2 (mip) shipped as Video.FixMipTextures; item 4 (palette) retracted; the "interlace" was texSwapAltRowBytes not on this list (D159, fixed); the Depot roof was CI8+G_TT_NONE not this list (D161, fixed); item 5 (dump hook) shipped as GE_DTEX + GE_TEXDUMP. Items 1 (G_SETTEX) and 3 (wrap) not done as written — see §0 / §6b.

  1. G_SETTEX (gfx_pc.cpp dispatch): rebind render tile to the texpool entry for texture_id per gmain.s semantics. Biggest single visual win (characters).
  2. Mip contamination: upload base level only in gfx_dp_load_block/import_texture_*; drop glGenerateMipmap and the MIPMAP_LINEAR min-filter default. Fixes Depot floor noise.
  3. 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.
  4. Palette off-by-one (gfx_pc.cpp:835): one-line-class fix per channel.
  5. 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/internals.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 case add. src/game/tex.c texLoadFromGdl already expands every G_SETTEX (opcode 0xc0 == G_NOOP slot) in a model GDL into a full standard load sequence (texLoadFromTextureNum + texFindInPool + texWriteTextureCmd) written into a replacement GDL, and src/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 into 0xfafbfbfc… 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.height was removed because it "dropped mip chains" and "truncated sub-tiled textures" (Rare-logo D_02005FF0 20×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 is rdp.tex_lod (passed to every import_texture_* as gen_mipmaps): when LOD is on and size_bytes/row > tile_height, the excess is mip data → clamp height to ((lrt-ult)>>2)+1 from rdp.texture_tile[tile] and pass gen_mipmaps=false (don't let GL build mips from the stacked image). When rdp.tex_lod is 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.

6c. B2 Depot ceiling — ROOT-CAUSED + FIXED (D161, M-31)

The blue-speckle + radial-ray roof was not RC2/RC3/RC4/filtering. GE_TEXDUMP (PPM dump of every uploaded texture + a fmt/siz/palfmt/palidx/pal[0..3] log line, both env-gated, in gfx_pc.cpp/gfx_opengl.cpp) identified the surface as one 16×16 CI8 texture uploaded with rdp.palette_fmt == G_TT_NONE — every other CI texture that frame had palfmt == 0x8000 (RGBA16). GE draws the Depot roof with gsDPSetTextureLUT(G_TT_NONE): a CI-siz tile with the TLUT disabled is a legit N64 idiom meaning "use the raw 8-bit texel as intensity" (≈ I8). import_texture() ignored palette_fmt for fmt==CI and did a palette lookup against a stale rdp.palette → 16×16 of blue/magenta noise; the "rays" were that noise aliasing on the receding ceiling plane (hence filtering never helped).

Fix: gfx_pc.cpp import_texture() — fmt_eff = (fmt==CI && palette_fmt== G_TT_NONE) ? I : fmt, dispatch on fmt_eff (CI4→I4, CI8→I8). Narrow (only touches currently-garbage surfaces). Depot corridor + control-room ceilings now render as clean dark/grey industrial roofs; -level_09/-30/-34 sweep PASS. Full write-up: §F D161.

6d. RC3 non-PoT wrap period — IMPLEMENTED (D167, M-31), default OFF

gfx_dp_set_tile now stores masks/maskt on the tile (was dropped). The hoisted per-texunit pre-wrap block in gfx_sp_tri1 (the reworked D74 block — already lifted out of the vertex loop at M-30, indexed by texunit t) gained a branch: when the render tile is WRAP (no CLAMP/MIRROR) and 1<<mask != tex_width, fold the UV at the N64 period 1<<mask, then clamp the [dim, 1<<mask) overflow band (no real texels — a TMEM smear on console) to the last texel. All behind the existing Video.WrapFix knob (GE_WRAPFIX env overrides it for testing).

Captures (-level_09/-30/-34/-20, WrapFix OFF vs ON, GE_PCDUMP 6-frame windows): no crashes, 6/6 frames each; Silo ~pixel-identical (phash 0–11), Facility 180–260 pixel-identical, Depot shows small localized texel changes on ceiling/wall cells (dmean 5–9, no structural break). Large per-run frame deltas are all D117 intro-camera-pan nondeterminism.

Default kept OFF — no regression, but a headless structural diff can't confirm the Depot ceiling looks better. To finish RC3: run Depot with Video.WrapFix=1 and eyeball the corrugated roof / repeating wall panels vs default; flip the default in port/src/video.c (cfgWrapFix = 1) if clean. The overflow-band clamp is an approximation of console TMEM-smear behaviour, not an exact emulation.

7. Artifacts (in <local-path>, 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)