fix(render): land D266 IA4/texedge decode + D271 portal-culling fixes (M-139 R2)

R2 extraction from the parked D236 investigation tree
(park/d236-probes-m139): pulls out only the two verified, behavior-real
fixes and drops every D267-D280 debug probe (census counters, PPM
dumpers, per-tri/per-portal logging) that had accumulated alongside them.

- D266: import_texture_ia4 decodes RDP IA4 as I2:A2 (was I3:A1, an
  inherited PD assumption); gfx_opengl.cpp's CVG_X_ALPHA "texedge" path
  now keys off the real alphacompare field (dither/threshold/none)
  instead of a hardcoded 0.19 cutoff, and gfx_pc.cpp excludes AC_DECAL
  tiles from the texedge path entirely (it never discards on real
  hardware).
- D271: the D106 near-plane portal-straddling guard's `lim` constant
  widens from 1e5 to 1e38 so finite out-of-range bounds flow through to
  bgRectIntersect and clamp/cull exactly as they do on N64, instead of
  being force-mapped to full-screen (was drawing whole rooms N64 culls).

Checked D271 against D249 (distant-geometry drop-out on Streets/Egyptian)
before landing per M-139's overlap concern: no overlap, different code
paths (near-plane portal guard vs. far-plane/LOD symptom); D249 stays
open.

Verified: 242/242 clean build; full 21-level verify.sh sweep clean (one
dam NO-FRAMES on the sweep pass reproduced PASS on an immediate solo
re-run - a load-sensitivity flake, not a regression).

