diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md index a57d9be9..0ddd9134 100644 --- a/docs/dev/GE-ENV-PROBES.md +++ b/docs/dev/GE-ENV-PROBES.md @@ -72,6 +72,7 @@ Two classes: | `GE_D85DUMP` | `src/game/bg.c:2510` | D85 `bgWidenRoomGdl` 8→16B dump | dead (D85 closed) | | `GE_D85TEX` | `src/game/image.c:2479`, `src/game/tex.c:859,1050` | D85 texpool-full events (Depot analysis) | dead (D85 closed) — was useful for §3 | | `GE_D217TEX` | `src/game/image.c:234` (`texInflateZlib`) | D217: logs `texnum`/`format`/`numcolours`/`palette[0..1]` straight off the decode bitstream (before any arena/pointer math) — the decode-correctness check that proved the affected texture (grip, `texnum=1608`) decodes byte-identically across levels, ruling out a decode/ROM-data bug. | **live** (D217 open — investigation instrument) | +| `GE_D229` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri` dual-texunit probe + `import_texture`) | D229: per-draw dump of both texunits' tile/tmem/fmt/siz/UV state for 2-cycle draws, plus a one-shot confirm line when the RGBA16-over-CI8 import remap fires (the fix path). Inert by default. | **live** (D229 fixed — probe kept for the Surface 2 / sweep verification) | | `GE_D86` | `src/game/model.c:6295`, `src/game/objecthandler_2.c:143` | D86 model rwdata trace | dead (D86 closed) | | `GE_D87` | `src/game/ramromreplay.c:296,363,381` | D87 `ramromfilestructure` endianness | dead (D87 closed) | | `GE_D88` | `src/game/prop.c:1367`, `src/game/stan.c:3090` | D88 `Usetup*Z` propDef stream | dead (D88 family closed; D88.4 resolved) | diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index c4a95b50..26fe8166 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -35,7 +35,7 @@ D224,FIXED,"QoL gap (M-87, from a PD-PC-port survey):","DEPRIORITIZED (M-87, use D225,OPEN,"Dev-tooling QoL (M-87, user ask):","OPEN — root-caused, not implemented." D226,OPEN,"QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.","OPEN — root cause / call sites identified, not designed or implemented." D227,FIXED,Sky renders as two independently-moving halves —,"FIXED (M-100b, PR #43) —" -D229,OPEN,"Water on IsWater levels renders green and pulses blue<->green on an interval (M-101, user playtest, Frigate -level_26; Surface 2 also an IsWater level).","OPEN — pre-existing (A/B proven), mechanism partly identified, not root-caused." +D229,FIXED,"Water on IsWater levels renders green and pulses blue<->green on an interval (M-101, user playtest, Frigate -level_26; Surface 2 also an IsWater level).",FIXED (port-layer) — root cause: D230,OPEN,In-level music quality:,"OPEN — static-analysis pass done (M-108), not runtime-confirmed." D233,FIXED,All levels (intermittent):,"FIXED (port/fast3d/gfx_pc.cpp, gfx_sp_tri1) —" D234,FIXED,"Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).",VISUALLY VERIFIED FIXED (M-108) — @@ -126,3 +126,6 @@ D240,OPEN,"Player gunshot SFX timing/cadence reportedly does not match the N64 o D241,OPEN,Player gunshot SFX goes silent under some conditions —,"OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug." D242,?,Player-death sequence UX regression vs N64 original:,NOT A PORT-LOGIC BUG as far as static analysis can tell. D243,OPEN,"Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject):",OPEN — M-109 re-analysis of the same session's raw logs corrects the timing/pattern claims above; the D134/pendingGfx race hypothesis is likely wrong. +D245,OPEN,IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) —,"OPEN — observed, not investigated." +D246,OPEN,"Tiny pixel strips at the left and right edges of the screen, ""like overscan"" —","OPEN — observed, not investigated." +D247,OPEN,Black bars top and bottom of screen —,OPEN — possibly intended pending the widescreen decision; needs one data point. diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 29e18d93..6800ac44 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -408,7 +408,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D225 | **Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.** Root-caused a clean single choke point — **both** gates route through the same function: `bool fileGetIsCheatUnlocked(save_data *save, s32 cheat)` (`src/game/file2.c:391`) reads the save's `unlocked_cheats_1/2/3` bitfields and returns whether bit `cheat` is set. Mission-select lock (`src/game/file.c:59`, gates whether a briefing page's mission is selectable) and the cheat-menu availability check (`frontCheckIfCheatIsUnlocked`, `front.c:1009`, itself calling `fileGetIsCheatUnlocked` for the per-level `SP_LEVEL_*` cheat IDs) **both** call it — it's a pure, side-effect-free bitfield read (no save-data mutation), so short-circuiting it to always-true under a debug env var unlocks mission-select *and* the cheat menu in one change, matching exactly what was asked ("those two go a long way"). **Bonus, free once cheats unlock:** the ROM already contains real Rare-era developer cheats that are otherwise unreachable in normal play — `CHEAT_LINEMODE` (wireframe render — would make D222-style culling bugs trivial to see), `CHEAT_BONDPHASE`, `CHEAT_DEBUG_POS`, `CHEAT_DEBUG_UNK5` (`cheat.c` ~L687-707, `CHEAT_MASK_GLOBAL`) — worth trying once this lands, may be useful dev tools in their own right. | **OPEN — root-caused, not implemented.** Plan: `#ifdef PORT`, env-gated (e.g. `GE_DEBUG_UNLOCKALL=1`, catalogue in `GE-ENV-PROBES.md` once added) short-circuit at the top of `fileGetIsCheatUnlocked` — `if (getenv-cached-flag) return TRUE;` before the bitfield read. Default off ⇒ zero behavior change for normal play; when set, purely a query-time override (save file itself is never touched, so it can't corrupt a save or leak into the shipped default). No `src/` logic change beyond the guarded early-return (same class of edit as other `#ifdef PORT` dev-convenience hooks, e.g. `GE_STARTMENU`). | | D226 | **QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.** All three route through the same primitive, `textRenderOutlined()` (`src/game/textrelated.c:688`, doc comment: *"Used for ammo counter, bottom left HUD messages, countdown timers"*), called from two specific in-game (not menu) sites in `bondview2.c`: **ammo + bottom-left pickup/status** at `bondview2.c:10005` (`stringbuffer_lowerleft[...]`, anchored at `view_left`/`view_vert`), and **top-of-screen dialogue** at `bondview2.c:10163`/`10165` (`stringbuffer_top[...]`, anchored at `msg.x`/`msg.y`). `textRenderOutlined` → `textDrawGlyphQuad` (`textrelated.c:615`) emits each glyph as a plain RDP `gSPTextureRectangle` at the glyph's native font-metric pixel size, `dsdx`/`dsdy` = `0x400` (1:1 texel:pixel, no scale factor anywhere in the call chain). Same function is also used by menus/options/multiplayer UI (`front.c`, `options.c`, `mpmenu.c`) — **do not scale generically**, those have their own layout at fixed screen fractions and D211 already established HUD anchors must stay put; scale only the 3 in-game HUD call sites named above, about their existing anchor (so bottom-left stays bottom-left, top stays top — same anchor-preserving principle as D211's HUD-static check). | **OPEN — root cause / call sites identified, not designed or implemented.** Two viable directions: (a) a `#ifdef PORT` wrapper at just these 2-3 `bondview2.c` call sites that scales the emitted `gSPTextureRectangle` geometry (`xl/yl/xh/yh`) by a new `f32 portHudScale` (default 1.0 ⇒ byte-identical) while inversely scaling `dsdx/dsdy` to keep the same source glyph texels mapped onto the larger/smaller rect, re-anchored so the opposite edge (bottom for the lower-left block, top for dialogue) doesn't move; or (b) thread a scale parameter into `textRenderOutlined`/`textDrawGlyphQuad` itself, defaulted to 1.0 at every other call site. (a) is more surgical (matches the "don't touch shared menu text" constraint) but duplicates a little geometry math; (b) is more central but touches a much more widely shared function. No PD precedent found (PD's port has no equivalent HUD-scale option). F10 overlay slider candidate once implemented, alongside `Video.FovScale`. | | D227 | **Sky renders as two independently-moving halves — reads as "two sky processes" (M-93, user QA on PR #43's debug build, `-level_22` Statue and Cradle).** Each half of the screen shows a different sky pattern, and the two halves scroll/move at different rates relative to each other. Surfaced *while* verifying D176(a)'s perspective-correct-interpolation fix — D176(a)'s fix measurably improved the scroll-speed symptom on the visible pattern but did not touch this; this is a distinct defect, not a regression from that PR (unclear yet whether it pre-dates PR #43 or was merely easier to notice once the scroll speed calmed down enough to see the seam). User flagged one confound worth ruling out first: might be a widescreen/aspect-ratio artifact of rendering at modern resolutions rather than a logic bug — original N64 output is 4:3/{something narrower}, and the sky-quad tiling math (`skyPortRenderPoly`, `sub_GAME_7F097388` upstream) may assume the original screen split. | **FIXED (M-100b, PR #43) — root cause was a unit error, and it also fixed the "sky scrolls too fast" complaint (M-96) and the over-tiling.** `unk20`/`unk24` are already in the RDP's native S10.5 1/32-texel units; M-82 read them as texel counts and baked `tc = (S - fold) * 32`, making every sky texcoord 32x too large and overflowing the `s16` `tc`. Human play-test confirmed by the user on the previously-failing levels. Full write-up: §H "D227 — M-100b". Historical investigation trail below. **OPEN — visual evidence captured + a concrete mechanistic hypothesis found (M-94), not yet fixed.** Headless `-level_22` capture confirms a real, visible crease/discontinuity in the cloud texture — not a rendering artifact of viewing it live, and present in a single static frame (rules out the "just camera pan" explanation for at least this instance). Screenshot: `docs/img/bugs/d227-sky-seam-statue.png`. **Leading hypothesis:** `skyPortRenderPoly` (D176(a)'s fix) computes `wScale` per call — i.e. per individual triangle in the sky fan (`skyRenderTri`/`skyRenderFull` issue up to 5 separate `skyPortRenderPoly` calls per frame from a 5-vertex fan, `sky.c:900-920`/`1372-1393`) — from that triangle's own 3 vertices' `unk0c`. The position math is exact for any `wScale` (verified algebraically: `clip.x = (ndcX·w/wScale)·wScale = ndcX·w` regardless of `wScale`), so this is not a logic bug in the fix itself, but the `s16`-quantized `ob[]` storage means each triangle's shared-edge vertices get **independently re-quantized at a different granularity** depending on that triangle's own `wScale` — a vertex sitting on the seam between two triangles can round to a measurably different `ob[]` value in each triangle's independent call, producing a small position/continuity crack exactly at triangle boundaries. This would read as "two adjacent regions with different patterns" without requiring two draw calls or two cloud layers — one `skyPortRenderPoly` call producing a slightly different quantization per neighboring primitive is sufficient. **Not yet confirmed** — did not isolate camera motion from sky-scroll motion cleanly enough to independently verify the "each half moves at a different rate" part of the user's report, only the static crease. **M-95: FIXED (the static-crease part) + headless-verified.** Implemented the fix direction above: `skyPortBeginFan()`/`skyPortEndFan()` (new, `#ifdef PORT`, `src/game/sky.c`) compute one shared `wScale` across every vertex in the whole fan (`sp274[]`/`sp94[]`, up to 5 verts), called once right after each fan's vertex array is populated and before its 1-3 `skyRenderTri`/`skyRenderFull` calls; `skyPortRenderPoly` now uses that shared value when set, falling back to its old per-call-local computation otherwise (defensive — no other caller exists today, checked: `grep` finds `skyRenderTri`/`skyRenderFull` used nowhere outside this file). **Verified:** same-frame before/after screenshots at `-level_22` frame 345 — `docs/img/bugs/d227-sky-seam-statue.png` (before, visible kink ~x=1000/1280) vs `docs/img/bugs/d227-sky-seam-statue-fixed.png` (after, same frame, continuous streaks, kink gone). Full 21-level `verify.sh sweep`: 21/21 PASS both before and after this change (no regressions). `bunker1`'s `worst_cell` framediff number is unaffected by this change — reproduced identically on the pre-fix build, it's the already-known stale-golden-baseline issue (see M-84/M-83 notes; owed a re-baseline, unrelated to sky.c). **Cradle checked too:** the "black rectangle" artifact visible there is confirmed pre-existing (identical on a pre-fix build capture) — that's the separate, already-documented FOV-coupled black-sky-past-frustum-edge symptom (D218/D222 family), not this defect; didn't chase it here. **Not fully closed:** did not re-verify the "moves at a different rate" half of the original report post-fix (the M-94 addendum's static-camera diff measurement wasn't repeated on the fix); the aspect-ratio-artifact alternate theory was never separately confirmed or ruled out, though the fix visibly closing the crease at a fixed resolution is itself decent evidence the crease specifically was a code-side seam, not a display-scaling artifact. Human eyeball still owed before merge, same as every other rendering fix landed without display access this session. **M-96: human eyeball done — seam/crack STILL PRESENT on Statue with the M-95 fix in.** The static-crease theory (verified by the frame-345 screenshot diff) is therefore not the whole story, or not the dominant contributor to what's actually visible in live play; the never-repeated "moves at a different rate" temporal theory from the original report is the next thing to check — a single-frame diff can't see it. **D227 NOT resolved; PR #43 stays in draft.** **Also M-96: user reports the N64 sky should be *almost static* and this build's scroll is *extremely fast* — stronger than M-93's "still reads too fast," bears on D176(a)'s fix magnitude itself, not just this seam; possibly the same wrong-magnitude tc-delta drives both symptoms, not yet investigated.** Full write-up: §H "D227 — M-96". **M-98: seam residual FIXED (same bug class as M-95, one field over) + scroll-rate quantitatively measured.** Re-derivation confirmed the tick-rate side is clean: `skyTick()` (unmodified N64 logic) adds `g_ClockTimer` (verified via `frametiming.c` to run ~1/tick under normal load, D193/D204's territory) to `g_SkyCloudOffset` once per logic tick — not a per-render-frame or double-counted path, ruling out a global-timing explanation for "extremely fast" (that would show up as everything sped up, not just the sky). The seam, though: M-95 shared `wScale` across a whole sky fan but **left `skyPortRenderPoly`'s S/T fold (`foldS`/`foldT`) computed per-call, from only that triangle's own 2-4 verts** — the exact same class of bug M-95 fixed for position, just one field over, and never touched. Two triangles in one fan can independently floor a shared vertex's S/T to a different multiple of 64 texels; nominally invisible (GL_REPEAT period 64) but each triangle bakes `(S - fold) * 32` into its own `s16` Vtx buffer, and two folds 64 texels apart produce tc values 2048 apart — large enough to crack at the shared edge. Fix: extended `skyPortBeginFan()`/`skyPortEndFan()` to also compute one shared fold across the whole fan (`s_skyFanFoldS/T`), threaded into `skyPortRenderPoly` the same way as `wScale`. Verified: `-level_22` frame 345 (same frame M-95's before/after used) now shows continuous streaks with no visible crease at the ~x=1000/1280 location, vs. the still-visible crease in `docs/img/bugs/d227-sky-seam-statue.png`; `verify.sh bunker1` PASS (worst_cell unchanged, the known stale-golden-baseline number, not a regression). **Scroll-rate data point (the M-96/M-97-requested measurement):** cross-correlating consecutive-frame captures (`GE_PCDUMP=345-355:1`) at a static camera gives a consistent **~7 px/tick horizontal shift at 640px capture width** — a full 640px screen-width sweep roughly every 90 ticks (~1.5s at 60Hz), which is hard quantitative confirmation of "extremely fast," not just a subjective read. **Root cause of that magnitude is NOT yet found** — `g_SkyCloudOffset`'s accumulation (1 texel/tick) and the vertex S/T geometry it feeds (`sub_GAME_7F097388`) are unmodified N64 logic, so if the rate is genuinely wrong relative to N64, the bug must be in how many on-screen pixels one texel-tick maps to (camera-to-sky-plane geometry, FOV, or a scale baked into the D176(a) Path B substitution) rather than in the accumulator itself — not investigated this pass. Human eyeball still owed on both the seam-close and the (unfixed) scroll-speed complaint. **M-99: human playtest — seam STILL PRESENT after M-98, AND confirmed view-angle-dependent (moves as the mouse/view moves); also reported still bad on Cradle and present on Surface.** Ruled out the cross-quad water/cloud scale-mismatch theory on the two `IsWater==0` levels tested (Statue, Cradle) — untested on any `IsWater==1` level. Headless camera-pan repro via `GE_INPUTSCRIPT` confirmed NOT to work (doesn't reach real mouselook) — this needs either a human playtest or a new debug hook to chase further. Full write-up + ranked candidate list for next session: §H "D227 — M-99". **M-100: ROOT CAUSE FOUND AND PROVEN — `s16` texture-coordinate overflow.** A user-supplied emulator-vs-ours screenshot pair on Surface (same framing) showed the real symptom is a *radial starburst*, not a crack — which redirected the investigation off the quantization-mismatch track M-94→M-98 had been on. New `GE_D227V` per-vertex probe on the Cradle frame-350 camera: the sky is one quad spanning **29,830 texels in S and 29,929 in T**, but `Vtx.tc` is S10.5 in an `s16` = **±1024 texels**, so **7 of its 8 texture coordinates overflow and wrap to arbitrary values**; the fold cannot help against a span 29x the representable range. The quad is split `gSP2Triangles(0,1,3, 0,3,2)` on the 0–3 diagonal and vertices 0 and 3 are exactly the two that keep one coordinate in range — hence "two halves with different patterns, split by a line", the original D227 report verbatim. All four w positive, so no projective pole. Apparent view-dependence needs no view-dependent mechanism: S/T depend on view, so the wrap point moves. **Confirmed by diagnostic build:** baking tc at 1-texel instead of 1/32-texel granularity (32x range, deliberately wrong scale) makes the starburst and the split *completely disappear* (`docs/img/bugs/d227-cradle-tcscale-diagnostic.png`) — no second defect hiding behind it. **D227 == D176(a) "Defect 2" == M-98's "Cradle tower starburst"; M-98 misfiled that capture as unrelated, which is what made D227 look headlessly-unreproducible — it reproduces statically.** M-95/M-98 stay (per-fan consistency is what makes subdivision seamless). **Fix not implemented:** subdivide until span < ~960 texels/primitive; subdivision math and cut placement are worked out (S is linear in w, so cut uniformly in w — uniform-in-screen is badly wrong), but the vertex budget is an open decision (unchecked 50 KB/frame pool; pure tessellation wants ~40% of it). See §H "D227 — M-100". **M-100b: FIXED — and the real cause is one step further back: a UNIT ERROR.** M-82 ("Defect 1") read `unk20`/`unk24` as texel counts and baked `tc` as `(S - fold) * 32` to convert to S10.5. They are not texels: `skyRender` sets them as `worldpos * 0.1f` and the N64 path hands them to the RDP coefficient block unscaled (`skyRenderTri`: `S * unk34 * sp368`), so they are **already in the RDP's native S10.5 1/32-texel units**. That stray `* 32` made every sky texcoord 32x too large and caused **three symptoms tracked as separate bugs**: (1) D227 (spans of ~30,000 *texels* vs an `s16` holding ±1024 → overflow → starburst); (2) over-tiling (~466 repeats of the 64-texel cloud per quad instead of ~15); and (3) **M-96's "the N64 sky is nearly static, ours races"**, measured at ~7 px/tick in M-98 — `g_SkyCloudOffset` advances 1 unit/tick, which is 1/32 texel, not 1. **The scroll complaint and D227 were the same bug**, which is why M-98's tick-path re-derivation kept coming back clean: the accumulator was never wrong, its unit was. **Fix:** `tc = (S - fold)` 1:1, fold rebased to a whole tile period (2048 units); the span then fits an `s16` unaided — no tessellation, no tile shift, which is also why the original N64 code never needed either. **Verified:** Cradle frame 350 (`docs/img/bugs/d227-cradle-fixed-m100.png`) soft cloud bands at correct scale, no starburst/split/moiré; `GE_D227V` reports `kS=kT=0` and max tc 31,513 of 32,767; Statue frame 345 clean; `verify.sh bunker1` PASS. M-95/M-98 kept (per-fan consistency); the tile-shift machinery kept as a graceful-degradation safety valve (only ~4% headroom left on the observed span). **Human play-test still owed** — both prior "fixes" passed static checks and failed live. See §H "D227 — M-100b". | -| D229 | **Water on `IsWater` levels renders green and pulses blue<->green on an interval (M-101, user playtest, Frigate `-level_26`; Surface 2 also an IsWater level).** Reported alongside green weapon textures on Frigate/Surface 2 — **that half was NOT a bug**: the branch under test was 2 commits behind main and those 2 commits were exactly the D217 fixes (#47 texpool alignment, #49 CI cache palette-content key); after merging main the user confirms the gun is good. | **OPEN — pre-existing (A/B proven), mechanism partly identified, not root-caused.** Built plain `main` with none of the D227 work: `-level_26` frame 340 shows the **same green** (only the tiling density differs), so D229 is independent of D227. The quad is `sky.c`'s `IsWater` block1 (Frigate confirmed `IsWater=1` via `GE_D176` — which also settles M-99's ranked item 1: that path is textured and does go through `skyPortRenderPoly`). Draw state from `sub_GAME_7F09343C` (`unk_092E50.c:278`): tile 0 and tile 1 both RGBA/16b at **the same TMEM 0**, `masks=maskt=5`, tile 1 offset by `gDPSetTileSize(1, uls=90, ult=150, lrs=0, lrt=0)`, 2-cycle `LERP(TEXEL1, TEXEL0, PRIM_LOD_FRAC)` with cycle 2 `= COMBINED * SHADE` and the LOD fraction driven by **`sinf()`** — that sine IS the reported pulsing, so **one texunit resolves blue and the other green** and the sine fades between them. **Ruled out: the shade path** — `skyChooseWaterVtxColour` with measured `env RGB=16,48,96` / `WaterRGB=255,255,150` spans dark blue to pale yellow and can never be green; measured pixels ~`(5,70..175,20..37)` (R and B crushed) put the green on the texel side. **Not established:** why one texunit decodes green when both tiles share TMEM and format. One unproven oddity: `uls/lrs` are `uint16_t` and `width = ((lrs-uls)>>2)+1` gives tile 1 `-22` -> **65514**, though the matching negative `tex_width2` makes the sub-tile-window branch skip and fall through to the correct mask-based 32-texel wrap, so it may be harmless. **Next:** widen the `GE_D172` multitex probe (it filters on `tile1.tmem != tile0.tmem`, equal here, so it never fires) to dump both texunits' decoded state; capture a *sequence* across the sine period, since a single frame is useless (frame 340 reads green on all builds); and check `skywaterimages[2]`'s real format vs the RGBA/16b `sub_GAME_7F09343C` re-declares. **D227 is deliberately decoupled:** M-100b's safety valve takes `allowShift` and the water quad passes FALSE, leaving that path byte-identical to pre-D227 — shifting both tiles was observed live to flatten the cross-fade into constant green. Full write-up: §H "D229 — M-101". | +| D229 | **Water on `IsWater` levels renders green and pulses blue<->green on an interval (M-101, user playtest, Frigate `-level_26`; Surface 2 also an IsWater level).** Reported alongside green weapon textures on Frigate/Surface 2 — **that half was NOT a bug**: the branch under test was 2 commits behind main and those 2 commits were exactly the D217 fixes (#47 texpool alignment, #49 CI cache palette-content key); after merging main the user confirms the gun is good. | **FIXED (port-layer) — root cause: water image is ZLIB+CI8; pool holds raw 8-bit indices; N64's custom ucode expanded them to RGBA16 in TMEM at load, fast3d read index byte-pairs as 16-bit texels (g=2·idx mod 32 dominates → green). Fix: track `src_fmt` per TMEM load; RGBA/16b tile over a CI-sourced slot now imports via the CI8 palette path (`gfx_pc.cpp` import_texture). Frigate verified live (user: "looks better"); sweep + Surface 2 by-eye still owed.** Pre-existing (A/B proven), mechanism identified as above. Built plain `main` with none of the D227 work: `-level_26` frame 340 shows the **same green** (only the tiling density differs), so D229 is independent of D227. The quad is `sky.c`'s `IsWater` block1 (Frigate confirmed `IsWater=1` via `GE_D176` — which also settles M-99's ranked item 1: that path is textured and does go through `skyPortRenderPoly`). Draw state from `sub_GAME_7F09343C` (`unk_092E50.c:278`): tile 0 and tile 1 both RGBA/16b at **the same TMEM 0**, `masks=maskt=5`, tile 1 offset by `gDPSetTileSize(1, uls=90, ult=150, lrs=0, lrt=0)`, 2-cycle `LERP(TEXEL1, TEXEL0, PRIM_LOD_FRAC)` with cycle 2 `= COMBINED * SHADE` and the LOD fraction driven by **`sinf()`** — that sine IS the reported pulsing, so **one texunit resolves blue and the other green** and the sine fades between them. **Ruled out: the shade path** — `skyChooseWaterVtxColour` with measured `env RGB=16,48,96` / `WaterRGB=255,255,150` spans dark blue to pale yellow and can never be green; measured pixels ~`(5,70..175,20..37)` (R and B crushed) put the green on the texel side. **Not established:** why one texunit decodes green when both tiles share TMEM and format. One unproven oddity: `uls/lrs` are `uint16_t` and `width = ((lrs-uls)>>2)+1` gives tile 1 `-22` -> **65514**, though the matching negative `tex_width2` makes the sub-tile-window branch skip and fall through to the correct mask-based 32-texel wrap, so it may be harmless. **Next:** widen the `GE_D172` multitex probe (it filters on `tile1.tmem != tile0.tmem`, equal here, so it never fires) to dump both texunits' decoded state; capture a *sequence* across the sine period, since a single frame is useless (frame 340 reads green on all builds); and check `skywaterimages[2]`'s real format vs the RGBA/16b `sub_GAME_7F09343C` re-declares. **D227 is deliberately decoupled:** M-100b's safety valve takes `allowShift` and the water quad passes FALSE, leaving that path byte-identical to pre-D227 — shifting both tiles was observed live to flatten the cross-fade into constant green. Full write-up: §H "D229 — M-101". | | D230 | **In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).** Filed to give the post-D77 residual its own label — D77 (in-level music *absence*) is RESOLVED (PR #42, missing `return` in `sub_GAME_7F0C0BF0` zeroed the seqp volume on the only in-level trigger path), so "music doesn't play" claims in tracked docs are stale; what actually remains is this quality gap. **M-106 repro, deterministic (not intermittent):** Control's elevator track and its regular in-level track both carry the bad instrument throughout; Cavern's elevator track has it too; Runway's track is otherwise fine but the same instrument enters ~5–7s after the track starts and sounds bad from then on. User confirmed against original N64 reference audio that a bass instrument belongs there on Runway — this is **not** a wrong-instrument/wrong-bank selection (rules out the D77 trigger-path family and the seqp-volume fix's neighborhood); the instrument choice is correct, only its *sound* is wrong on PC. Determinism (same track, same spot, every time) points at a static decode/synthesis bug for this instrument's wavetable/ADPCM data — most likely low-frequency-specific (pitch-shift/downsample artifacting, a wrap/loop-point error, or a clipping/gain issue that only bites bass-range samples) — rather than a live mixing/voice-stealing issue (D147/D152/D202 family), which would be intermittent. The mixer layer and the trigger layer are both proven working by every track that sounds right, so the suspect neighborhood is the seq/instrument-bank decode path (`csplayer.c`/`synthesizer.c`, bank loading), not `port/` audio plumbing. | **OPEN — static-analysis pass done (M-108), not runtime-confirmed.** Repro: Control (elevator + regular track, throughout), Cavern (elevator, throughout), Runway (~5–7s in). Traced the suspect neighborhood to the software ADPCM decoder in `port/src/mixer.c` — the PC replacement for the RSP's `aLoadADPCM`/`aADPCMdec` ucode ops (`csplayer.c`/`synthesizer.c` only emit ucode command lists; they don't decode samples themselves). Found two hardcoded assumptions that don't hold for the general ADPCM-book format: (1) `sAdpcmTable` (`mixer.c:174`) is declared `[8][2][8]`, hardcoding predictor `order=2`, but `order`/`npredictors` are per-instrument authored fields (`include/PR/libaudio.h` `ALADPCMBook.order`; `src/libultra/audio/load.c:428-429` computes `bookSize` from them) that the bank-relayout code (`port/src/romdata.c:718-736`) already handles generically — if this bass instrument's authored book uses `order != 2`, the 2-tap recursion at `mixer.c:223-240` would decode it wrong; (2) even at `order=2`, `aLoadADPCMImpl` (`mixer.c:177-192`) silently truncates any book over 256 bytes (`mixer.c:183`, `npredictors > 8`), while the decode loop's 4-bit predictor-select nibble (`mixer.c:219`) can legitimately address up to 16 rows — an instrument with `npredictors > 8` would read truncated/garbage rows for some frames. **Caveat weakening confidence:** N64 SDK ADPCM in practice is conventionally always `order=2` (the RSP ucode's own decode algorithm is essentially fixed to a 2-tap recursion industry-wide), so candidate (1) may not apply to a real N64 title's data; candidate (2) (`npredictors > 8`) is the more plausible of the two absent contrary evidence. Not confirmed against GE's actual ROM-authored book data (no live capture taken this pass). Next: extend the existing `GE_MIXERTRACE=1` trace's `[LOADADPCM]` line (`mixer.c:187`, already logs `count` — `count/16 = order*npredictors`) to flag any book where `count > 256` (proves truncation) during a Control run, and correlate the anomalous book to its channel/instrument via `seqplayer.c:1120`'s program-change. If no anomaly shows, this whole neighborhood is likely a dead end and the next suspect is the resample (`mixer.c:295-341`) or envelope/gain (`mixer.c:379+`) paths for bass-range-specific behavior. | | D233 | **All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects (terminals, furniture) in that space still draw (M-106, user QA report; screenshots `docs/img/bugs/20260912-cullingbug-bugged.png` vs `-notbugged.png`).** Root-caused M-107 (session-016TaKy6, port/fast3d only): **fast3d's CPU-side "trivial reject" AND-test in `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp:1455-1460`) has a latent w-sign bug independent of D222/D106.** The per-vertex outcode bits computed in `gfx_sp_vertex` (`x<-w`/`x>w`/`y<-w`/`y>w`/`z>w`, `gfx_pc.cpp:1372-1386`) are only valid half-space tests when `w>0`; for a vertex that has crossed behind the camera plane (`w<0` — common when the camera is close to large room-structure polygons, e.g. walls/ceiling trusses near a doorway) the comparison sense is flipped, so its outcode can come out wrong. If that spurious bit happens to match the other two (genuinely correct) vertices' bits, `v1->clip_rej & v2->clip_rej & v3->clip_rej` fires and the whole triangle is dropped before it ever reaches OpenGL — even though GL's own homogeneous clipper (which *does* handle `w<0` correctly) would have rendered the visible part of it. Confirmed against the two screenshots: same room/camera/ammo-HUD, one frame is missing the room's ceiling trusses/walls (replaced by flat sky) while the console/terminal props in the same room still draw fully — exactly the "big background polys vanish, small prop models don't" split you'd expect, since large near-camera polygons are far more likely to have a vertex cross behind the eye than small self-contained prop meshes. The backface-cull code 6 lines below (`gfx_pc.cpp:1462-1495`) already special-cases this exact `w<0` hazard for its own cross-product sign (`(v1->w<0)^(v2->w<0)^(v3->w<0)`) — the trivial-reject block above it never got the same treatment. **Independent of D222** (D222 is a game-logic frustum-cull bug in `src/game/fr.c`/`bondview2.c` that only manifests at non-default `Video.FovScale`; this is a GPU-pipeline clip-rejection bug in `port/fast3d/` that fires at any FovScale, including the default 100, whenever the camera gets close to a large polygon — matching the report's "all levels, near doors" framing with no FovScale mentioned). Also independent of D106 (D106 fixed the *game-side* portal-visibility bbox computation for a different z==0 edge case in `bg.c`; this is the *renderer-side* per-triangle clip decision, a different stage of the pipeline entirely) — same neighbourhood/symptom vocabulary ("things vanish near a portal-adjacent boundary"), different bug. | **FIXED (port/fast3d/gfx_pc.cpp, `gfx_sp_tri1`) — high confidence on mechanism, UNVERIFIED visually.** Fix: skip the trivial-reject AND-test entirely whenever any of the triangle's 3 vertices has `w<0`, deferring to GL's clipper instead (mirrors the cull code's existing w-sign handling one block below). Change only ever *adds* triangles that were previously dropped — cannot make anything newly invisible, so it's a safe, narrowly-targeted fix with no game-logic touch (fast3d is PC-only code, no N64 build parity to preserve, no `#ifdef PORT` needed). **Build verified green** (`./build-pc.sh` equivalent — MSYS2 mingw64 cmake+ninja, full `ge007.x86_64.exe` link succeeds, only one pre-existing unrelated `-Wformat` warning in the same file). **VISUALLY VERIFIED (M-108):** user played `-level_26` (Frigate — corrected level id, the old `-level_02` note above predates the `bondconstants.h` numbering check) and confirmed the doorway/ship-corridor geometry no longer flickers or vanishes near room boundaries. Closed. | | D234 | **Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** Investigated M-107 (session-016TaKy6, port/fast3d scope): **NOT root-caused — no fast3d-side mechanism found that plausibly explains "only the first of N repeated instances renders."** Ruled out: fast3d has no matrix/model instance cache that could explain a first-instance-only bug (the D217-family "keyed by address, not content" cache-bug class doesn't recur here — the *only* content-addressed cache in fast3d is the texture cache, already excluded from this investigation per the M-106 triage note); the modelview matrix stack (`gfx_pc.cpp:105`, depth 11, push properly gated `< 11` at `gfx_pc.cpp:1220`) is correctly bounded and would affect every level's nested-DL geometry, not just Dam's towers specifically; the D233 w<0 clip-rejection bug (above) doesn't fit either — towers are viewed from a distance along the dam, not close enough for a camera-behind-vertex condition, and D233's mechanism is per-triangle/per-frame flicker, not a stable "always the same instances missing" pattern. **Leading hypothesis, NOT verified:** GE's road/structure-instancing for repeated architecture typically goes through the room-graph's bytecode "vis program" (`VISOP_*` opcodes interpreted in `bg.c` ~L4450-4620, e.g. `VISOP_PUSH_IF_ROOM_IN_RANGE`, `VISOP_ADD_VISIBLE_ROOM`) or through per-instance room/portal definitions in the level's compiled BG data — both are level *data*, not code reachable via static analysis without the ROM (this worktree has none extracted). Could equally be a per-instance object/prop transform or cull-volume bug in `src/game/propobj.c`/`chrprop.c` (out of this task's `port/fast3d/` scope, and out of the `src/game/*` model/anim exclusion in the brief regardless). **No fix attempted — do not have enough evidence to touch code.** | **VISUALLY VERIFIED FIXED (M-108) — mechanism still unexplained.** User confirmed on a live Dam playthrough (post-D233 fix, correct `-level_26`/Frigate session's build) that all guard towers along the dam now render, not just the first. No code change was made specifically for D234, and the M-107 investigation's own reasoning argued D233's mechanism doesn't fit this symptom (towers viewed from a distance, not a close-camera w<0 case) — so either that reasoning was wrong, D233's fix had a broader effect than analyzed, or the original symptom was intermittent/state-dependent rather than deterministic. Not closed with high confidence in *why* it's fixed, just closed on live confirmation that it *is*. If it resurfaces, re-open with a fresh `GE_PCDUMP` capture rather than assuming this write-up's ruled-out list still holds. | @@ -522,6 +522,9 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D241 | **Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb unaffected (M-106, user QA report, concrete repro).** This is very likely **D207 finally reproduced.** D207 (M-77) root-caused that GE's SFX player has a hard **8-voice cap** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `src/music.c:76`) and that D206's fix made the alarm klaxon (`ALARM3_SFX`=163, triggered from `handle_alarm_gas_timer_calldamage()`, `src/game/propobj.c:14457`) a genuine **infinite ADPCM loop** that now legitimately occupies one of those 8 voices for the alarm's whole duration; the manual preemption scan (`snd.c:~347-392`) refuses to steal any *owned* LOOPED voice (the M-65 carve-out only exempts *ownerless* loops), so once 8/8 voices are held, a new one-shot request is silently dropped via `sndDisposeSound` (`snd.c:391`). D207's own M-77b session built a `GE_FORCEALARM=1` BUNKER1 harness and could **not** reproduce audible drops (`[D207-DROP]=0`), and explicitly left as "owed": *"needs a real repro from the user: which level, which weapon/situation."* This report supplies exactly that (Bunker, alarm). **Investigated why PP7/AK47 and not Klobb, inconclusive at the code level:** Klobb (`ITEM_SKORPION` — confirmed GE's internal name for the Klobb via `src/game/player.c:576`, `skorpion_stats` in `assets/obseg/gun/skorpion/gunWeaponStat.inc.c`) and AK47 (`ak47_stats`, `assets/obseg/gun/ak47/gunWeaponStat.inc.c`) share the **same** `AutomaticFiringRate=0x03` (fast auto), while PP7 (`wppk_stats`, `assets/obseg/gun/wppk/gunWeaponStat.inc.c`) is semi-auto only (`AutomaticFiringRate=0xFF`) — so a naive "fires faster ⇒ more self-inflicted voice contention" theory does not cleanly explain grouping PP7 with AK47 against Klobb. All three weapons' fire SFX (`GUN_B1_MGUN3_3_SFX`=Klobb, `GUN_B2_HEAVY_SFX`=PP7, `GUN_B4_BOLTACTION_SFX`=AK47 — `src/bondconstants.h:2277-2280`) go through the identical one-shot, priority-0x40 `sndPlaySfx` path (`gunfire.c:3246-3286`); no per-weapon distinction exists in the trigger or priority *code*. A real distinction, if one exists, most likely lives in the **sample bank data itself** (per-sample envelope/decay length — a longer-tailed sample stays `PLAYING` and stealable/held longer, changing its odds in the preemption scan) — that data is ROM-derived binary content, not inspectable without the extracted ROM (`./data/`, absent in this worktree). Confirmed the voice-allocation/preemption code is unmodified `src/snd.c` game logic, not a `port/` mixer bug: `port/src/mixer.c`/`port/src/audio.c` implement DSP synthesis and device output only (per D199), with no SDL `Mix_*`-style channel-priority layer in the port to have introduced a new bug. Per AGENTS.md non-negotiable #2, any real fix belongs in D207's already-designed (reverted, M-77b) last-resort LOOPED-preemption pass in `snd.c`, not in `port/`. | **OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug.** Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately. Next: re-run M-77's `GE_FORCEALARM=1` harness on `-level_09` with scripted PP7 and AK47 fire (not just generic fire) against Klobb as a control, watching `g_sndAllocatedVoicesCount` / the (currently reverted) `[D207-DROP]` counters to see whether the 8-cap is actually hit for two of the three weapons and not the third — or whether it is really the sample-envelope-length distinction above. No runtime verification possible in this environment (no ROM/display). | | D242 | **Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays 3 death-camera angles then transitions with no input needed (M-106, user QA report).** **The 3-angle auto-replay and its no-input auto-transition both exist, unmodified, in the decomp — confirmed, not a design difference removed for the port.** `bondviewSetCameraMode(CAMERAMODE_DEATH_CAM_SP)` (`src/game/bondview2.c:901-1010`) calls `pickDeathCameraAngles()` (`bondview2.c:641-782`, picks a fresh random valid camera angle each call) to stage each replay; when it returns 0 — no further valid angle found — the code calls `bossRunTitleStage()` directly, and the source comment at `bondview2.c:1006-1007` literally reads *"...or when the three death replays are finished."* The 3-cycle count is driven by `bondviewAdvanceCameraMode()` (`bondview2.c:1041-1078`): `CAMERAMODE_DEATH_CAM_SP` → `CAMERAMODE_DEATH_CAM_MP` → `camera_mode++`, looping back to `DEATH_CAM_SP` while `camera_mode < CAMERAMODE_SWIRL` (three iterations). Each individual replay's own advance is driven purely by animation/timer state (`bondview2.c:1467-1490`): the `g_CameraAfterCinema` flag that `bondviewAdvanceCameraMode()` acts on is set automatically once `modelGetAnimFrame(bodyModel) >= modelGetAnimEndFrame(bodyModel)`, or after a `camera_transition_timer >= 180.0f` tick-timeout fallback when there is no body model — **no button check in that branch; fully automatic.** **The button-press the user is hitting is a real, intentional N64 skip-ahead shortcut, not a port-added gate:** the same code block (`bondview2.c:1483-1489`) *also* fires the identical advance immediately on `(buttons & ~oldbuttons & (CONT_A | B_BUTTON | Z_TRIG | START_BUTTON))` whenever `bonddead && redbloodfinished && deathanimfinished` — i.e. on real N64 hardware, pressing A/B/Z/Start at any point during a death replay skips straight to the end, by design. **On the port, LMB (mouse fire) is mapped to `GE_CONT_G`, which is Z_TRIG** (`port/src/input.c:664-665`: `(mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ... button \|= GE_CONT_G`; binding table `input.c:351`: `{"Z",GE_CONT_G}`) — exactly one of the four original skip buttons. So an LMB click during the death sequence is GE's own original cutscene-skip input, faithfully wired through the port's control remap, not a port-invented "click to continue" prompt. **What this investigation could not determine** (no build/runtime access in this worktree): whether the *automatic*, no-input path actually completes on PC in a reasonable time. `camera_transition_timer` accumulates via `g_GlobalTimerDelta` (`bondview2.c:1470,1493`), which is set from the same `g_ClockTimer`/`speedgraphframes`/`deltaFrames` chain flagged in D240 above and investigated under D155/D186/D193/D204/D209 — if any residual in that chain stalls or mis-scales specifically in this state, the auto-advance could take far longer on PC than on N64 (or effectively never complete before the player gives up and clicks), which would make an LMB click *feel* mandatory even though the ground-truth logic never requires one. | **NOT A PORT-LOGIC BUG as far as static analysis can tell.** Confidence HIGH that the LMB-click skip path is faithful original behavior (Z_TRIG-mapped, exists unmodified in `bondview2.c`), not a defect to remove. Confidence LOW (untested) on whether the no-click automatic path is actually healthy on PC. Needs a human playtest: die, then **deliberately touch no input** during the death sequence and time whether/how long until the mission-failed screen appears on its own. If it never arrives without a click, the real bug is a `g_GlobalTimerDelta`/`camera_transition_timer` stall in the D240 clock family — cross-reference D240 — not a wrongly-added input gate, so there is still no `#ifdef PORT` skip-gate to remove per AGENTS.md #2. No runtime verification possible in this environment (no ROM/display). | | D243 | **Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle cuts (M-107, live `GE_D160=1` trace, two interactive `-level_33` runs + one fresh-save normal-progression run).** **First, corrects D160's own premise: the cutscene is NOT skipped on the current build.** Both debug-loaded runs show exactly the AI-script-scripted sequence firing: `bondviewSetCameraMode(7)` (`CAMERAMODE_POSEND`, the cutscene camera-lock state) called 2-3 times, matching `ai_17`'s three `camera_switch` commands in `UsetupdamZ.c`'s abseil ailist (`assets/obseg/setup/UsetupdamZ.c:2656`+), followed by `bossReturnTitleStage`. So D160's original (b)/(c) hypotheses (AI trigger never fires / puppet renders invisibly) are **refuted on this build** — the mechanism runs; what's broken is what it renders. **Root cause candidate, directly observed:** right in the camera-cut window, fast3d's D146 catch-all (`port/fast3d/gfx_pc.cpp:3037`, "unknown GBI opcode … ending DL") fired a tight burst — same 4 memory addresses repeating every frame for ~10 frames, then clean — with payload bytes `0xfafbfbfcfcfbfdfc`/`0xfcfafbfbfafbfafb`: a **freed/uninitialized-memory fill pattern**, not real GBI commands. This reads as a stale-pointer display-list read, not a decode bug — some segment/scene buffer backing the outgoing camera shot's geometry got freed or recycled while a still-in-flight display list (queued ahead in the port's double-buffered render pipeline — see D134's "pendingGfx" note, `port/src/libultra.c:733-746`, on how many frames of latency the sched round-trip can carry) still referenced it. **Independent evidence of a race, not a fixed-order bug:** the two `-level_33` debug-load runs show the camera_switch(7) calls landing in a *different order relative to* `bossReturnTitleStage` between runs (run 1: three switches, then the stage-return call; run 2: two switches, the stage-return call, *then* a third switch arriving after) — and the D146 corruption burst appeared in run 1 but not run 2 at all, non-deterministically. That pattern (symptom present/absent, call order shuffling) is the signature of a genuine timing race between the AI-list's cutscene commands and the level-exit/report-transition code, not a deterministic logic bug. **User-confirmed on real (non-debug-boot) play, not just the debug harness:** a fresh-save normal playthrough into Dam still hit the camera glitch on this same cutscene — ruling out "artifact of the `-level_33` debug boot" for this specific symptom (unlike the secondary "no report screen, straight to file-select" symptom seen only on the debug-loaded runs, which did NOT reproduce on the fresh-save run and is therefore most likely a `-level_N` debug-boot artifact per the D190 precedent, not filed as its own bug here). | **OPEN — M-109 re-analysis of the same session's raw logs corrects the timing/pattern claims above; the D134/pendingGfx race hypothesis is likely wrong.** Confidence MEDIUM on the correction, LOW-MEDIUM on the new lead. Re-reading `data/playtest_d160.log`/`_run2.log` at the `g_framesRendered` level (not just grepping for `D146`) shows the D146 burst fires **~1600-3900 frames (tens of real seconds) *after*** `bossReturnTitleStage`/the `camera_switch(7)` calls, not during them, in **both** runs — "right in the camera-cut window" and "run 1 only" are both wrong as stated above. This also weakens the D134/`pendingGfx` citation: `osSpTaskStartGo` runs `gfx_run()` synchronously and inline (`port/src/libultra.c:1232-1258`), so nothing from the cutscene's own DL submission is still in flight by the time any teardown code runs — the port doesn't have the async-RSP race the hypothesis above assumes. **New lead:** whatever screen renders in that multi-thousand-frame gap after mission-complete is the more likely site — probably `MENU_MISSION_COMPLETE` (`front.c:7267`, `constructor_menu0D_missioncomplete`) or an adjacent briefing/file-select screen in that chain, same general class as D75 (front-end model absence) / D141 (menu bad-pointer bug), not a cutscene-render race. Of the 4 repeating fault addresses, 3 read as literal zero words and only 1 matches the `0xfafbfbfc…` pattern, arguing against a deliberate poison-fill (none found anywhere in `port/`) and for either genuinely-unwritten memory or a decode landing inside unrelated valid data. Next: an env-gated `front.c` menu-transition trace (`MENU_*` id + `g_framesRendered` on every transition after `bossReturnTitleStage`) plus a `GE_PCDUMP` window over frames ~4500-8000 (not the camera-cut window) to name the exact screen/segment. Cross-ref D160 (this supersedes its stale "cutscene skipped" framing with a "cutscene renders corrupted" one, now further corrected to "corruption isn't in the cutscene render at all") and D173 (Cradle's Bond-floating/spinning cutscene bug — same subsystem family, unconfirmed shared root cause). | +| D245 | **IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) — "looks almost exactly like skybox" (M-116, user QA report, post-D229-fix build).** The symptom is D227's exact signature class (s16 `Vtx.tc` overflow → rasteriser interpolates between wrapped coordinates → two differently-wrong screen regions + a seam at the wrap boundary; on the sky quad the split ran on the 0–3 diagonal). Water draws through the same port path: `src/game/sky.c` water block (~905–956) — `texSelect(&skywaterimages[WaterImageId])` + `sub_GAME_7F09343C` (two-texunit cross-fade; both tiles re-declared RGBA/16b @ tmem 0, the draw D229 fixed) then a `skyRenderTri` fan over `sp274[]` under `skyPortBeginFan(sp274, s1, FALSE)` — and **the `FALSE` is a documented, deliberate tolerance of exactly this overflow**: the comment above `skyPortBeginFan` (sky.c ~1738) records that the IsWater quad is excluded from D227's tile-shift safety valve because both texunits read the same vertex tc, so a tc rescale would need matched shifts on BOTH tiles and "shifting both makes them sample identically, which flattens the cross-fade"; the 2026-09 decision was that "the water quad keeps the plain 1:1 tc and simply tolerates the overflow on its horizon vertex, exactly as it did before D227." The mechanism is real: `unk20/unk24` are S10.5 units reaching tens of thousands near the horizon; the fold rebases to a multiple of `SKY_TC_WRAP` (2048) but with k=0 an s16 tc holds only ±32767, so any wider span wraps. It was invisible before D229 because every water texel was green mottle garbage anyway; with correct blue texels the wrapped region now reads as a visibly different pattern + seam, and it tracks the view because the fan's S/T depend on camera orientation. (On N64 this draw is a verbatim RDP `G_TRI_SHADE_TXTR` coefficient block with no s16 tc — the overflow constraint is a port artifact of re-emitting as `gSPVertex` batches, same class as D227.) | **OPEN — observed, not investigated.** Strong lead: the `allowShift=FALSE` tolerance in `sky.c`. Concrete next steps: (1) run an IsWater level (`-level_26` Frigate) with `GE_D227V=1` and check whether water-fan vertex tc values actually overflow s16 on current main (the probe prints S/T/k/tc per vertex; water verts flow through the same `skyPortRenderPoly` as sky); (2) if confirmed, re-examine the "flattens the cross-fade" objection — a matched shift on both tiles leaves each tile's *decoded* coordinate identical to 1:1 (shift is a decode-side rescale, not an offset), so the cross-fade should be preserved and the sky-style safety valve may apply to water as-is; verify what actually distinguishes tile0/tile1 sampling in `sub_GAME_7F09343C` (`src/game/unk_092E50.c:278`) before concluding; (3) fallback if a matched shift genuinely breaks the effect: fan tessellation (the D176(a) "Defect 2" option). Cross-ref D227 (mechanism), D229 (made this visible). | +| D246 | **Tiny pixel strips at the left and right edges of the screen, "like overscan" — look like raw pixels, not 3D graphics (M-116, user QA report).** Uninvestigated. The port renders at native 640×480 (`GE_NATIVE_W/H`; `gfx_current_native_aspect` set in `port/src/video.c:253,570`) and scales to the window on present, so the leading candidate is a scaling/blit rounding artifact: non-integer scale factors leaving 1–2 columns of uncleared backbuffer content at the edges (or an off-by-one in the blit rect). Secondary candidates: an RDP fill-rect/scissor off-by-one in a game DL or the F10 overlay hook (`gfx_run()` seam, D184), or GL edge filtering on the scaled texture. | **OPEN — observed, not investigated.** Next: (1) `GE_PCDUMP` a frame showing the strips and inspect the edge columns directly (raw-pixel look suggests buffer content, not scene geometry); (2) read the present/scale path in `port/src/video.c` + `gfx_pc.cpp` framebuffer blit for rounding/rect math; (3) ask the user for window size and whether the strip width changes with it (fixed 1px = blit rect off-by-one; scales with window = scale-factor remainder). | +| D247 | **Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).** Uninvestigated. Context: the port currently renders native 4:3 (640×480); widescreen is a *parked proposal* (`docs/dev/WIDESCREEN-FOV-PLAN.md` — PD-parity native 16:9 render, needs rule-#2 sign-off for its game-code HUD wave), so if the user's window/display is wider than 4:3 and the present path letterboxes to preserve aspect, the bars are current intended behaviour rather than a bug. If they appear in a 4:3 window too, it is a real defect (framebuffer sized wrong vs window, `videoCreateFramebuffer(w,h,upscale,autoresize)` → `gfx_create_framebuffer`). | **OPEN — possibly intended pending the widescreen decision; needs one data point.** Next: confirm with the user whether the bars appear at every window size or only when the window is wider than 4:3 (and their current window/monitor aspect); then read the framebuffer-sizing path in `port/src/video.c`/`gfx_pc.cpp` to see what letterboxing, if any, is deliberate. If intended, this row closes as "documented pre-widescreen behaviour" and folds into WIDESCREEN-FOV-PLAN. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software @@ -8915,6 +8918,34 @@ the water quad keeps plain 1:1 tc and tolerates the overflow on its horizon vertex exactly as before. D227's fix stands on its own for the non-water levels; D229 is a separate defect needing its own fix. +**ROOT CAUSE FOUND AND FIXED (port-layer, `port/fast3d/gfx_pc.cpp`).** The water +image (`skywaterimages[2]` = texnum 1509, `IMAGE_WATER_BLUE`, 32×32, level 6) is a +ZLIB+CI8 image: the texture pool holds raw 8-bit palette indices (verified: +the dumped pool block is 1400 bytes, every byte ≤ 23 — exactly the 24-colour +blue palette's index range; the full mipmap chain 32²+16²+8²+4²+2² plus row +padding). `texSelect` (`othermodemicrocode.c`, "Perfect Dark texSelect") loads it +with `gDPSetTextureImage(CI, 16b)` + `gDPLoadBlock` — the 16b "size" is only the +fast3d 4KB-per-block convention (lrs counts 16-bit units: 700 × 2 = 1400 bytes of +8-bit indices). On N64, GE's custom RSP ucode expands the indices through the +TLUT into real 16-bit pixels in TMEM at load time — which is why +`sub_GAME_7F09343C` can then re-declare TMEM slot 0 as RGBA/16b and sample blue. +fast3d has no such expansion: `import_texture_rgba16` read pairs of index bytes +as 16-bit texels, where g = 2·idx mod 32 dominates and r/b ≤ 2 → the green +mottle. The "pulsing" is the pre-existing `sinf()`-driven `PRIM_LOD_FRAC` +cross-fade between the two tiles (both mis-decoded, at different UV offsets), +not a second bug. + +**Fix:** track the source format of each TMEM load (`texture_to_load.fmt` was +previously dropped; now recorded into `LoadedTexture.src_fmt` by +gfx_dp_load_block/gfx_dp_load_tile) and, in `import_texture`, when a tile is +declared RGBA/16b but the slot's last load came from a CI source, route the +import through `import_texture_ci8` (palette expansion via `rdp.palette`) — the +port-layer equivalent of the ucode's expand-at-load. Fires only on the genuine +format disagreement; real RGBA16 textures are untouched. Verified live: Frigate +water renders blue and the user confirms "looks better". Closure still wants the +standard verification ritual (sweep + by-eye on Surface 2, the other IsWater +level). + ## D227 — M-100b: FIXED. The real cause is a unit error (`unk20`/`unk24` are S10.5, not texels); the s16 overflow was its symptom, and so are the over-tiling and the "scrolls too fast" complaint **This supersedes the fix design in M-100 below.** M-100's measurement and its diff --git a/docs/porting-notes.md b/docs/porting-notes.md index bd5ccb6a..c6601e3d 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -732,6 +732,27 @@ through a converter or a runtime bswap fixup reads scrambled. swap regressing muzzle flash/ammo HUD (M-114), reverted to identity, then M-115's format-scoped re-application — is in `docs/dev/findings.md` D219. +- **The tile's declared format is not the source data's format when GE's + custom ucode transforms TMEM at load time (D229).** `texSelect` + (`src/game/othermodemicrocode.c`) loads CI8 water images with + `gDPSetTextureImage(CI,16b)` + `gDPLoadBlock` (the 16b is only the + 4KB-per-block load convention: lrs counts 16-bit units, so 700 × 2 = 1400 + bytes of raw 8-bit indices), and GE's RSP ucode expands indices→RGBA16 in + TMEM at load — after which `sub_GAME_7F09343C` (`src/game/unk_092E50.c`) + re-declares the slot as RGBA/16b and samples blue. fast3d has no such + transform: it trusted the tile declaration, read index byte-pairs as + 16-bit texels (g = 2·idx mod 32 dominates → green mottle), and the water's + pre-existing `sinf()` PRIM_LOD_FRAC cross-fade read as "pulsating". Fix + (`port/fast3d/gfx_pc.cpp`, M-116): record the `G_IM_FMT` of each + SetTexImage into `LoadedTexture.src_fmt` at load time; in `import_texture`, + an RGBA/16b tile whose slot's last load came from a CI source routes + through the CI8 palette import — the port equivalent of expand-at-load. + Fires only on a genuine format disagreement, so real RGBA16 textures are + untouched. **General rule:** when a GE draw declares a tile format that + looks wrong for the data it loaded, suspect the ucode's load-time transform + (ginit.s is missing from this repo — the expansion lives there), not UV, + shade, or the combiner. + ## D2. The HUD/model "X-mirror" (D114/D116) — RESOLVED: it was an upside-down capture **M-33 (finding D168).** There was no mirror. `gfx_opengl_dump_bound_fbo` diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index f41b06e3..35d14c49 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -150,6 +150,11 @@ struct LoadedTexture { uint32_t line_size_bytes; uint32_t tex_flags; struct RawTexMetadata raw_tex_metadata; + /* D229: G_IM_FMT_* of the gDPSetTextureImage active when this slot was + * last written by a load command (0xFF = never / unknown). Used to tell + * a CI8 index stream apart from real RGBA16 pixels when a later tile + * re-declares the same TMEM in another format -- see import_texture. */ + uint8_t src_fmt = 0xFF; }; static struct RDP { @@ -159,6 +164,7 @@ static struct RDP { uint32_t palette_hash; /* D217: FNV-1a of palette[], refreshed in gfx_dp_load_tlut */ struct { const uint8_t* addr; + uint8_t fmt; /* D229: was dropped before; needed to track CI sources */ uint8_t siz; uint32_t width; uint32_t tex_flags; @@ -1107,40 +1113,66 @@ static void import_texture(int i, int tile, bool importReplacement) { * decoding it against the stale rdp.palette produced the blue-speckle roof * (docs/dev/TEXTURE-GLITCH-ANALYSIS.md, B2). Route CI4/CI8 -> I4/I8 here. */ uint8_t fmt_eff = fmt; + uint8_t siz_eff = siz; if (fmt == G_IM_FMT_CI && rdp.palette_fmt == G_TT_NONE) { fmt_eff = G_IM_FMT_I; } + /* D229: green/pulsating sky water. GE's texSelect loads CI8 mipmap chains + * with gDPLoadBlock (SetTexImage format = CI; the 16b "size" is only the + * fast3d 4KB-per-block convention), then the sky-water draw re-declares + * the same TMEM slot as RGBA/16b (sub_GAME_7F09343C). On N64 GE's custom + * RSP ucode expands the indices through the TLUT into real 16-bit pixels + * in TMEM, so the RGBA16 tile samples blue water. fast3d has no such + * expansion: it uploads the raw index bytes as 16-bit texels (g = 2*idx + * mod 32 dominates) -> green mottle. Route the import through the CI8 + * palette path instead -- the port-layer equivalent of the ucode's + * expand-at-load. Only fires when the tile format genuinely disagrees + * with the loaded source, so real RGBA16 textures are untouched. */ + if (fmt_eff == G_IM_FMT_RGBA && siz == G_IM_SIZ_16b && + loaded_texture.src_fmt == G_IM_FMT_CI) { +#ifdef PORT + if (getenv("GE_D229")) { + static int n_ci8r = 0; + if (n_ci8r++ < 8) + sysLogPrintf(LOG_NOTE, "D229: RGBA16 tile over CI8 source -> ci8 import (addr=%p size=%u palidx=%u)", + (const void *)orig_addr, loaded_texture.size_bytes, palette_index); + } +#endif + fmt_eff = G_IM_FMT_CI; + siz_eff = G_IM_SIZ_8b; + } + if (fmt_eff == G_IM_FMT_RGBA) { - if (siz == G_IM_SIZ_16b) { + if (siz_eff == G_IM_SIZ_16b) { import_texture_rgba16(tile, loaded_texture, rdp.tex_lod); - } else if (siz == G_IM_SIZ_32b) { + } else if (siz_eff == G_IM_SIZ_32b) { import_texture_rgba32(tile, loaded_texture, rdp.tex_lod); } else { sysFatalError("Bad size for RGBA texture in tile %d: %02x", tile, siz); } } else if (fmt_eff == G_IM_FMT_IA) { - if (siz == G_IM_SIZ_4b) { + if (siz_eff == G_IM_SIZ_4b) { import_texture_ia4(tile, loaded_texture, rdp.tex_lod); - } else if (siz == G_IM_SIZ_8b) { + } else if (siz_eff == G_IM_SIZ_8b) { import_texture_ia8(tile, loaded_texture, rdp.tex_lod); - } else if (siz == G_IM_SIZ_16b) { + } else if (siz_eff == G_IM_SIZ_16b) { import_texture_ia16(tile, loaded_texture, rdp.tex_lod); } else { sysFatalError("Bad size for IA texture in tile %d: %02x", tile, siz); } } else if (fmt_eff == G_IM_FMT_CI) { - if (siz == G_IM_SIZ_4b) { + if (siz_eff == G_IM_SIZ_4b) { import_texture_ci4(tile, loaded_texture, rdp.tex_lod); - } else if (siz == G_IM_SIZ_8b) { + } else if (siz_eff == G_IM_SIZ_8b) { import_texture_ci8(tile, loaded_texture, rdp.tex_lod); } else { sysFatalError("Bad size for CI texture in tile %d: %02x", tile, siz); } } else if (fmt_eff == G_IM_FMT_I) { - if (siz == G_IM_SIZ_4b) { + if (siz_eff == G_IM_SIZ_4b) { import_texture_i4(tile, loaded_texture, rdp.tex_lod); - } else if (siz == G_IM_SIZ_8b) { + } else if (siz_eff == G_IM_SIZ_8b) { import_texture_i8(tile, loaded_texture, rdp.tex_lod); } else { sysFatalError("Bad size for I texture in tile %d: %02x", tile, siz); @@ -1730,6 +1762,49 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo rendering_state.textures[i]->second.cmt = cmt; } } + +#ifdef PORT + /* D229 probe (env-gated, inert): green/pulsating IsWater water. + * The water quad is a 2-cycle LERP(TEXEL1, TEXEL0) draw binding + * BOTH tiles to the same TMEM with tile 1 offset by uls/ult -- + * the D172 probe's `tmem differs` filter never fires for it. Dump + * each texunit's full decoded tile state + wrap decision + GL + * texture identity so one Frigate capture pins whether TEXEL1 + * resolves to the same image/region as TEXEL0 or drifts into + * other TMEM content (the green). */ + if (getenv("GE_D229") && use_2cyc && comb->used_textures[0] && comb->used_textures[1]) { + static int d229x = 0; + static int d229_total = 0; + d229_total++; + if (d229x < 12) { + d229x++; + const uint32_t tmem = rdp.texture_tile[tile].tmem; + sysLogPrintf(LOG_NOTE, + "D229: texunit%d first=%u lodoff=%u tile=%u tmem=%u fmt=%u siz=%u | " + "uls=%u ult=%u lrs=%u lrt=%u masks=%u maskt=%u shifts=%d shiftt=%d cms=%u cmt=%u | " + "texw=%u texh=%u texw2=%u texh2=%u | " + "wrapS=%d(tw=%.1f,uls=%.1f) wrapT=%d(th=%.1f,ult=%.1f) | " + "tmembytes=%u GLaddr=%p texid=%u glbytes=%u same_tmem_as_other=%d", + i, rdp.first_tile_index, gfx_lod_tile_offset(i), tile, tmem, + rdp.texture_tile[tile].fmt, rdp.texture_tile[tile].siz, + rdp.texture_tile[tile].uls, rdp.texture_tile[tile].ult, + rdp.texture_tile[tile].lrs, rdp.texture_tile[tile].lrt, + rdp.texture_tile[tile].masks, rdp.texture_tile[tile].maskt, + (int)rdp.texture_tile[tile].shifts, (int)rdp.texture_tile[tile].shiftt, + (unsigned)cms, (unsigned)cmt, + tex_width[i], tex_height[i], tex_width2[i], tex_height2[i], + (int)wrap_s[i], wrap_tw[i], wrap_uls[i], + (int)wrap_t[i], wrap_th[i], wrap_ult[i], + rdp.loaded_texture[tmem].orig_size_bytes, + rendering_state.textures[i] ? (const void*)rendering_state.textures[i]->first.texture_addr : nullptr, + rendering_state.textures[i] ? rendering_state.textures[i]->second.texture_id : 0u, + rendering_state.textures[i] ? rendering_state.textures[i]->first.size_bytes : 0u, + (int)(rdp.texture_tile[rdp.first_tile_index + gfx_lod_tile_offset(1 - i)].tmem == tmem)); + } else if (d229_total % 400 == 0) { + sysLogPrintf(LOG_NOTE, "D229: (summary) %d dual-texunit 2-cycle tris so far", d229_total); + } + } +#endif } } @@ -2219,6 +2294,7 @@ static void gfx_dp_set_scissor(uint32_t mode, uint32_t ulx, uint32_t uly, uint32 static void gfx_dp_set_texture_image(uint32_t format, uint32_t size, uint32_t width, uint32_t tex_flags, const void* addr) { rdp.texture_to_load.addr = (const uint8_t*)addr; + rdp.texture_to_load.fmt = (uint8_t)format; /* D229 */ rdp.texture_to_load.siz = size; rdp.texture_to_load.width = width; rdp.texture_to_load.tex_flags = tex_flags; @@ -2350,6 +2426,7 @@ static void gfx_dp_load_block(uint8_t tile, uint32_t uls, uint32_t ult, uint32_t loaded_texture.tex_flags = rdp.texture_to_load.tex_flags; loaded_texture.raw_tex_metadata = rdp.texture_to_load.raw_tex_metadata; loaded_texture.addr = rdp.texture_to_load.addr; + loaded_texture.src_fmt = rdp.texture_to_load.fmt; /* D229 */ rdp.textures_changed[0] = rdp.textures_changed[1] = true; } @@ -2390,6 +2467,7 @@ static void gfx_dp_load_tile(uint8_t tile, uint32_t uls, uint32_t ult, uint32_t loaded_texture.tex_flags = rdp.texture_to_load.tex_flags; loaded_texture.raw_tex_metadata = rdp.texture_to_load.raw_tex_metadata; loaded_texture.addr = rdp.texture_to_load.addr + start_offset_bytes; + loaded_texture.src_fmt = rdp.texture_to_load.fmt; /* D229 */ rdp.texture_tile[tile].uls = uls; rdp.texture_tile[tile].ult = ult;