diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 29738a44..df144cde 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -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. diff --git a/docs/porting-notes.md b/docs/porting-notes.md index 729ef510..d1a44af0 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -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. diff --git a/port/fast3d/gfx_opengl.cpp b/port/fast3d/gfx_opengl.cpp index d369758f..4177a203 100644 --- a/port/fast3d/gfx_opengl.cpp +++ b/port/fast3d/gfx_opengl.cpp @@ -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));"); diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index 129e364d..d064233f 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -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; } diff --git a/src/game/bg.c b/src/game/bg.c index 55fe4dfe..194124a5 100644 --- a/src/game/bg.c +++ b/src/game/bg.c @@ -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) ||