D235: wire videoResetTextureCache into per-stage reset (stale GL texture cache); D311: arm speed-poison choke-point probes

D235 (wrong textures after dying/restarting a level) -- mechanism found
+ fix (M-193). M-108's refutation of the game-side CI-cache theory stands,
but the port has its own TextureCacheMap (port/fast3d/gfx_pc.cpp) keyed by
(source address, palette addrs, fmt, siz, size_bytes, palette_hash) with NO
content check. Stage-texture source addresses live in MEMPOOL_STAGE, which
the per-stage while(!done) block in boss.c resets on every transition
(death/restart included). After the reset, entries keyed into that arena are
stale; if the texpool places a different image at an address that held
another one in the previous run of the level, the lookup hits and returns
the old pixels -- 'wrong textures' only on the reload path, never on a cold
boot. The N64 has no such cache (RSP re-reads DRAM every frame), so this is
a pure port-side defect: videoResetTextureCache() (port/src/video.c) existed
with zero callers and is now called in that block after memaReset
(#ifdef PORT). Re-uploads are pixel-identical; cost is one-time during the
loading screen. GE_D235=1 logs the purged entry count per transition
(cold boot = 0, death/restart > 0) as mechanism evidence; new
gfx_texture_cache_count() accessor backs it. Full 21-level golden sweep:
21/21 PASS (pixel-identical). Live death/restart confirmation is a playtest
item.

D311 (upstream poison source, M-194): every write of model->speed funnels
through modelSetAnimSpeed (startframe<=0 path) or modelSetAnimation2 -- both
now log 'D311: ... ANOMALY' when the incoming value or the resident speed is
non-finite/|v|>1e4 (#ifdef PORT, unconditional-on-fire like the D156
guards; silent in healthy play). The line captures in/resident/startframe/
oldspeed/newspeed/timespeed + model ptr so the next Caverns occurrence pins
the injecting call. Static pass: sub_GAME_7F06FE90 callers all pass
arg2=16.0f (denominator safe); the old->new lerp only circulates values;
bheadAdjustAnimation is the one path where a live player-velocity
corruption reaches model->speed.
This commit is contained in:
James Dansereau committed 2026-09-20 23:46:43 -04:00
1 parent 8d3bd5732a
commit bb508e5ce4
6 files changed
+67 -3

No files matched your search

+3 -2
View File
@@ -3,7 +3,7 @@
Every `getenv("GE_…")` in the tree. The table prose is hand-curated;
**`tools_pc/gen_env_probes.py`** re-greps the live sites and reports drift
(NEW / GONE vars + a fresh file:line map) — run it before trusting the
File:line cells. Last reconciled 2026-09-20 (D322 probe added; v0.3.0 release review was 2026-09-20). All are **env-gated**: unset = zero behavior
File:line cells. Last reconciled 2026-09-21 (GE_D235 added, D235 fix wired; GE_D288 refined per M-155 follow-up). All are **env-gated**: unset = zero behavior
change, so they are safe to leave in a release build (they only cost a
`getenv` on the guarded path). "Dead" below means *the finding it was built
for is closed* — the probe is strip-candidate scaffolding, not that it does
@@ -62,7 +62,7 @@ Two classes:
| `GE_DTEX` | `port/fast3d/gfx_pc.cpp:1001` | Per-`import_texture` param log (dims / line / size / lod / gen_mipmaps). Used to root-cause RC2 and D159. Kept as an inert diagnostic. | **live** (RC2/RC3 not finished) |
| `GE_TEXDUMP` | `port/fast3d/gfx_pc.cpp:1020`, `port/fast3d/gfx_opengl.cpp:700` | PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx/pal[0..3]` line. Root-caused D161 (Depot ceiling). Kept as an inert diagnostic. | **live** (RC3 not finished) |
| `GE_D116` | `port/fast3d/gfx_pc.cpp:1710,2362`, `src/game/textrelated.c:265,541` (cached `ge_d116`) | HUD/text "X-mirror" (D114/D116) vbo/uv trace. **D114/D116 CLOSED — NOT A BUG (M-33/D168): the captures were upside-down, not mirrored.** Probe is now dead scaffolding — strip on the next cleanup pass. | dead (D114/D116 closed) |
| `GE_D288` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1`, right after the D233 trivial-reject guard; cached `ge_d288`) | M-152 diag for the Silo intro stray triangle: logs any triangle whose clip-space NDC bbox covers >15% of the screen or goes non-finite, with each vertex's `w` and `any_behind_camera`. **Investigation in progress** — narrowed to two candidates (an unguarded `w`-division NaN leak in the backface-cull test just below, or the D106/D271 portal-BFS family), not yet root-caused; needs a live/headless `-level_20` run to disambiguate. | **live** (D288) |
| `GE_D288` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1`, right after the D233 trivial-reject guard; cached `ge_d288`) | M-152 diag for the Silo intro stray triangle: frame-gated to `num_dls` 550–755 (M-155), now **skips `any_behind_camera==1` triangles** (M-155 follow-up — the x/w divide is meaningless for w≤0; that subset was pure false-lead noise) and logs the render-mode word (`oml`) so a live capture can ID the culprit asset against level tables. Logs any triangle whose clip-space NDC bbox covers >50% of the screen or goes non-finite, with each vertex's `w`. Bare `-level_20` re-run: 1965 sane hits in the window, still zero for the target color RGB(83,62,44) — consistent with M-155's working theory that the bare boot's scripted camera never reaches the bug state; **headless probing is at its limit, live capture is next**. | **live** (D288) |
| `GE_D236RM` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1`, unconditional getenv, no state kept when unset) | M-155/pass-10 diag for D236: per-frame (`num_dls`) triangle count of the `0x0c184b50` render-mode class (`other_mode_l` exact match), heartbeat-logged every 300 frames even at zero. **REFRAMED D280's "run-to-run dependent instantiation" as ordinary room-based occlusion**, not a CPU-side gating bug — see `## D236` pass-10 writeup. | **live** (D236 OPEN, kept for future passes) |
| `GE_D236ORDER` | `port/fast3d/gfx_pc.cpp:1844` (`gfx_sp_tri1`, cached `s_d236order`) | D236 pass-12 diag: per-DL sequence log of the tree-class triangles (DL index, screen-space depth, noise min/max census) to test the draw-order hypothesis. Zero cost unset; remove once D236 concludes. | **live** (D236 OPEN) |
| `GE_D236ALPHA` | `port/fast3d/gfx_pc.cpp:1896` (`gfx_sp_tri1`, cached `s_d236alpha`) | D236 pass-14 diag: for the `0x0c184b50` tree-class render mode, logs the combine/alpha state of up to 4000 matching triangles (deduped on combine change) so the captured draw can be cross-checked against the source. Zero cost unset; remove once D236 concludes. | **live** (D236 OPEN) |
@@ -71,6 +71,7 @@ Two classes:
| `GE_D245V` | `src/game/sky.c:962` (`skyPortRenderPoly`-adjacent, `#ifdef PORT`) | D245 water-seam diag: per-call min/max of the recomputed S/T texture-coordinate ranges for the water fan vertices (rate-limited), to check whether the seam tracks a tc-shift boundary. Inert unset. | **live** (D245 OPEN) |
| `GE_D245_FIXEDSHIFT` | `src/game/sky.c:946` (`#ifdef PORT`) | D245 test override: pins the water tc shift `k` to the given integer (0..SKY_TC_MAX_SHIFT) instead of the computed per-vertex value, to A/B the allowShift=TRUE behavior. Off by default (unset = current behavior). | **live, test-only** (D245 OPEN) |
| `GE_D252` | `port/fast3d/gfx_pc.cpp:733` (`import_texture`, cached `s_d252`) | D252 rainbow-particle diag: one-shot per (address,size) import log of the tile state (tmem/fmt/siz/addr/size/line) for small textures so a suspect particle tile can be checked against its source bytes by hand. Remove once D252 is resolved. | **live** (D252 OPEN — non-reproducible) |
| `GE_D235` | `src/boss.c` (per-stage `while (!done)` block, after `memaReset`; `#ifdef PORT`) | D235 evidence probe: logs how many GL-texture-cache entries the per-stage `videoResetTextureCache()` call purges at each level load. A cold boot into a level should log 0; a death/restart (or any transition from a level that uploaded textures) logs >0 — direct evidence of the stale-entry mechanism. The clear itself is **not** gated on this var (it's the fix, see below); the var only controls the log line. | **live** (D235) |
## Dxx diagnostic probes — finding closed unless noted
+5 -1
View File
@@ -10728,6 +10728,8 @@ D243 (cutscene shake/duplication, its own CONSOLIDATED NEXT STEPS block appears
**Status (from §F table):** OPEN — needs: does it repro on a *fresh* level boot (rules out reload-state) vs only after death/restart (confirms it); which surfaces; a screenshot. **M-108 static-analysis pass: the leading "stale CI-cache not reset on restart" hypothesis is likely REFUTED, don't re-propose it as-is.** The theory was that `g_TexCacheItems`/`g_TexCacheCount` (`src/game/image.c:38-40`, cleared only in `set_mt_tex_alloc()`, `src/game/initmttex.c:8`) survives a death/restart because the restart path skips `set_mt_tex_alloc`. But tracing the actual call graph: `set_mt_tex_alloc()` runs inside `lvlStageLoad()` (`src/game/lv.c:369`), which has exactly **one** call site (`src/boss.c:485`) inside `bossMainloop`'s outer `while (!done)` loop; that loop re-runs on *every* stage transition, driven by `g_StageNum = g_MainStageNum` (`boss.c:693`) which is set by `bossSetLoadedStage()` (`boss.c:722`) — the same function the front-end's level-select/continue path calls (`front.c:6947`). Nothing in this call graph is level-select-specific vs. restart-specific; both look like they go through the identical full reload (including the `set_mt_tex_alloc` cache reset). Have not yet found the actual death/restart trigger's call site to be 100% certain it doesn't have its own bypass, so this isn't fully closed out — but the single-reload-path structure makes the "cache survives restart" mechanism unlikely as stated. Next: find the actual death-handler call site (search for where a life/continue/retry decision is made, likely `chr.c`/a game-state machine, not yet located) and confirm whether it calls `bossSetLoadedStage`/goes through `bossMainloop`'s reload path same as normal transitions, or has a distinct lighter-weight path — if it's the same path, the real D235 mechanism is still unknown and this CI-cache theory should be dropped.
**M-193 (v0.4.0 Phase 1, 2026-09-21): port-side GL texture cache found — the M-108 refutation stands for the *game-side* CI cache, but the port has its own cache with the exact same structural hazard, and it was never invalidated on stage transitions.** The PC renderer keeps a `TextureCacheMap` (`port/fast3d/gfx_pc.cpp`) mapping `(source address, palette addrs, fmt, siz, palette_index, size_bytes, palette_hash)` → uploaded GL texture, with **no content check** — a hit trusts the key. Stage textures' source addresses live in the `MEMPOOL_STAGE` arena, which the per-stage `while (!done)` block in `boss.c` resets via `mempResetBank`/`memaReset` on *every* transition (death/restart included — M-108's call-graph trace already established both paths re-run that block). After the reset, any cache entry keyed into that arena is stale; if the texpool places a different image at an address that held another image in the previous run of the level, the lookup hits and returns the old pixels = "wrong textures after dying/restarting", never on a cold boot (empty cache). The N64 has no such cache (the RSP re-reads DRAM every frame), so this is a pure port-side defect — fixable in `port/`-adjacent code with no rule-2 sign-off. **Fix applied:** the orphaned `videoResetTextureCache()` (`port/src/video.c`, zero callers) is now wired into that per-stage block right after `memaReset`/`reset_play_data_ptrs` (`#ifdef PORT`) — a full clear on every stage load. Re-uploads are pixel-identical (same content → same GL upload), cost is one-time during the loading screen; ROM-segment textures (menu/HUD) just re-upload too, harmlessly. `GE_D235=1` logs the purged entry count per transition (cold boot = 0, death/restart > 0) as direct mechanism evidence. **Remaining:** live death/restart confirmation at playtest (wrong textures gone?) + a `GE_D235` capture showing >0 on a restart; if symptoms persist after this, the next suspect is intra-level texpool eviction reusing addresses mid-play (nothing emits `G_INVALTEXCACHE_EXT`, so per-address staleness is possible there too — the handler exists at `gfx_pc.cpp:3624` but has no emitter).
## D236 — Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).
**Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).** Eight passes so far (D265, D266–D270, D271, D272, D273, D274, D275–D276). Ruled out: IA4/IA8 decode (as originally scoped), the blend pipeline, fog combine (as originally scoped), portal-culling over-draw (D271, real bug, fixed, not the cause), converter/UV-conversion bugs (D272, code-read clean), the CI palette bit-layout (D273/D274, tested twice against real content), "mips absent" (D274, confirmed present/enabled), "identical UV window" *for the CI8 classes* (D272). **Current leading picture (D276, M-134): the actual visible weave at confirmed weave pixels is painted by a different, previously-dismissed class — an IA8 blocky-noise texture (`oml=0xc81049d8`/`fmt=3`) sampled with a fixed, byte-identical 2×-wrapped UV window and low fog, reproducing D265's original "same window" observation exactly (D269's CI8-classes framing had reframed away from that lead prematurely).** A third, unattributed CI8 class (`0x0c184b50`) may be the reference's actual discrete-tree layer and shows an erratic z/w suggesting its own occlusion/billboard bug — D236 may split into two root causes. **Ninth pass (D280, M-135): fog math verified N64-faithful (±2 truncation over 2505 verts), the IA8 noise class visually confirmed as the weave's painter (HF 14.8→2.9 when collapsed), `0xc8102078` ruled out (terrain, 1.4M tris), and the depth-race read inverted — `0x0c184b50` tree cards are IN FRONT of the noise quad (z/w smaller=closer) yet still don't visibly win; under deterministic `-level_36` boot they barely instantiate at all (~4 tris vs 720 in a free run). No quick win: remaining work is CPU-side instantiation/room-streaming, not a port fix.** **Tenth pass (M-155): that CPU-side-instantiation framing itself is REFUTED — three independent deterministic re-runs of the same `-level_36` boot (one with sustained camera rotation) proved the class's per-frame count is byte-identical, not run-to-run dependent; the earlier "~4 vs 720" comparison was an outdoor-vs-indoor room mismatch (the boot's scripted intro walks the camera out of the tree-bearing room at a fixed frame), not nondeterminism. No fix; the original wall-vs-discrete-sprites symptom is unresolved and untouched by this pass.** Distinct from D176(b)'s painted tree-line backdrop. Logged in `GRAPHICS-BACKLOG.md`.
@@ -11983,7 +11985,9 @@ This confirms the M-160/M-164/M-165 hypothesis (`## D173`'s own consolidated-nex
**Open question (deeper root cause, NOT fixed):** what wrote `-1.75e18` into `oldspeed` / poisoned `animframe1` in the first place? `oldspeed = model->speed` at transition start (`model.c:2975`) means `speed` was already garbage upstream; `timespeed = 0` and `unkb0 = 0` at the guarded tick are suspicious division denominators (D156's original note flagged `/ unkb0` and `/ timespeed` as guarded `> 0` but not against tiny values). Also, the model's persistent root-motion header (`header->unk34/unk24`) is poisoned to ~1e30–1e32 in this instance, so **Bond's rendered model may be displaced even after the freeze is prevented** — a level reload clears it. Needs its own investigation (dump speed-field write sites during the triggering transition) before any further fix.
**Status:** GUARDED (freeze class neutralized; upstream poison source OPEN). **RULE-2-SIGNOFF (2026-09-19, release review — M-192 item 2):** user approved retention as the stability-maximizing option; classified as a defensive guard against non-finite state in the same class as the accepted D156 guards — it fires only on poison values that indicate an already-broken state and snaps to a valid frame rather than altering any legitimate animation. Previously disclosed in-thread as a D156-guard extension following the already-accepted D156 precedent in this same file; revert is still two small blocks. Cross-refs: D156 (original guard + Facility outro hang), D309/D310 (ruled out for this freeze — no AI involvement).
**M-194 (v0.4.0 Phase 1, 2026-09-21): upstream poison source — static pass + choke-point instrumentation.** Every write of `model->speed` funnels through exactly two functions: `modelSetAnimSpeed` (startframe≤0 path) and `modelSetAnimation2`. Both now log a `D311: … ANOMALY` line when the incoming value OR the already-resident `model->speed` is non-finite or |v|>1e4 (`#ifdef PORT`, unconditional-on-fire like the D156 guards — silent in healthy play). The line captures in/resident/startframe/oldspeed/newspeed/timespeed + model pointer, so the next occurrence pins the exact injecting call. Static pass over the computed-speed callers: `sub_GAME_7F06FE90` (the `t = speed + 2·φ/arg2` divider) is only ever called with arg2=16.0f — its denominator is safe, though a garbage `animframe1` would poison its numerator; `bheadAdjustAnimation` divides by the *static* `g_BondMoveAnimationSetup[].speedMultiplier` table (bondhead.c:19) — safe constants, but its `speed` parameter is the player's live movement speed, so an upstream velocity-field corruption would flow straight into `model->speed`; the old→new lerp at model.c:3654/3663 only circulates already-poised values (no new garbage). Live repro (Caverns, ~2 min in) remains the trigger; the probe turns that occurrence into a one-line diagnosis.
**Status:** GUARDED (freeze class neutralized; upstream poison source OPEN — M-194 probe armed). **RULE-2-SIGNOFF (2026-09-19, release review — M-192 item 2):** user approved retention as the stability-maximizing option; classified as a defensive guard against non-finite state in the same class as the accepted D156 guards — it fires only on poison values that indicate an already-broken state and snaps to a valid frame rather than altering any legitimate animation. Previously disclosed in-thread as a D156-guard extension following the already-accepted D156 precedent in this same file; revert is still two small blocks. Cross-refs: D156 (original guard + Facility outro hang), D309/D310 (ruled out for this freeze — no AI involvement).
## D312 — Throwable items (mines, grenades, etc.) can clip through walls in certain spots (user report, v0.3.0 pre-release playtest, 2026-09-18) — FIXED: `bgBuildRoomVtxBounds` mis-reads the G_VTX vertex count on PC (Gfx_le `dma.par` bit-layout shift), collapsing whole batches of wall geometry out of the collision query (diagnosed + fixed, 2026-09-18).
+1
View File
@@ -56,6 +56,7 @@ void gfx_set_safe_area_crop(int on); /* crop the N64 TV-overscan safe-area
* (see port/src/optionsoverlay.c, D316). */
void gfx_get_ui_screen_rect(int32_t *outX, int32_t *outY, int32_t *outW, int32_t *outH);
void gfx_texture_cache_clear(void);
int gfx_texture_cache_count(void); /* D235 */
void gfx_texture_cache_delete(const uint8_t *orig_addr);
void gfx_texture_cache_delete_range(const uint8_t *start, const uint8_t *end);
int gfx_create_framebuffer(uint32_t width, uint32_t height, int upscale, int autoresize);
+5
View File
@@ -563,6 +563,11 @@ void gfx_texture_cache_clear() {
memset(rendering_state.textures, 0, sizeof(rendering_state.textures));
}
/* D235: entry count for the stage-transition probe (boss.c). */
extern "C" int gfx_texture_cache_count(void) {
return (int)gfx_texture_cache.map.size();
}
static bool gfx_texture_cache_lookup(int i, const TextureCacheKey& key) {
TextureCacheMap::iterator it = gfx_texture_cache.map.find(key);
TextureCacheNode** n = &rendering_state.textures[i];
+28
View File
@@ -469,6 +469,34 @@ void bossMainloop(void)
memaReset(mempAllocBytesInBank(g_CurentMaMallocValue, MEMPOOL_STAGE), g_CurentMaMallocValue);
reset_play_data_ptrs();
#ifdef PORT
/* D235 (wrong textures after dying/restarting a level): the port-side
* GL texture cache (port/fast3d/gfx_pc.cpp TextureCacheMap) is keyed by
* (source address, palette addrs, fmt, siz, size_bytes, palette_hash)
* with NO content check. The MEMPOOL_STAGE arena those stage-texture
* source addresses live in was just reset above, so every entry
* pointing into it is stale. On a death/restart the texpool can place
* a DIFFERENT image at an address that held another one during the
* previous run of this level -> cache hit returns the old image =
* "wrong textures" that only ever appear on the reload path, never on
* a cold boot. The N64 has no such cache (the RSP re-reads DRAM every
* frame), so clearing it here is a port-only correctness fix; the
* re-uploads are pixel-identical and cost one-time during the loading
* screen. videoResetTextureCache() existed for exactly this and had
* zero callers -- this is the wiring. GE_D235=1 logs how many stale
* entries each transition actually purged (evidence for the finding;
* a cold boot into a level should log 0, a death/restart >0). */
{
static int ge_d235 = -1;
if (ge_d235 < 0) ge_d235 = getenv("GE_D235") != NULL;
extern void videoResetTextureCache(void);
extern int gfx_texture_cache_count(void);
if (ge_d235)
osSyncPrintf("D235: stage=%d texcache entries purged at load=%d\n",
(s32)g_StageNum, gfx_texture_cache_count());
videoResetTextureCache();
}
#endif
localSelectedNumPlayers = 0;
if (g_StageNum != LEVELID_TITLE)
+25
View File
@@ -2717,8 +2717,26 @@ void modelCopyAnimForMerge(Model *model, f32 timemerge)
}
#ifdef PORT
/* D311 (M-194): the upstream poison source is still unknown -- GDB caught
* oldspeed=-1.75e18 / newspeed=-3.2e7 but not which call first wrote a
* garbage speed. Every write of model->speed funnels through
* modelSetAnimSpeed (startframe<=0 path) or modelSetAnimation2, so flag any
* anomalous value entering OR already resident at those two choke points.
* Fires only on |v|>1e4 or NaN -- silent in healthy play; the line pins the
* exact transition that poisons the model. Same unconditional-on-fire
* pattern as the D156 guards (which log when they fire, no env var). */
static int d311Anomalous(f32 v) { return (v != v) || (v > 10000.0f) || (v < -10000.0f); }
#endif
void modelSetAnimation2(Model *model, ModelAnimation *anim, s32 flip, f32 frame, f32 speed, f32 arg5)
{
#ifdef PORT
if (model != NULL && d311Anomalous(speed)) {
osSyncPrintf("D311: setanimation2 ANOMALY in=%.6g resident=%.6g frame=%.6g model=%p\n",
(double)speed, (double)model->speed, (double)frame, (void *)model);
}
#endif
s32 hadNoAnim = !model->anim;
s32 padding;
s32 type;
@@ -2953,6 +2971,13 @@ void sub_GAME_7F06FE44(Model *model, s32 arg1) {
void modelSetAnimSpeed(Model *model, f32 anim_speed, f32 startframe) {
#ifdef PORT
if (model != NULL && (d311Anomalous(anim_speed) || d311Anomalous(model->speed))) {
osSyncPrintf("D311: setanimspeed ANOMALY in=%.6g resident=%.6g startframe=%.6g "
"oldspeed=%.6g newspeed=%.6g timespeed=%.6g model=%p\n",
(double)anim_speed, (double)model->speed, (double)startframe,
(double)model->oldspeed, (double)model->newspeed,
(double)model->timespeed, (void *)model);
}
/* D243 M-180: log all modelSetAnimSpeed calls during scripted camera modes
* to identify if unusual speed values are being set during cutscenes.
* Env-gated on GE_D243M (already cached elsewhere). */