The D267-D280 probe code and the rest of the D236 tree-wall
investigation stay parked on park/d236-probes-m139 for whenever that
thread resumes (still no root cause for the tree wall itself - see
D280's negative outcome).

Also documents a build-environment gotcha hit this session: this Bash
tool's shell reports MINGW64 (uname/$MSYSTEM) but is actually
Git-for-Windows Bash, missing cmake/ninja/gcc from PATH until
/c/msys64/mingw64/bin and /c/msys64/usr/bin are prepended explicitly
(docs/porting-notes.md SS E).

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
This commit is contained in:
James Dansereau committed 2026-09-15 16:34:28 -04:00
1 parent e7fee0d7a4
commit 532092c732
5 files changed
+78 -17

No files matched your search

+4
View File
@@ -9929,6 +9929,8 @@ New env-gated census probe (`GE_D266`, in `gfx_sp_tri1`) logging per-draw tile f
**State: both fixes remain in-tree, UNCOMMITTED, with the probes.** They are necessary-but-unverified for the wall (the wall turned out to be a different texture class); the I2:A2 decode still owes a game-wide IA4 regression sweep before any commit. Nothing user-visible changed at the wall — consistent with D265's negative re-test.
**LANDED (M-140, R2 extraction, `fix/d266-d271-extraction` off `release/v0.3.0`):** the two real fixes only (IA4 I2:A2 decode + AC_DECAL-excluded texedge) extracted out of the parked D236 probe tree, stripped of every D267–D280 debug probe (census counters, PPM dumpers, per-tri logging — none of it behavior-affecting, but none of it belongs in a landed commit either). Full 21-level `verify.sh sweep` clean (20/21 PASS on first pass; the 21st, `dam`, came back NO-FRAMES/pixcount-0 on the sweep run but reproduced PASS on an immediate solo re-run — a load-sensitivity flake per the D117/D134 class, not a regression from this change). Build: 242/242, clean link. The D267–D280 probe code + everything else in the D236 investigation stays parked on `park/d236-probes-m139` for whenever the tree-instantiation thread resumes.
## D267 — M-126: IA8 dump probe + per-tile-slot upload-sequence tracking — the three IA8 images in play are an opaque noise card and two sparse mast sprites; none of them is the wall band
Probe infra (`GE_D267`, `gfx_pc.cpp`): PPM dumper + alpha histogram for 32px-wide IA8 strips in `import_texture_ia8`; per-tile-slot upload sequence tracking (`d267_seq` counter + `d267_slot_img[128]` stamped in the `import_texture()` dispatch) so tri-time probes can name which uploaded image a draw uses. Caveat discovered: `gfx_texture_cache_lookup()` returns early **before** the stamp on cache hits (key = fmt+addr+size+pal), so a draw's logged imgseq can be stale — the actual image is the earlier import that populated the cache (traceable via the GE_TEXI census log).
@@ -9982,6 +9984,8 @@ Texture content (`GE_TEXDUMP`/`GE_TEXRAW` + offline decode of `texdump/r*.bin`):
**OUTCOME (user re-test 2026-09-14): NEGATIVE.** With the fix in place the trees still render as one big wall — and the user's read is that **the texture itself isn't being mapped correctly**, not merely an over-draw/alpha problem. So suspect #3 was a real N64-divergent over-draw (kept — it is a fidelity fix, do not revert) but it is NOT the wall's root cause. The D265 note gains weight: *"all captured cards sampled the same u:0..64/v:0..16 window — confirm against N64"* — if every card samples the same region of a shared texture, the result is exactly a repeating sheet. Top suspect for the next pass: **per-card S/T coordinates / UV windowing** (model DL vertex S/T, tile setup foldS/foldT/tcShift, or `d43_emit.py`'s S/T conversion), then TEXTLOD/mip sampling, then palette.
**LANDED (M-140, R2 extraction, `fix/d266-d271-extraction` off `release/v0.3.0`):** just the one-constant fix (`lim = 100000.0f` → `1e38f`), stripped of the `GE_D271`/`GE_D104` debug probes (per-portal capture, hit counters) — none of it changes behaviour, none belongs in a landed commit. Checked against **D249** (distant-geometry drop-out on Streets/Egyptian, per M-139's R2 overlap concern) before landing: no overlap — D249 is a far-plane/LOD symptom on a different code path (open, unrelated), this fix is a near-plane portal-straddling guard; D249 stays open. Full 21-level `verify.sh sweep` clean post-extraction (see D266's landing note for the one dam/NO-FRAMES flake, confirmed non-regression on retry).
**Owed / next.**
1. **Per-card S/T audit** — dump the actual vertex S/T + tile for each wall card at draw time (extend `GE_D266W`) and check whether distinct cards carry distinct S/T windows; compare against the N64 ref (`docs/img/bugs/d227-surface-emulator-ref.png`: smooth bright band + sparse organic clusters, not a continuous sheet).
2. Regression sweep of other levels' room counts is cheap insurance (`GE_D104=1` spot-checks); BUNKER1 already re-verified above.
+22
View File
@@ -1203,3 +1203,25 @@ and turns every such overrun into a fatal `*** stack smashing detected ***`
one silently drifts from the other; when investigating a decode bug, diff
the suspect path against its sibling path for the same format before
re-deriving the bit layout from scratch. §F **D228**.
- **A Claude Code session's default `Bash` tool is NOT the MSYS2 MINGW64
shell the build needs, even when `uname -a`/`$MSYSTEM` claim it is (M-140).**
On this box the tool's shell is Git-for-Windows Bash (root mounted from
`C:/Program Files/Git`), which sets `MSYSTEM=MINGW64` and reports
`MINGW64_NT-...` from `uname` for compatibility — both of AGENTS.md's own
“are you in the right shell” checks pass while `cmake`/`ninja`/`gcc` are
still missing from `PATH` (they resolve at `/c/msys64/mingw64/bin` and
`/c/msys64/usr/bin`, not `/mingw64/bin` — that path exists but is a
Git-Bash stub, not the project's real MSYS2 install). Symptom if you build
anyway with only Git-Bash's own `gcc`/toolchain state: `cmake: command not
found`, or (if some `gcc.exe` further down PATH resolves) every compile
fails with `Cannot create temporary file in C:\Windows\: Permission
denied` — a native Windows exe's `GetTempPath` falling back to `C:\Windows`
because the child process doesn't actually see a usable `TMP`/`TEMP` the
way it does when launched from a real MSYS2 shell window. Fix: explicitly
`export PATH="/c/msys64/mingw64/bin:/c/msys64/usr/bin:$PATH"` (adjust if
MSYS2 is installed elsewhere) before `./build-pc.sh`/`ninja`/`cmake` in any
Claude Code Bash-tool session; do this once per session, first thing, not
reactively after a mystery failure. Also: `cd` with a *backslash* Windows
path (`cd C:\Users\...`) is unreliable in this same shell and can silently
mis-parse to a mangled path on a later command in the same tool call —
prefer `cd /c/Users/...` (forward slashes) for the build working directory.
+18 -2
View File
@@ -510,11 +510,27 @@ static struct ShaderProgram* gfx_opengl_create_and_load_new_shader(uint64_t shad
}
}
/* D266: CVG_X_ALPHA ("texedge") modelled on the RDP's actual semantics,
* keyed on the alphacompare field. The inherited line quantized to
* opaque/discard at a hardcoded 0.19: it kept texels the N64 discards and
* forced survivors fully opaque, defeating the ALPHA_CVG_SEL blend.
* GE's Surface 1 IA4 tree strip (D236/D265) rendered as a wall because of
* it. Now: AC_DITHER -> dithered discard; AC_THRESHOLD -> discard only
* fully-transparent; AC_NONE -> discard below 0x80 and let survivors keep
* their alpha for blending. (AC_DECAL never sets SHADER_OPT_TEXTURE_EDGE,
* see gfx_pc.cpp.) */
if (cc_features.opt_texture_edge && cc_features.opt_alpha) {
append_line(fs_buf, &fs_len, " if (texel.a > 0.19) texel.a = 1.0; else discard;");
if (cc_features.opt_noise) {
append_line(fs_buf, &fs_len,
" if (texel.a < ((random(vec3(floor(gl_FragCoord.xy * noise_scale), float(frame_count))) + 1.0) / 2.0)) discard;");
} else if (cc_features.opt_alpha_threshold) {
append_line(fs_buf, &fs_len, " if (texel.a < 1.0 / 256.0) discard;");
} else {
append_line(fs_buf, &fs_len, " if (texel.a < 0.5) discard;");
}
}
if (cc_features.opt_alpha && cc_features.opt_noise) {
if (cc_features.opt_alpha && cc_features.opt_noise && !cc_features.opt_texture_edge) {
append_line(fs_buf, &fs_len,
" texel.a *= floor(clamp(random(vec3(floor(gl_FragCoord.xy * noise_scale), float(frame_count))) + "
"texel.a, 0.0, 1.0));");
+16 -6
View File
@@ -44,6 +44,9 @@ uintptr_t gfxFramebuffer;
#define SCALE_4_8(VAL_) ((VAL_)*0x11)
#define SCALE_8_4(VAL_) ((VAL_) / 0x11)
#define SCALE_3_8(VAL_) ((VAL_)*0x24)
/* D266: RDP IA4 is I2:A2 (2-bit intensity, 2-bit alpha), not the I3:A1 the
* PD-derived importer assumed. */
#define SCALE_2_8(VAL_) ((VAL_)*0x55)
#define SCALE_8_3(VAL_) ((VAL_) / 0x24)
// SCREEN_WIDTH and SCREEN_HEIGHT are defined in the headerfile
@@ -724,13 +727,16 @@ static void import_texture_ia4(int tile, const LoadedTexture& loaded_texture, bo
for (uint32_t i = 0; i < size_bytes * 2; i++, dest += 4) {
const uint8_t byte = addr[i / 2];
const uint8_t part = (byte >> (4 - (i % 2) * 4)) & 0xf;
const uint8_t intensity = part >> 1;
const uint8_t alpha = part & 1;
const uint8_t c = SCALE_3_8(intensity);
/* D266: I2:A2 -- the hardware IA4 layout. The old I3:A1 read made the
* A2=1 canopy pixels fully opaque and the A2=2 edge dither invisible
* (D236/D265 tree "wall"). */
const uint8_t intensity = part >> 2;
const uint8_t alpha = part & 3;
const uint8_t c = SCALE_2_8(intensity);
dest[0] = c;
dest[1] = c;
dest[2] = c;
dest[3] = alpha ? 255 : 0;
dest[3] = SCALE_2_8(alpha);
}
const uint32_t width = rdp.texture_tile[tile].line_size_bytes * 2;
@@ -1650,7 +1656,11 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
bool use_alpha =
(rdp.other_mode_l & (3 << 20)) == (G_BL_CLR_MEM << 20) && (rdp.other_mode_l & (3 << 16)) == (G_BL_1MA << 16);
const bool use_fog = ((rdp.other_mode_l >> 30) == G_BL_CLR_FOG) || ((rdp.other_mode_l >> 26) == G_BL_A_FOG);
const bool texture_edge = (rdp.other_mode_l & CVG_X_ALPHA) == CVG_X_ALPHA;
/* D266: G_AC_DECAL (alphacompare == 2) never discards, so it must not
* take the texedge path at all; NONE/THRESHOLD/DITHER are distinguished
* in gfx_opengl.cpp via the existing options. */
const bool ac_decal = (rdp.other_mode_l & (3U << G_MDSFT_ALPHACOMPARE)) == (2U << G_MDSFT_ALPHACOMPARE);
const bool texture_edge = (rdp.other_mode_l & CVG_X_ALPHA) == CVG_X_ALPHA && !ac_decal;
const bool use_noise = (rdp.other_mode_l & (3U << G_MDSFT_ALPHACOMPARE)) == G_AC_DITHER;
const bool use_2cyc = (rdp.other_mode_h & (3U << G_MDSFT_CYCLETYPE)) == G_CYC_2CYCLE;
const bool alpha_threshold = (rdp.other_mode_l & (3U << G_MDSFT_ALPHACOMPARE)) == G_AC_THRESHOLD;
@@ -1659,7 +1669,7 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
const bool use_modulate = use_alpha && (rsp.extra_geometry_mode & G_MODULATE_EXT) != 0;
const bool use_blur = (rdp.other_mode_h & (3U << G_MDSFT_TEXTFILT)) == G_TF_BLUR_EXT;
if (texture_edge) {
if ((rdp.other_mode_l & CVG_X_ALPHA) == CVG_X_ALPHA) {
use_alpha = true;
}
+18 -9
View File
@@ -1722,16 +1722,25 @@ s32 sub_GAME_7F0B5864(s32 portalnum, bbox2d *bbox)
{
s32 degenerate = (bounds.max.x <= bounds.min.x) || (bounds.max.y <= bounds.min.y);
#ifdef PORT
/* D106: a portal straddling the camera near-plane feeds z==0 clip
* points into transform3Dto2DWithZScaling, whose inv_z = -1e20 sends
* them to +/-1e20-scale screen coords. On N64 those bracket the view
* symmetrically so the box just clamps to full screen downstream;
* on PC the exact garbage can come back min>max on only one axis, or
* non-finite, slipping past the check above and culling the room the
* player is walking toward. Any wildly out-of-range / non-finite
* bound means "portal fills the view" -> full screensize. */
/* D106 (revised by D271): a portal straddling the camera near-plane
* feeds z==0 clip points into transform3Dto2DWithZScaling, whose
* inv_z = -1e20 sends them to +/-1e20-scale screen coords. On N64
* those huge FINITE values flow straight into bgRectIntersect (pure
* comparisons), which either clamps them to the parent/screen box
* (portal covers the view -> room kept) or yields an empty box
* (the portal's visible projection falls off-screen -> room CULLED).
* The original D106 guard mapped ANY out-of-range bound to full
* screen, which over-included the second case: at Surface 1's boot
* view, 9 of 11 near-plane-straddling portals produce garbage that
* clamps to an empty box (N64 culls the room) but was forced
* full-screen here, drawing whole rooms N64 never submits. Only
* NON-FINITE bounds are unsafe to let through (NaN poisons the
* comparisons downstream); this projection can reach ~1e26 at most,
* so lim=1e38 catches NaN/inf and nothing finite. Finite min>max is
* already caught by the `degenerate` check above, which is the N64
* code path itself. */
{
const f32 lim = 100000.0f;
const f32 lim = 1e38f;
if (!(bounds.min.x > -lim && bounds.min.x < lim) ||
!(bounds.min.y > -lim && bounds.min.y < lim) ||
!(bounds.max.x > -lim && bounds.max.x < lim) ||