D320: resolve sweep via forced repro — ai_19/ai_9 pins confirmed, ai_12 clean; watchdog generalized (table-driven + uncapped repeat rescue); D318 N64-manifestation corrected (unverified; 1964 deliberate-timing A/B armed)

Also commits the prior session's carried-uncommitted set: D294 room-pool
fix (sqrt + 2x cap, user-verified state), D324 stdlib-shim prototypes,
D323 explicit-LOD census + tools_pc tooling, GRAPHICS-BACKLOG row.
texdump*/ probe artifacts gitignored.
This commit is contained in:
James Dansereau committed 2026-09-22 22:30:32 -04:00
1 parent 12490b95e6
commit 97c7e38845
18 files changed
+1375 -112

No files matched your search

+3
View File
@@ -170,6 +170,9 @@ docs/img/shots/CONTACT-SHEET.jpg
/audiodump.raw
/audiotrace.log
/mixertrace.log
# texture-dump probe artifacts (regenerable via GE_TEXDUMP)
/texdump/
/texdump_p23_old/
# Claude Code local state / agent worktrees
.claude/
+1
View File
@@ -34,6 +34,7 @@ once the game is playable start-to-finish without crashes.
| **D197** | Character face/head textures wrap wrong — the face texture repeats around the side of the head "and continues weird" instead of clamping to the front. Seen on **Silo** (M-49 playtest), intermittent ("sometimes"). Reads as **`G_TX_CLAMP` not honoured** for the character-skin tile in fast3d (→ `GL_REPEAT`, so the face UV region tiles around the skull), or wrong `shifts`/`shiftt` / LOD-tile selection on the head texture, or the D167 non-PoT wrap-period issue (head textures are often non-PoT) hitting skinned models. | **Observed, not investigated** (M-49, user bug report — needs a capture + which chr/level). Distinct from D176(b)/D182(2) (those are *bg wall* pitch shear). Related: RC3/D167 (`Video.WrapFix`, non-PoT wrap period) + the `texWriteTileLods` `shifts=shiftt=lod` note in findings §F "D176(b) → 4. candidate 2". First checks: does `gfx_pc.cpp` map N64 `cms/cmt` `G_TX_CLAMP` → `GL_CLAMP_TO_EDGE` for character DLs; is the head tile getting the base tile vs an LOD tile (D107); try `Video.WrapFix=1` on a repro. |
| **D195** | Transparency / alpha-blended surfaces render wrong — reported on **Control** (Severnaya bunker basement — Natalya's console room; heavy on glass partitions and the translucent projected/holographic wall displays). Exact failure mode not yet pinned by the reporter (M-49 playtest): candidates are alpha-blended geometry drawing fully opaque, translucent panels drawing invisible, or draw-order/sort so translucent surfaces occlude what's behind. | **Observed, not investigated** (M-49, user bug report — needs a `GE_PCDUMP` capture + which surfaces). fast3d render-mode / combiner path. Same neighborhood as D161 (CI8 tile + `G_TT_NONE` → garbage), D172 (particle-sprite channel order), D176(b) (texture pitch shear) — but this is a *blend/alpha* defect, not a texture-decode one. Note D128 already touched Control's `specialportalarray` (portal struct stride) — check whether the affected surfaces are portal-adjacent. First checks: does `gfx_pc.cpp` honour `G_RM_*` XLU render modes + `G_AC_THRESHOLD`/`G_CC` alpha for these DLs; is `gDPSetRenderMode` `w1` being parsed (cf. findings §F "zbuf-clear w1 scribble"); is Z-write-vs-blend ordering right for XLU surfaces. Get the reporter to say: opaque-where-should-be-clear, invisible, or wrong edges? |
| **D252** | Particle effects cycle through a rainbow palette — bullet-impact sparks and lingering smoke/explosion residue drift through the full hue wheel over time (~10 shots to sweep it) instead of holding their intended grey/orange palette (M-124, user playtest). Full mechanism + suspect in findings §F **D252**: colour comes from 15 distinct `IMAGE_SMOKE_N`×`IMAGE_FIRE_N` (RGBA16) pairs the particles age through; prime suspect is `import_texture_rgba16` deriving width/height from the tile line size (32×7 instead of 16×14 for the fire tiles), scrambling each stage's sprite into a different wrong hue. | **Documented only — not fixed for v0.2.0-pre** (user instruction). Confirm with one dumped fire tile vs raw `IMAGE_FIRE_N` bytes, then derive width/height from the LOADBLOCK dtx/dlh. D172/M-82 lineage. |
| **D323** | Distant geometry on 15 levels samples driver-generated mips from the finest texture level; N64 samples Rare's hand-authored mip chain for explicit-LOD textures bound under `G_TL_LOD` (heaviest: Statue 378 binds, Aztec 806). Overwhelmingly CI8/IA8 — subtle fidelity gap, no reported artifact; the only RGBA5551 cut-outs are the D236 set (already handled). | **OPEN / parked** — mechanism confirmed, census in `tools_pc/bg_lod_audit.py`, fix path documented in findings §F **D323** (upload GL mip chains for TEXTLOD-on bindings). Not worth doing speculatively; revisit only if a playtest flags mushy distant detail. |
| **1P weapon viewmodel** | **RESOLVED (M-83, D215).** In-level first-person weapon models drew nothing. Root cause: `gunRenderFirstPersonGunModels` built its `ModelRenderData` via `*(ModelRenderData*)&D_80035CC0` — a reinterpret across two separately-declared adjacent globals that only lands right on the N64 linker + 4-byte pointers; on x86-64 `renderdata.flags` read 0, gating every weapon DL node off in `modelRenderNodeGundl`. All 3 ranked suspects in VIEWMODEL-RESEARCH.md were ruled out first by the `GE_DVM=1` probe. Fixed `#ifdef PORT` (explicit field init). `subdraw` 1→153 gfx cmds; owed one BUNKER1 eyeball. |
## Resolved (for reference)
+4 -2
View File
@@ -171,7 +171,7 @@ D290,OPEN,"Watch's in-game ""controller"" section shows no control-pad graphic (
D291,OPEN,"Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).","OPEN — observed, not investigated."
D292,FIXED,Death animations are glitched —,"MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed alongside D173) —"
D293,FIXED,"QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest —","FIXED (M-141) — ""Quit to desktop"" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.…"
D294,OPEN,"Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later… —",OPEN — not investigated.…
D294,FIXED,"Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later… —",FIXED pending user playtest (2026-09-22) —
D295,FIXED,"Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtes…",FIXED (M-149) — M-148 root-caused + reproduced on HEAD; fix landed:
D296,OPEN,"Control Center, 00 Agent:","OPEN — single live report, un-triaged; observed only under a gdb-attached debug-harness launch (tools_pc/debug.ps1), not the packaged release artifact —"
D297,FIXED,v0.3.0 silently wipes ALL pre-fix player saves on first launch (D284 side effect; found during D295/M-148).,FIXED (M-150) — migration shim landed:
@@ -197,6 +197,8 @@ D316,FIXED,"Mouse-clicking some menu items glitches and lands on the item below
D317,CLOSED,"Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) —","CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on ""All unlocked"" fall through to the adjacent ""Quit to desktop"" row)."
D318,RESOLVED,Facility (regular play path):,RESOLVED (2026-09-19):
D319,FIXED,"Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).",FIXED — new ROW_FPSCAP cycle (30/60 only) in port/src/optionsoverlay.c; default changed to 60 in port/src/video.c.
D320,OPEN,"D318-class sweep: aim-hold → _update AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).","OPEN — static sweep complete (Facility ai_19, Control ai_9, Depot ai_12 flagged high/med-high; Bond-combat loops likely safe); no live confirmation beyond D318 itself; probe-verify path + generalized…"
D320,RESOLVED,"D318-class sweep: aim-hold → _update AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).",RESOLVED (2026-09-22):
D321,CLOSED,D318 trigger chain fully mapped:,CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play.
D322,OPEN,Long-session audio degradation:,"OPEN — static triage done (teardown audit, mixer statelessness, D202-coverage check); ranked hypotheses:"
D323,OPEN,G_TL_LOD bindings of explicit-LOD textures:,"OPEN / parked — mechanism confirmed in code; census (tools_pc/bg_lod_audit.py) shows 15 levels with explicit-LOD textures bound under TEXTLOD-on classes (stat 378, azt 806 binds heaviest); overwhelmi…"
D324,FIXED,"GE's <stdlib.h> stub declares nothing, so TUs that include it get NO CRT prototypes —",FIXED (2026-09-22) — proven impact:
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
171 D291
172 D292
173 D293
174 D294
175 D295
176 D296
177 D297
197 D317
198 D318
199 D319
200 D320
201 D321
202 D322
203 D323
204 D324
+79 -10
View File
@@ -579,7 +579,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D291 | **Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).** — full `## D291` entry at file tail | OPEN — observed, not investigated. Needs a live repro with the truck's exact position/timing and, ideally, a screenshot. |
| D292 | **Death animations are glitched — Bond is often sunk halfway into the floor (M-141, Steam Deck user playtest).** — full `## D292` entry at file tail | **MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed alongside D173) — same fix, same outcome: right 9/10 times, small residual offset only when off, no more full sinking/glitching.** See D173 for the user's exact confirmation wording; not separately re-tested, reported together. |
| D293 | **QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest — "couldn't see a way to exit the game. Maybe build this into the…** — full `## D293` entry at file tail | FIXED (M-141) — "Quit to desktop" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.… |
| D294 | **Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later…** — full `## D294` entry at file tail | OPEN — not investigated.… |
| D294 | **Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later…** — full `## D294` entry at file tail | **FIXED pending user playtest (2026-09-22)** — root cause: room-model POOL exhaustion (authored `-ma220`=220 KB < ~250 KB monument-plaza demand at FovScale 130 + max DD/Lod); `memaAlloc` NULL → silent skip → undrawn room shows the night-sky clear colour. Stochastic (wall-clock seed → load order). Fix: `portRoomPoolScale()` (video.c, identity at N64-faithful settings) applied in `bossMainloop`; verified headless (faithful→exact authored size; user settings→0 failures; `GE_ROOMPOOL=0.25`→exhaustion on demand). Playtest: main patch gone; residual corner flicker traced to full-level residency (487 KB = 2.2× authored) starving the 2× pool; the 3×/linear fix REGRESSED the renderer on the menu-driven path (D156 NaN anim state, not a bank overflow) → reverted to sqrt + 2.0 cap (ma=450560). Residual open; probes in place for next capture. High-DD horizon edge on night levels = setting-inherent, documented only.… |
| D295 | **Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtest).** — full `## D295` entry at file tail | FIXED (M-149) — M-148 root-caused + reproduced on HEAD; fix landed: port-side JPN-cache seed (`port/src/main.c`) + `#ifdef PORT` buffer widen in `front.c` (D8 class). Verified: previously-crashing input now clean, 21/21 sweep PASS. Companion save-wipe regression → D297. History — M-148 fully root-caused + **reproduced on current HEAD**: `difficultytext[4]` (`front.c:2616`) overflow — `strcpy("00 Agent")`/`strcat("\n")` spills onto `textpos.p[0]` (y, ≥0x80 in normal geometries) → high-bit byte → `langGetJpnCharPixels` NULL deref (`language.c:320`, `g_JpnCacheCacheItems` NULL outside JPN mode — likewise unallocated on N64 non-JP builds, crash potential is decomp-native). The user's save is a *valid pre-D284* save (all 5 slot CRCs bit-exact under the old PRNG); post-fix builds wipe it at validation, which is why the unmodified artifact no longer triggers it — NOT a fix. No OOB exists (M-147 guess retracted: 007 difficulty has no stored times). Fix options flagged for sign-off (port-side JPN-cache init preferred; `difficultytext` enlarge = D8 class). Side finding: v0.3.0 silently wipes all pre-fix saves on first launch (D284 side effect — own D entry owed). Separate still-open issue found: unconditional `[EVTQ-DROP]` audio-pool-exhaustion logging, `src/libultra/audio/event.c:92`.… |
| D296 | **Control Center, 00 Agent: noticeable stutter, user's own read is "maybe from the drone guns" (M-146, same D295 investigation session).** — full `## D296` entry at file tail | OPEN — single live report, un-triaged; observed only under a gdb-attached debug-harness launch (`tools_pc/debug.ps1`), not the packaged release artifact — user flagged that harness overhead has caused false stutter before. Needs a plain-exe / real-release repro check before any perf tracing.… |
| D297 | **v0.3.0 silently wipes ALL pre-fix player saves on first launch (D284 side effect; found during D295/M-148).** — full `## D297` entry at file tail | FIXED (M-150) — migration shim landed: `port/src/legacycrc.c` (isolated, deliberately-wrong pre-D284 PRNG, reviewer-verified bit-exact against `0de99b4d^`) + eeprom-shim read-path hook in `libultra.c` (block 4 only; persists pristine+migrated bytes before the read-time-only AllUnlocked patch). Verified: user's #87 artifact → `D297-migrated 5`, all slots OK, idempotent on relaunch; native post-fix save untouched; corrupt slot left to the game's own wipe path; fresh eep untouched; 21/21 sweep PASS. Block-0 header needed no migration (factory seal, verified). |
@@ -603,11 +603,13 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D315 | **Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D315` entry at file tail | OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix). |
| D316 | **Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D316` entry at file tail | FIXED — root-caused to two compounding bugs (naive mouse->logical mapping ignoring the safe-area crop; asymmetric row hit-test band) in `port/src/optionsoverlay.c` + `port/fast3d/gfx_pc.cpp`. Build-verified (golden diff PASS, worst_cell=0); interactive click verification owed. |
| D317 | **Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) — no dump left behind.** — full `## D317` entry at file tail | CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on "All unlocked" fall through to the adjacent "Quit to desktop" row). Same fix resolves it. |
| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | RESOLVED (2026-09-19): as-authored latent race (gas bit mid-monologue derails c78's pre-aim hold into a permanent ACT_ATTACK anim pin; N64-identical), NOT a port bug; port-side watchdog `port/src/d318watchdog.c` re-inits the attack on confirm. D263 mechanism map stands (different failure mode). |
| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | RESOLVED (2026-09-19): as-authored latent race (gas bit mid-monologue derails c78's pre-aim hold into a permanent ACT_ATTACK anim pin), NOT a port bug; port-side watchdog `port/src/d318watchdog.c` re-inits the attack on confirm. D263 mechanism map stands (different failure mode). **N64 manifestation unverified** (2026-09-22 correction: user's empirical prior is no such N64 freeze; upstream-port-divergence vs unobserved — A/B vs N64 RAM capture is the discriminating test). |
| D319 | **Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).** — full `## D319` entry at file tail | FIXED — new `ROW_FPSCAP` cycle (30/60 only) in `port/src/optionsoverlay.c`; default changed to 60 in `port/src/video.c`. Root cause was D186 (sim tick ceiling + low-cap guard); this just fixes the menu's exposure of it. Build-verified; interactive confirmation owed. |
| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | OPEN — static sweep complete (Facility ai_19, Control ai_9, Depot ai_12 flagged high/med-high; Bond-combat loops likely safe); no live confirmation beyond D318 itself; probe-verify path + generalized-watchdog recommendation documented. |
| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | RESOLVED (2026-09-22): forced repro confirms the pin on Facility ai_19 (off=483) and Control ai_9 (off=69, ~2/3 variants); Depot ai_12 clean 3/3 (scripted kill progresses — not covered). D318T detector fixed to scan `g_ChrSlots` as well as `g_ActiveChrs`; watchdog generalized to a table with uncapped repeat rescue. Open threads: N64-fidelity A/B via unthrottled emulator (per D318 correction), single Depot ai_10/TREED pin observation, ai_13/14/15 pad lists. |
| D321 | **D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).** — full `## D321` entry at file tail | CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play. Optional deferred tuning: `D318_DEADLOCK_TICKS` 600→300 (user's call). |
| D322 | **Long-session audio degradation: full campaign on v0.3.0 — audio progressively worsens from Silo, by Caverns/Cradle the OST is inaudible and SFX "come and go"; restarting the game restores it (issue #87, user report, 2026-09-20).** — full `## D322` entry at file tail | OPEN — static triage done (teardown audit, mixer statelessness, D202-coverage check); ranked hypotheses: voice-pool exhaustion/counter drift > queue starvation > evtq saturation. `GE_D322` pool-telemetry probe shipped; needs a campaign capture with `GE_D322=1 GE_D204=1`. |
| D323 | **G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0, N64 samples the authored mip chain (static census, 2026-09-21).** — full `## D323` entry at file tail | OPEN / parked — mechanism confirmed in code; census (`tools_pc/bg_lod_audit.py`) shows 15 levels with explicit-LOD textures bound under TEXTLOD-on classes (stat 378, azt 806 binds heaviest); overwhelmingly CI8/IA8 (subtle), the only RGBA5551 cut-outs are the D236 set (already fixed). No reported artifact; fix path documented (upload GL mip chains for TEXTLOD-on bindings) but not worth doing speculatively. Also documents GE's OMH field-write encoding (op 0xBA, value at absolute bit position in w1). |
| D324 | **GE's <stdlib.h> stub declares nothing, so TUs that include it get NO CRT prototypes — implicit `int f()` declarations silently corrupt non-int returns: `atof` garbage in every float ini parse (D38 class).** — full `## D324` entry at file tail | FIXED (2026-09-22) — proven impact: `Game.ScreenShakeIntensity` (the only float ini option) silently parsed to garbage whenever a user set it; caught via the D294 `GE_ROOMPOOL` hook. Fix: `port/shim/stdlib.h` now declares atof/strtod/strtol/atexit/getenv unconditionally (K&R form); pc_protos.h gains atof/strtod for game TUs; glass2.c PORT block uses `__builtin_memset`. Residual harmless implicit decls (atoi/floorf/sqrtf/memset builtins, lround) documented. |
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
@@ -11755,6 +11757,22 @@ Superseded history below (the original OPEN investigation, root cause not found
**Status (from §F table):** OPEN — not investigated. Next: reproduce on Statue (`-level_22`) at the reported settings (`Video.FovScale≈130`, max draw distance/LOD) near the ramp by the CCCP monument; a `GE_D271`/`GE_D104`-style room-visibility trace (`roomsDrawn` count, portal-cull hits) correlated with the black-ground window would confirm or rule out the room-visibility-BFS hypothesis quickly.
**ROOT-CAUSED AND FIXED (2026-09-22 session) — not the room-visibility BFS; it is room-model POOL exhaustion under widened PC visibility settings.** The room-visibility hypothesis above is ruled out: `roomsDrawn`/portal-cull state stays healthy throughout the black window. The actual chain: (1) each level's authored `memallocstringtable` row budgets the room-model pool — Statue's is `-ma220` = **220 KB**; (2) the PC visibility knobs (`Video.FovScale`, `WidescreenAuto`, `DrawDistance`, `LodDistance`) keep more rooms on screen at once than the N64's fixed ~60°/4:3/authored-far-clip view, so more room models must stay resident simultaneously — the monument-plaza 12-room set alone totals ~250 KB "used", already over the authored pool; (3) when demand exceeds supply, `memaAlloc()` returns NULL (after its 8 defrag passes) and `bgLoadRoomModelData()` bails **silently** — the undrawn room's scissor region then shows the framebuffer clear colour. On Statue that is the night-sky fill RGB(0,0,8): a wide solid-black patch of "ground" that self-recovers 10–30 s later when the player moves and the rooms reload into freed space — exactly the reported symptom shape (transient, ground-specific, self-correcting). **A/B confirmed both knobs independently trigger it:** FovScale=130 alone → 372 alloc failures; DrawDistance=400 alone → 33 (and `DrawDistanceAutoFov`, default ON, couples DD to 4×FovScale when DD is at its default, so "FOV-only" configs carry a hidden 5.2× effective DD). **Stochastic overlay:** `randomSetSeed(nowCount)` uses the wall clock, so trajectory → room load order varies per run; one run logged 174 failures while six subsequent identical runs logged zero — even at the *authored* pool size in short walks. That is why the bug was intermittent for the user and evaded early headless repro.
**Fix (port-only, `#ifdef PORT`):** `portRoomPoolScale()` in `port/src/video.c` returns a pool multiplier composed from the same knobs that widen the view — FOV term = manual FovScale × the WidescreenAuto aspect factor (both widen the frustum horizontally); DD/Lod terms = **square root** of the distance multipliers (room *count* grows far slower than distance² — measured 9→12 rooms at 4× DD on Statue — so sqrt is a deliberate under-estimate). Capped at 2.0× (worst case `-ma350` row → 700 KB, well inside the PC's ~6 MB STAGE bank, D95); **identity (1.0) when every knob is N64-faithful**, so all-default settings keep the exact authored budget and golden dumps stay byte-identical. Applied in `bossMainloop` (`src/boss.c`) immediately after the `-ma` token parse — the single chokepoint both play flows (menu-driven and `-level_XX`) pass through — clamped to STAGE-bank-minus-128 KB headroom. `GE_ROOMPOOL=<float>` overrides the computed scale outright (test hook; floor 0.25 for stress-testing the exhaustion path; also a user opt-out).
**Verified headless (`-level_22`, probes `GE_D294`/`GE_D104`; the `GE_D294` stage-load print is left in `boss.c`, env-gated):** all-N64-faithful INI → `ma=225280` = exact authored size (identity ✓); user settings (FovScale 130, DD/Lod 400) → `ma=450560` (2×) with **zero** alloc failures across repeated runs; `GE_ROOMPOOL=0.25` → `ma=56320` and exhaustion reproduces on demand (thousands of `FAIL alloc` lines with movement) — proving both the override and the mechanism.
**Residual, documented only (NOT a port bug): the far-horizon hard edge at high DrawDistance on night levels.** Where fog saturates to the sky colour before the geometry's far clip, the horizon reads as a sharp lit→black boundary. That is inherent to the D218 draw-distance setting on levels whose sky ≈ black (Statue, and similar); the N64 never shows it because its far clip is authored short enough that fog always saturates first. No fix warranted; if it bothers a user, lower DrawDistance.
**Side finding (CLI footgun, no fix — game code is ground truth):** `tokenFind(1, "-m")` **prefix-matches** `-ma`, `-ml`, `-me`, …, so passing a bare `-ma N` (or `-m`) on the PC command line corrupts `g_CurentMMallocValue` (→ 0 → divide-by-zero crash). The in-ROM rows are the only intended source of these tokens; don't pass `-m*` flags on the CLI. (The D121 guard `tokenFind(1, "-m") == 0` correctly means "no -m* flag supplied".)
**Playtest update (evening of 2026-09-22).** First live Statue session at the user's settings: the wide monument-plaza patch is **gone**; residual report — "minor geometry going black/void at the corner of the screen at max FOV when running forward/backwards fast". An instrumented re-test (`scratch/d294_capture.bat`) did not visibly reproduce, but the log caught **75 `FAIL alloc` events anyway** (room 11 ×43 @33 KB, plus 23/7/17) — brief per-frame-retried starvation episodes the eye doesn't register. Root of the residual: GE keeps loaded rooms resident, and Statue's **full 27-room set is 487 KB = 2.2× its authored pool** — running around at max visibility eventually demands the whole set, which starves even the 2× (450 KB) pool. The sqrt rationale measured only *simultaneously visible* rooms; it missed accumulated residency.
**3×/linear attempt REGRESSED — reverted (same evening).** Distance terms linear + cap 3.0 (`ma=675840`) fixed the starvation headroom but broke the renderer on the **menu-driven** Statue path: "world renders as untextured polygons, Bond's hands normal". Log evidence: `[D156] bad anim frame guarded: frame=nan ...` + `subcalcmatrices: frameb out of range!` spam starting ~5 frames after stage load; the corrupted Model has valid fields (speed=0.54, anim ptr into PERMANENT) mixed with NaN/garbage floats (newspeed≈1e24, elapsespeed≈-FLT_MAX). **Not a bank overflow** — `GE_D294BANK` probe shows STAGE [0x70050000..0x706a8000] with pos=0x702ebb30 (2.6 MB used, 3.7 MB free) and `needmemallocation=0`. **Path-dependent**: direct `-level_22` boots were clean in 4/4 headless runs (Statue at vi post #1); the user's menu-driven load (Statue at vi post ~#3541, after ~60 s of front-end) corrupted. Suspects: menu-path-specific init order, or wall-clock-seeded stochasticity that the short headless runs didn't hit. Reverted to sqrt + 2.0 cap (user-verified working state; `ma=450560` re-verified). Probes left in place for the next repro: D156 hexdump of the corrupted struct (`src/game/model.c`, TEMP-marked) + `GE_D294BANK` bank-state dump (`src/game/bg.c`, wired into `scratch/d294_capture.bat`).
**Status:** **FIXED (main symptom, user-confirmed); residual corner flicker OPEN** (2026-09-22). The 2× build is the shipped state. To close the residual: capture-bat run at max FOV with fast movement → if `FAIL alloc` recurs, the fix must cover whole-level residency (487 KB) **without** the 3× regression — options: scale only when the level's room-set total is known to exceed pool (bg data has per-room sizes at load), or a smaller targeted bump (e.g. 2.5×); any change needs BOTH a direct-boot AND a menu-driven verification.
## D211 — PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).
**PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** fast3d applies a multiplicative half-angle scale to every *perspective* projection matrix as it is loaded (`gfx_apply_fov_scale()` in `gfx_pc.cpp gfx_sp_matrix`, on the `G_MTX_PROJECTION | G_MTX_LOAD` path only — GE always reloads its view projection via `guPerspectiveF` at `fr.c:725`, so a relative `G_MTX_MUL` onto an already-scaled matrix cannot compound). Columns 0 and 1 are scaled by the same factor `tan(half_orig)/tan(half_adj)` → H and V FOV widen uniformly, aspect ratio exactly preserved (no stretch), and because the game rewrites fovy every frame for aim-zoom the scale composes with zoom for free. Ortho matrices (`P[3][3]==1`) are left untouched so HUD/menu 2D is unaffected. `Video.FovScale` = percent of the original vertical FOV, 50–150, **default 100 ⇒ `g_fov_scale == 1.0f` ⇒ guarded early-return, zero FP ops on the matrix ⇒ golden dumps byte-identical**. F10 overlay row "FOV scale %". Math checked offline (`s=1.25 → fovy 60°→75°`, aspect held at 1.3333).
@@ -12466,7 +12484,9 @@ Ourumov = chr 78 on list 0x0417 (ai_22). Key offsets: off=38 pre-aim at Trevelya
### Classification
**As-authored latent race, not a port bug.** Every link — ai_22 flow, the update command's missing re-init, the `framea==frameb` pin, the ACT_ATTACK-gated stop loop — is byte-matched ROM logic; the N64 has the identical race and the identical unrecoverable softlock. The trigger (gas bit landing during the monologue while c78 is in pre-aim hold) is organic level state on both platforms.
**As-authored latent race, not a port bug.** Every link — ai_22 flow, the update command's missing re-init, the `framea==frameb` pin, the ACT_ATTACK-gated stop loop — is byte-matched ROM logic.
**N64 manifestation — CORRECTION (2026-09-22, user challenge).** The earlier claim "the N64 has the identical race and the identical unrecoverable softlock" was *inference* from byte-matched logic, never observation — we cannot instrument an N64. The user's empirical prior (and ~30 years of community play with no such known bug) is that guards do **not** freeze on real hardware. For that to hold, the derail must effectively never occur on N64 — i.e., the gas bit never lands inside c78's pre-aim hold window there. But that window is player-timing-gated (tanks destroyed during the hold) and tick counts are platform-invariant, so if the port were fully tick-faithful upstream of this beat, an organic PC freeze should reproduce on N64 with the same play. Two live hypotheses: **(i) an upstream port divergence** (timing shim / scheduler phase / earlier state misread) opens a derail window on PC that never opens on N64 — in which case the freeze is a *port-exposed* artifact and the real fix is finding that divergence; **(ii)** it can occur on N64 but has simply never been observed (narrow window). **Discriminating test (revised 2026-09-22.)** Primary — **deliberate timing on an unthrottled emulator**; the derail window is *player-controllable*, so no instrumentation is needed. Per D321 the combat bit lands 212 ticks (~3.5 s) after the last tank destruction, and c78's vulnerable span runs from pre-aim (off=38) through the monologue loop (off=75, check re-evaluated continuously; ~22 s). So: on an **unthrottled emulator** (stable 60 Hz tick regime — the same regime as the port; Mupen64Plus-Next/Project64 at full speed) with save states, trigger the Ourumov scene, then destroy the last tank ~1–15 s into the monologue; repeat with fresh loads to sample the aim-variant lottery. **Freezes ≈ the port's forced-repro rate (~2/3)** → the race is reachable under stable ticks; "N64 never freezes" is an organic-timing/frame-drop story (hypothesis ii; watchdog = insurance, no port bug). **Never freezes across ~10 deliberate placements** → the mechanism differs environmentally (hypothesis i) and there is a genuine upstream port divergence to hunt. Control first: confirm the *port* freezes at the expected rate under the same deliberate timing (forced-repro probe can calibrate this headlessly). Secondary — **PRNG-stream A/B** (only if the emulator never freezes and hypothesis (i) needs hunting): the port already ships `GE_RSEED_LV` (`port/src/d318watchdog.c`) + DET seed logging — read `g_randomSeed` from N64 RAM `0x80024460` at a canonical moment (emulator memory viewer; that read is the only console-side step), inject it with `GE_RSEED_LV=<hex>@<tick>`, replay the same play, and compare DET lines: any divergence pinpoints the tick where the port's stream leaves the original's. The watchdog remains justified either way as a port-side liveness measure: it acts only in a state where the game otherwise hard-locks.
### Fix (port-side, `port/src/d318watchdog.c` — same pattern as D202 M-66b)
@@ -12503,7 +12523,7 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
## D320 — D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).
**Context.** D318 (Facility ai_22) root-caused a permanent softlock to a *generic* game-code mechanism, not to anything Facility-specific: `actor_fire_or_aim_at_target_update` (`chraction.c:4882`) succeeds whenever the chr is in ACT_ATTACK with `attacktype & (TARGET_AIM_ONLY | TARGET_DONTTURN)`, retargets, and calls `chrlvAttackActionRelated` — which only changes the model's **endframe** and **never calls `modelSetAnimation`**. If the chr is pinned on a non-looping aim-hold pose whose endframe H sits *before* the variant's shoot window (`shoot_start_frame`), and the new endframe E lands above H, nothing ever re-inits the animation (the `chrlvTickAttackCommon` "endframe <= frame" block's re-init sub-branches are all gated out: `unk31==0`, `unk33 != unk34`) → the model stays pinned below the shoot window → the timer/frame-gated fire events (`shoot_start <= frame < shoot_end`) never open → the chr never fires. If the surrounding AI script's only escape is an `if_guard_has_stopped_moving` gate, it can never pass: `chrHasStoppedOrPatroling` (`chraction.c:4291`) returns FALSE for ACT_ATTACK. **Permanent softlock.** Every link is byte-matched ROM logic; the N64 has the identical race (the random aim-variant pick in `chrlvInitActAttack`, `randomGetNext() % len`, differs per run on both platforms).
**Context.** D318 (Facility ai_22) root-caused a permanent softlock to a *generic* game-code mechanism, not to anything Facility-specific: `actor_fire_or_aim_at_target_update` (`chraction.c:4882`) succeeds whenever the chr is in ACT_ATTACK with `attacktype & (TARGET_AIM_ONLY | TARGET_DONTTURN)`, retargets, and calls `chrlvAttackActionRelated` — which only changes the model's **endframe** and **never calls `modelSetAnimation`**. If the chr is pinned on a non-looping aim-hold pose whose endframe H sits *before* the variant's shoot window (`shoot_start_frame`), and the new endframe E lands above H, nothing ever re-inits the animation (the `chrlvTickAttackCommon` "endframe <= frame" block's re-init sub-branches are all gated out: `unk31==0`, `unk33 != unk34`) → the model stays pinned below the shoot window → the timer/frame-gated fire events (`shoot_start <= frame < shoot_end`) never open → the chr never fires. If the surrounding AI script's only escape is an `if_guard_has_stopped_moving` gate, it can never pass: `chrHasStoppedOrPatroling` (`chraction.c:4291`) returns FALSE for ACT_ATTACK. **Permanent softlock.** Every link is byte-matched ROM logic. (N64 manifestation unverified — see D318's 2026-09-22 correction: the user's empirical prior is that no such N64 freeze exists; the same upstream-divergence-vs-unobserved question applies to every list in this sweep.)
**Sweep.** All 21 levels' AI script sources (`assets/obseg/setup/*.c`) scanned for lists containing both an aim command (`guard_try_fire_or_aim_at_target[_kneel]`, BITFIELD byte with `TARGET_AIM_ONLY` 0x20 set — the hold pose) and a `guard_try_fire_or_aim_at_target_update` reachable while the hold is active. 17 lists in 8 levels match; control-flow checked per list:
@@ -12525,15 +12545,22 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
**Why the Bond-combat loops are probably not affected.** In normal sustained fire the aim-hold endframe sits *at/inside* the shoot window (the hold pose is the recoil pose; shots fire from the `attack_time`/`unk44` timer machinery in `chrlvTickAttackCommon`, not from frame progression past endframe), so a `_update` 0x21→0x01 keeps the model pinned *inside* the firing window and the guard keeps shooting — that is how continuous fire works by design. The D318 danger requires the hold pose to be a distinct pre-shoot pose (H < shoot_start) — which is what "aim at X but don't fire yet" scripts produce. These combat lists are also exercised on every playtest of every level with no observed freezes, which is strong empirical evidence. This distinction is inferred from code reading + playtest history, **not** probe-verified per list.
**Classification.** As-authored latent race(s), faithfully reproduced — NOT a port bug (same classification as D318). No `src/game` fix is warranted or permitted absent Rule-2 sign-off; the N64 carries the identical risk on the identical random-variant branch.
**Classification.** As-authored latent race(s) in the byte-matched state machine; PC manifestation now confirmed by forced repro (below). N64 manifestation unverified — inherits D318's 2026-09-22 correction (upstream-divergence-vs-unobserved; A/B against an N64 capture is the discriminating test). No `src/game` fix is warranted or permitted absent Rule-2 sign-off.
**Verification path (owed).** The pin branch depends on the per-variant animation-table fields (H vs E), which are data, not code — static analysis cannot price the risk per list. Use the D318T-style timeline probe (`port/src/d318watchdog.c` `d318TimelineTick`, generalized to log any chr in ACT_ATTACK with an AIM_ONLY attacktype whose `modelGetAnimFrame` is byte-stable across ticks while `aioffset` sits in a stop-check loop) during playtests of: Facility (the ai_19 beat — the guard who monologues at the captive scientists before shooting), Control (the ai_9 beat), Depot (the ai_12 execution beat). A frozen run shows the exact D318 signature: stable `animframe1`, zero `modelSetAnimation` calls post-`_update`.
**Forced-repro results (2026-09-22, headless).** Method: probe `port/src/d320repro.c` (env-gated `GE_D320R`, TEMP) hijacks a named guard's AI list + offset at chosen ticks in a bare `-level_XX` boot. **Detector fix first:** the D318T timeline scanned only `g_ActiveChrs`, which holds *background script entities only* (chrnum 0xFE, allocated from AI lists with ID ≥ 0x1000 in `deb_loadallmodels.c`) — it was blind to every real guard. Real guards live in `g_ChrSlots[0..g_NumChrSlots)`; the detector now builds a unified pointer array over both (both are ticked by `chrlvAllChrTick`, chraction.c:9539).
- **ai_19 (Facility): PIN CONFIRMED.** c5 pinned at off=483 (label 0x34 loop), act=8, atk=0x8, ent=109 (pad 0x6d), frame stable 30.00/23.00 for thousands of ticks.
- **ai_9 (Control): PIN CONFIRMED, variant-dependent** (~2/3 of seed variants pin; the rest escape — the same random-variant structure as D318). Pinned at off=69 (label 0x0b loop), act=8, atk=0x04, ent=252, frame stable 23.00.
- **ai_12 (Depot): CLEAN 3/3.** The scripted `chr_hit_body_part_with_item_damage` kill progresses the beat to off=267+ regardless of pin state; no objective-block observed under forced repro → deliberately NOT covered by the watchdog.
**If confirmed in live play:** generalize the D318 watchdog instead of writing per-list copies — parameterize (AI list ID, offset range, chr number, target entityid, confirmation tick count) as a small table; the detection signature and the `sub_GAME_7F025560` re-init are already generic. The current `d318WatchdogTick` is hardcoded to ai_22/c78/c67 by design (one-shot, zero false-positive surface); keep it that way until a second list actually freezes in play.
**Watchdog generalized (`port/src/d318watchdog.c`).** Table-driven entries: `{0x0417, [321,333), ent=67, c67-alive}`, `{0x0414, [480,548), any}`, `{0x040a, [60,86), any}`; detection = ACT_ATTACK + live-fire attacktype (no TARGET_AIM_ONLY bit) + offset in range [+ per-entry target-alive condition], held 600 ticks; recovery = `sub_GAME_7F025560(chr, attacktype, entityid)` (the game's own fresh-attack entry). **Repeat rescue:** a successful reseed lets the attack play out → stop-check passes → the list re-aims → a new variant can re-pin (~240 ticks later), so if the signature persists the watchdog re-fires every 360 ticks, uncapped. Verified on both Facility and Control forced runs: continuous rescues at ~600-tick cadence, guard never frozen >~6 s. D318's entry behavior is unchanged by the refactor (code path re-verified in a live playtest is still owed). Fidelity note: detection + one-shot rescue is faithful (the N64 escape path does exactly this re-init); the *uncapped repeat* is an intentional port-side accommodation for the forced/context-dependent re-pin — same class as D318/D202 M-66b; logged (`D318W:`) for playtest audit.
**Observation (single, not reproduced).** Depot bare headless boot: c0 on ai_10 (a TREED guard list) pinned at off=49, act=8, atk=0x1 (live-fire), ent=0, frame stable 33.00 for 1200+ ticks — a combat-loop list the sweep rated "likely safe." Not reproduced on re-run. If seen in playtests, the "combat loops are safe" inference needs revisiting; severity is low either way (a frozen combat guard doesn't block objectives — the player can kill him).
**Status:** RESOLVED (2026-09-22) — pins confirmed on ai_19 + ai_9, clean on ai_12; generalized table-driven watchdog shipped. Open threads: (a) N64-fidelity classification per D318's 2026-09-22 correction (unthrottled-emulator deliberate-timing A/B); (b) the Depot ai_10 observation; (c) ai_13/14/15 pad lists untested (LOW-MED). Cross-ref: D318 (mechanism + watchdog), D263 (ai_22 mechanism map).
**Second-pass verification (2026-09-20, review of this entry).** All mechanism claims re-checked against source and confirmed: `chrHasStoppedOrPatroling` (chraction.c:4291) returns TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL; `actor_fire_or_aim_at_target_update` (chraction.c:4882) succeeds on ACT_ATTACK + (AIM_ONLY\|DONTTURN) and calls `chrlvAttackActionRelated`, which only ever calls `modelSetAnimEndFrame` (recoil/shoot start or end frame) — never `modelSetAnimation`. The three high/med-high lists were re-read in full: ai_19 (UsetuparkZ.c 2630–2826), ai_9 (UsetupcontrolZ.c 2279–2313), ai_12 (UsetupdepoZ.c 2174+) — structures match the table above, with the two precision corrections applied to the ai_19/ai_12 rows. **AI command-ID mapping settled:** `bondaicommands.h`'s explicit IDs are the cmdbuilder enum values from `aicommands.def` order (cross-checked on four known commands; a naive `#define CMDNAME` count is off by 2). In particular `if_guard_bitfield_is_set_on` = ID 0x96 = `AI_IFMyFlags2Has` — it tests the **executing guard's own** flags2 (`AiIFMyFlags2HasRecord {cmd, BITS, GOTOLABEL}`, handler chrai.c:3146), and `guard_bitfield_set_on` = 0x94 = `AI_SetMyFlags2`. The per-chr-targeting family is separate (0x97–0x99, `chr_bitfield_*`). `AI_TRYFireOrAimAtTarget[Kneel]` jumps to GOTOLABEL on success, falls through on failure (chrai.c:1372+). TARGET_ flag decode for the hold commands: 0x24 = CHR\|AIM_ONLY, 0x28 = PAD\|AIM_ONLY (hold poses); updates set 0x04/0x08 (live fire, no AIM_ONLY).
**Status:** OPEN — static sweep complete and second-pass verified; no live confirmation yet for any list beyond D318 itself. Cross-ref: D318 (mechanism + watchdog), D263 (ai_22 mechanism map).
(Superseded 2026-09-22 — see the RESOLVED status line at the end of this entry.)
## D321 — D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).
@@ -12554,7 +12581,7 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
**Optional tuning (deferred, user's call).** `D318_DEADLOCK_TICKS` 600→300 would halve the worst-case pin-branch pause (10 s → 5 s); the margin over the natural escape (<2 s; run B: 0 ticks) stays ≥2.5×. One-line `#define` in `port/src/d318watchdog.c`; not done pre-ship to avoid re-validating the watchdog on the release branch.
**Status:** CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play (fired + recovered in run A, correctly inert in run B). Cross-ref: D318 (pin mechanism + watchdog), D320 (sweep of the same race in 8 levels), D263 (ai_22 mechanism map), M-192 (release review).
**Status:** CLOSED — trigger chain mapped and faithful *at the script-data level* (the ~3.5 s cascade delay and same-tick derail are identical ROM data on both platforms); D318 watchdog validated in live play (fired + recovered in run A, correctly inert in run B). Caveat per D318's 2026-09-22 correction: "faithful end-to-end" does not establish that the derail window ever opens organically on N64 — that remains the open upstream-divergence-vs-unobserved question. Cross-ref: D318 (pin mechanism + watchdog + N64-manifestation correction), D320 (sweep of the same race in 8 levels), D263 (ai_22 mechanism map), M-192 (release review).
## D322 — Long-session audio degradation: full campaign on v0.3.0, audio progressively worsens (Silo → Caverns/Cradle: OST inaudible, SFX "come and go"); restart restores it (issue #87, user report, 2026-09-20)
@@ -12614,3 +12641,45 @@ Full campaign (or at minimum Silo → Caverns/Cradle) with **`GE_D322=1 GE_D204=
**Live verification:** bare front-end boot (existing binary, new data only). `GE_PCDUMP="240-330:10"`: logo window goes 45 → 851 → 8,599 → **23,302 non-black px (7.59%)** over frames 260–320 with a wide wordmark bbox `(94,190)-(542,290)`; `GE_PCDUMP="340-560:20"` shows the logo continuing its rotation animation at 3–10% coverage (ASCII-art inspection: elliptical ring + shaded 3D lettering, grey/white palette consistent with the wordmark texture). Pre-fix baseline (M-196): 0–33 px/frame.
**Status:** **FIXED, user-confirmed live (2026-09-21)** — "the images and legal page and Nintendo logo are good now." Tooling fix + ntsc-final sidecars regenerated; headless-verified before the live check. Remaining: `pal-final`/`jpn-final` sidecar regeneration on machines holding those ROMs (`python tools_pc/d43_emit.py <region>` — the fix is in the tool, so any future regen is correct), and D75's last unverified scope: cast-roll models (status unknown since M-33; recheck with `GE_PCDUMP="1400-2200:25"`).
## D323 — G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0; N64 samples the authored mip chain (static census, 2026-09-21)
**Context.** D107 established that GE's room DLs emit `G_TL_LOD` and that the port folds every LOD draw to tile 0 (driver-generated GL mips from the finest level). D236 then found + fixed the one case where that folding was *visibly* wrong for a type-1 (DETAIL) binding of an RGBA5551 cut-out. This entry closes the loop on the rest of the surface: how often do LOD-on draw classes bind textures that actually carry **authored** mip levels, and does the N64 hardware behavior differ from the port's?
**N64 mechanism (confirmed in code).** For a texture whose imagelist header declares `hasExplicitLods` with N images (`tex->maxlod = N`, image.c:343), every binding type emits `gDPSetTextureLOD(G_TL_LOD)` + `G_TD_DETAIL` and sets up **N tiles**: tile 0 at the image-table TMEM slot, tiles 1..N-1 at successive TMEM addresses with halved dims/masks (`texWriteTileLods`, tex.c:355; one LOADBLOCK covering the whole chain via `texGetDepthAndSize`). With TEXTLOD on, the RDP ORs the minification level into the tile index, so distant geometry samples the **authored** coarser images.
**Port behavior.** fast3d folds all of tiles 0..N-1 to GL texture unit 0 (D107; D236's type-1 carve-out excepted), with `glGenerateMipmap` mips from LOD0. So on PC, minified geometry samples **driver-filtered** versions of the finest image instead of Rare's hand-authored mips.
**Census (`tools_pc/bg_lod_audit.py`, ntsc-final, bg room DLs only).** GE's modified-fast3d `G_SETOTHERMODE_H` is a partial field write (op 0xBA; w0 bits 8-15 = shift, bits 0-7 = width; the value sits at its *absolute* bit position in w1 — fast3d calls `gfx_sp_set_other_mode(shift+32, nbits, w1<<32)`, an identity map). TEXTLOD = bit 16. The tool walks every room DL tracking OML/OMH state and cross-references each bind's texnum against the imagelist explicit-LOD flag:
- **423** textures carry explicit LODs; **15 levels** bind at least one of them under a TEXTLOD-on class (azt 806, stat 378, arec 88, sevx 84, crad 17, pete 15, jun 25, depo 28, dest 8, run 8, arch 4, ark 4, sev 4, sevb 4, silo 2 LOD-on binds).
- Formats: overwhelmingly **RGBA16_CI8 / IA8** (7-8 authored levels each) — driver mips of these are usually close to the authored ones; this is a subtle fidelity gap, not an artifact.
- The only **RGBA5551** (1-bit alpha cut-out) explicit-LOD textures bound under G_TL_LOD anywhere are tex1198-1201 in sevx/sev/sevb — the D236 set, already handled by the type-1 carve-out; user reports Surface 2's treeline reads fine.
**Assessment.** Real N64-vs-PC divergence, pervasive but low-visibility: no reported artifact, and the one high-visibility subset (cut-outs) is already covered by D236. A full fix means uploading GL mip chains from the explicit LOD images for TEXTLOD-on bindings (tex.c + fast3d texture-cache plumbing), which would invalidate golden baselines across all 15 levels for a change nobody has seen to be wrong — not worth doing speculatively.
**Status:** OPEN / parked (GRAPHICS-BACKLOG). If a playtest ever flags mushy distant detail in one of the 15 levels above (Statue and Aztec are the heaviest), the fix path is: in fast3d's texture upload, when the binding class has TEXTLOD on and `tex->maxlod > 1`, feed `glTexImage2D` levels 0..N-1 from the decoded explicit images instead of `glGenerateMipmap`. Audit tool: `tools_pc/bg_lod_audit.py` (also documents the GE OMH field-write encoding, which no other tool captures).
## D324 — GE's <stdlib.h> stub declares nothing, so TUs that include it get NO CRT prototypes: implicit `int f()` declarations silently corrupt non-int returns (atof garbage in every float ini parse; D38 class)
**Context.** Found while verifying the D294 fix: the new `GE_ROOMPOOL` env hook in `port/src/video.c` called `atof()`, and the parsed value was deterministic-per-call-site garbage (observed 15279232 / 7087232 / 6817888 / 14288992 across runs) instead of the env string's value. The scale override was silently ignored on every run until prototypes were added.
**Mechanism.** `include/stdlib.h` (the N64 decomp's libc stub) wraps its entire body in `#if 0` — only the `lldiv_t`/`ldiv_t` typedefs are live. `port/shim/stdlib.h` (what `<stdlib.h>` resolves to for C TUs; see docs/internals.md §11 D12) includes that stub and then declared a couple of stragglers — but `getenv`/`realloc` **only on non-Windows**, on the empirically-wrong assumption that MinGW's other CRT headers "leak" prototypes (config.c gets no leaked `getenv`; the implicit-declaration warning proves it). So any port/src or game TU that calls a CRT function nothing else declared compiles with an **implicit `int f()` declaration**. On x86-64 SysV that means: a `double`/`float` return in **xmm0 is read from eax** (garbage), pointer returns are truncated to 32 bits, and `long` returns truncate on LP64 (Linux) builds.
**Why only some functions actually misbehave (the triage rule for this class):**
- **GCC builtins** (`floorf`, `sqrtf`, `memset`, …): GCC recognises them even when implicitly declared — it warns (`-Wbuiltin-declaration-mismatch`) but generates the *correct* xmm0/inline code. Harmless in practice. (This is why `sky.c`'s implicit `floorf` never visibly broke: correct values, and its fold result is additionally masked by `SKY_TC_WRAP` periodicity — any multiple of the wrap period is visually identical on a wrapping sky texture.)
- **int-returning** (`atoi`, `atexit`): the 32-bit int fits in eax exactly. Harmless (still UB per the standard; warning only).
- **Non-builtin float/double/pointer/64-bit-long returns** (`atof`, `strtod`, `getenv`, `strtol` on LP64, …): genuine silent corruption. This is the only bucket that needs fixing.
**Proven impact.** (1) `port/src/config.c` `parseFloat` → **every float ini option parsed to garbage whenever a user set one** — currently exactly one exists, `Game.ScreenShakeIntensity`, and the affected user had it set to 10 (silently became an eax-derived float for months). (2) The D294 `GE_ROOMPOOL` hook (video.c) — caught during verification. (3) `getenv` in 8 port TUs + `src/libultra/audio/csplayer.c`: pointer truncated to 32 bits — harmless *so far* on Windows (CRT env block sits below 4 GB), a landmine otherwise.
**Ruled out during diagnosis** (all tested, all clean): msvcrt corruption, locale, stack alignment, C++ runtime interference — plain MinGW test programs parse identically; only this build is affected, purely via the header shadowing. Garbage values are deterministic per call-site (same eax state each time) but vary between runs.
**Fix (port-layer only):**
- `port/shim/stdlib.h`: now declares `atof` / `strtod` / `strtol` / `atexit` / `getenv` **unconditionally** (K&R form — compatible with any real or leaked prototype), after including the N64 stub. `realloc` stays non-Windows-only (strict-GCC leak gap).
- `port/include/pc_protos.h`: gains `double atof(); double strtod();` for game TUs that include `<ultra64.h>` but never `<stdlib.h>` (no game TU currently calls them; future-proofing + canonical D38 list).
- `src/game/glass2.c` (the D219 `#ifdef PORT` block): `memset` → `__builtin_memset` so that block needs no prototype at all.
**Residual implicit declarations (audited, all in the harmless buckets above — warning-only, no action):** `atoi` in `src/game/chr.c`, `src/game/sky.c`, `src/game/bondview2.c`, `port/src/optionsoverlay.c`; `lround` in `optionsoverlay.c` (fine on Windows LLP64 where `long`=32 bits; would truncate on an LP64 Linux build — revisit only if a Linux port is actively maintained); the builtin family (`floorf`/`sqrtf`/`memset`) wherever it appears.
**Status:** FIXED (2026-09-22). Build clean; D294 verification battery re-run post-fix (faithful→authored size, user settings→0 failures, `GE_ROOMPOOL=0.25`→honoured + exhaustion on demand). Cross-ref: D38 (original implicit-declaration finding), D294 (where it was caught).
+5
View File
@@ -71,6 +71,11 @@ void alloc_false_GUARDdata_to_exec_global_action();
void alloc_init_GUARDdata_entries();
void alloc_load_expand_ani_table();
void assert();
/* D324: GE's <stdlib.h> stub (#if 0'd body) declares neither; an implicit
* `int f()` declaration reads the xmm0 double return from eax -> garbage.
* port/src TUs that include <stdlib.h> get these from port/shim/stdlib.h. */
double atof();
double strtod();
char * bgDebPrintROOMID();
void bgFindRoomsAlongSegment();
s32 bgGetConnectedRooms();
+22 -8
View File
@@ -17,15 +17,29 @@
#include "hoststdlib.h"
#else
#include "include/stdlib.h"
#if !defined(_WIN32)
/* The N64 stub declares only lldiv_t/ldiv_t + lldiv/ldiv. MinGW's other CRT
* headers leak getenv() etc.; a strict host GCC (Linux/macOS) does not, so
* getenv()'s 64-bit pointer return is assumed int and truncated (crashed
* configGetFrameDump). Declare the pointer-returning stragglers the port +
* game code use that nothing else declares. K&R form = compatible with any
* real prototype. (malloc/calloc/free are in port/include/pc_protos.h and
* a few port TUs' own local decls — not repeated here to avoid conflicts.) */
/* D324: the N64 stub declares nothing else, and -- unlike getenv/realloc
* below -- MinGW's leaked CRT headers do NOT declare these in the port TUs
* that use them. An implicit `int f()` declaration makes the caller read a
* double return from eax instead of xmm0 (silent garbage) and truncates
* pointer/64-bit-long returns to 32 bits (latent on LP64 for strtol).
* Declared unconditionally; K&R form is compatible with any real prototype.
* Game TUs that never include <stdlib.h> get these from pc_protos.h. */
extern double atof();
extern double strtod();
extern long int strtol();
extern int atexit();
/* D324: getenv was previously declared only on non-Windows, on the (wrong)
* assumption that MinGW's other CRT headers leak a prototype -- config.c gets
* none, so its implicit `int f()` declaration truncated the pointer return to
* 32 bits. Declare unconditionally; K&R form is compatible with any leaked or
* real prototype. */
extern char *getenv();
#if !defined(_WIN32)
/* The N64 stub declares only lldiv_t/ldiv_t + lldiv/ldiv, and a strict host
* GCC (Linux/macOS) leaks no CRT prototypes at all, so realloc's pointer
* return would be assumed int and truncated (crashed configGetFrameDump).
* (malloc/calloc/free are in port/include/pc_protos.h and a few port TUs'
* own local decls — not repeated here to avoid conflicts.) */
extern void *realloc();
#endif
#endif
+214 -62
View File
@@ -38,9 +38,12 @@
* TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL) can never pass
* while c78 is in ACT_ATTACK -> PERMANENT SOFTLOCK.
*
* The N64 has the identical race and the identical softlock; this watchdog
* is a deliberate port-layer accommodation (same pattern as the D202 M-66b
* port-side expiration). It detects the exact unrecoverable signature and,
* N64 manifestation unverified (findings D318, 2026-09-22 correction): the
* user's empirical prior is that no such freeze occurs on real hardware;
* whether the derail window ever opens organically on N64 is open (an
* unthrottled-emulator deliberate-timing A/B is the discriminating test).
* This watchdog is a deliberate port-layer accommodation either way (same
* pattern as the D202 M-66b port-side expiration). It detects the exact unrecoverable signature and,
* after a long margin, re-initializes c78's attack via sub_GAME_7F025560 --
* the same entry point the game itself uses for a fresh attack (chrlvTickAttack
* type_of_motion==2 calls it with (attacktype, entityid)). That runs
@@ -74,6 +77,42 @@
* session (AllUnlocked) is still covered. Logs its detection and
* intervention for playtest audit.
*
* D320 generalization (2026-09-22, findings D320): the identical pin was
* force-reproduced headless on two more lists, and the watchdog is now
* table-driven over all confirmed signatures:
*
* - 0x0417 ai_22 [321,333) ent=67 c67-alive : D318 Facility execution
* - 0x0414 ai_19 [480,548) any-ent : D320 Facility scientist/
* pad kneel-aim hold (label
* 0x34 stop-check loop; pin
* observed at off=483)
* - 0x040a ai_9 [60,86) any-ent : D320 Control chr-0xFC aim
* hold (label 0x0b stop-check
* loop; pin observed at
* off=69, ~2/3 of variants)
*
* Any chr (not just c78) sitting in a listed loop range in ACT_ATTACK with
* a LIVE-FIRE attacktype (no TARGET_AIM_ONLY bit) for D318_DEADLOCK_TICKS
* consecutive ticks gets the same sub_GAME_7F025560 re-seed. Depot ai_12
* (0x040d) tested clean 3/3 under forced repro and is deliberately NOT
* covered. In every non-deadlocked state each entry is inert: the escape
* path leaves the loop range in < 2 s (60x under the threshold), and a
* re-holding guard's anim frame moves between cycles.
*
* Repeat rescues: for the D320 entries a successful re-seed plays out the
* attack, the stop check passes, and the beat RE-ROLLS (re-aim -> wait ->
* update) -- which can pin AGAIN immediately (verified: in the forced-repro
* contexts every re-roll re-pinned ~240 ticks after each rescue; whether a
* given roll pins depends on the random aim-variant and is context/RNG-
* stream dependent, sometimes ~100%). So there is NO fire cap: while the
* signature persists, the watchdog re-fires every D318W_REFIRE_TICKS (6 s,
* > the ~240-tick re-roll cycle; a non-pinned guard leaves the loop range
* well inside that). This keeps the beat alive -- the guard fires in
* periodic bursts instead of freezing -- until an escape roll lands, the
* target dies (the beats progress on their dying/dead checks), or player
* action breaks the state. Full re-arm only when the signature has been
* absent for D318_REARM_TICKS.
*
* Opt out (research only): GE_D318W=0
*/
#include <stdlib.h>
@@ -103,12 +142,11 @@ extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList);
extern void sub_GAME_7F025560(ChrRecord *self, s32 attack_type, s32 arg2);
#define D318_LIST_AI22 0x0417 /* Ourumov's execution list (ai_22) */
#define D318_OFF_LOOP_MIN 321 /* label 0x01: sleep/stop-check loop */
#define D318_OFF_LOOP_MAX 333 /* exclusive: the headshot command */
#define D318_OURUMOV_CHR 78
#define D318_TREVELYAN_CHR 67
#define D318_DEADLOCK_TICKS 600 /* 10 s at 60 Hz; escape takes < 2 s */
#define D318_REARM_TICKS 600 /* signature-absent ticks before re-arm */
#define D318W_REFIRE_TICKS 360 /* > the ~240-tick re-roll cycle */
static ChrRecord *d318wFindChr(s16 chrnum)
{
@@ -131,73 +169,165 @@ static ChrRecord *d318wFindChr(s16 chrnum)
return NULL;
}
/* Table-driven pin signatures (D318 + D320). off_max is exclusive. */
typedef struct
{
u32 listid;
s32 off_min, off_max;
s32 ent; /* required act_attack.entityid, -1 = any */
s32 target_alive_chr; /* must exist w/ prop and be alive, -1 n/a */
const char *tag; /* log tag */
} D318WEntry;
#define D318W_NENTRIES 3
static const D318WEntry s_wEntries[D318W_NENTRIES] = {
{ 0x0417, 321, 333, D318_TREVELYAN_CHR, D318_TREVELYAN_CHR, "D318 Facility ai_22" },
{ 0x0414, 480, 548, -1, -1, "D320 Facility ai_19" },
{ 0x040a, 60, 86, -1, -1, "D320 Control ai_9" },
};
static bool d318wChrAlive(s32 chrnum)
{
ChrRecord *c = d318wFindChr((s16)chrnum);
return c && c->prop && !chrIsDead(c);
}
/* Does this chr match the entry's pin signature? */
static bool d318wMatches(const D318WEntry *en, ChrRecord *c)
{
bool g = FALSE;
if (!c->ailist)
{
return FALSE;
}
if (chraiGetAIListID(c->ailist, &g) != (s32)en->listid)
{
return FALSE;
}
if (c->aioffset < en->off_min || c->aioffset >= en->off_max)
{
return FALSE;
}
if (c->actiontype != ACT_ATTACK)
{
return FALSE;
}
/* live-fire only: an AIM_ONLY pre-aim holding its pose is not a pin */
if ((s32)c->act_attack.attacktype & D320_TARGET_AIM_ONLY)
{
return FALSE;
}
if (en->ent >= 0 && (s32)c->act_attack.entityid != en->ent)
{
return FALSE;
}
return TRUE;
}
void d318WatchdogTick(void)
{
static int s_enabled = -1; /* -1 uncached, 0 off (GE_D318W=0), 1 on */
static s32 s_run = 0; /* consecutive ticks the signature has held */
static s32 s_absent = 0; /* consecutive ticks signature absent */
static bool s_fired = FALSE;
ChrRecord *c78;
ChrRecord *c67;
bool g = FALSE;
bool sig;
static int s_enabled = -1; /* -1 uncached, 0 off (GE_D318W=0), 1 on */
static s32 s_run[D318W_NENTRIES]; /* sig-held ticks per entry */
static s32 s_absent[D318W_NENTRIES];/* sig-absent ticks per entry */
static s32 s_cool[D318W_NENTRIES]; /* ticks since last fire (sig up) */
static s32 s_fires[D318W_NENTRIES]; /* rescues this occurrence */
static bool s_fired[D318W_NENTRIES];
s32 e, i;
if (s_enabled < 0)
{
const char *e = getenv("GE_D318W");
const char *ev = getenv("GE_D318W");
s_enabled = (e && e[0] == '0') ? 0 : 1;
s_enabled = (ev && ev[0] == '0') ? 0 : 1;
}
if (!s_enabled)
{
return;
}
c78 = d318wFindChr(D318_OURUMOV_CHR);
c67 = d318wFindChr(D318_TREVELYAN_CHR);
sig = (c78 && c78->ailist
&& chraiGetAIListID(c78->ailist, &g) == D318_LIST_AI22
&& c78->aioffset >= D318_OFF_LOOP_MIN && c78->aioffset < D318_OFF_LOOP_MAX
&& c78->actiontype == ACT_ATTACK
&& (s32)c78->act_attack.entityid == D318_TREVELYAN_CHR
&& c67 && c67->prop && !chrIsDead(c67));
if (sig)
for (e = 0; e < D318W_NENTRIES; e++)
{
s_absent = 0;
if (!s_fired)
const D318WEntry *en = &s_wEntries[e];
ChrRecord *hit = NULL;
bool sig = FALSE;
for (i = 0; i < g_NumChrSlots && !hit; i++)
{
if (s_run == 1)
if (d318wMatches(en, &g_ChrSlots[i]))
{
osSyncPrintf("D318W: t=%d deadlock signature detected (c78 ai_22 off=%d "
"ACT_ATTACK ent=67, c67 alive) -- starting %d-tick confirmation window\n",
(int)g_GlobalTimer, (int)c78->aioffset, D318_DEADLOCK_TICKS);
}
s_run++;
if (s_run >= D318_DEADLOCK_TICKS)
{
osSyncPrintf("D318W: t=%d D318 execution deadlock confirmed -- re-initializing "
"c78's attack (sub_GAME_7F025560 atk=0x%x ent=%d) to break the anim pin\n",
(int)g_GlobalTimer, (unsigned)c78->act_attack.attacktype,
(int)c78->act_attack.entityid);
sub_GAME_7F025560(c78, (s32)c78->act_attack.attacktype, (s32)c78->act_attack.entityid);
s_fired = TRUE;
hit = &g_ChrSlots[i];
}
}
}
else
{
s_run = 0;
/* One-shot per occurrence: once the deadlock signature has been
* clear for a while after a fire, re-arm so a later replay of
* Facility in the same process (AllUnlocked) is still covered. */
if (s_fired && ++s_absent >= D318_REARM_TICKS)
for (i = 0; i < g_ActiveChrsCount && !hit; i++)
{
s_fired = FALSE;
s_absent = 0;
osSyncPrintf("D318W: t=%d signature clear for %d ticks -- watchdog re-armed\n",
(int)g_GlobalTimer, D318_REARM_TICKS);
if (d318wMatches(en, &g_ActiveChrs[i]))
{
hit = &g_ActiveChrs[i];
}
}
sig = hit && (en->target_alive_chr < 0 || d318wChrAlive(en->target_alive_chr));
if (sig)
{
s_absent[e] = 0;
if (!s_fired[e])
{
if (s_run[e] == 1)
{
osSyncPrintf("D318W: t=%d [%s] pin signature detected (c%d off=%d "
"ACT_ATTACK atk=0x%x ent=%d) -- starting %d-tick confirmation window\n",
(int)g_GlobalTimer, en->tag, (int)hit->chrnum,
(int)hit->aioffset, (unsigned)hit->act_attack.attacktype,
(int)hit->act_attack.entityid, D318_DEADLOCK_TICKS);
}
s_run[e]++;
if (s_run[e] >= D318_DEADLOCK_TICKS)
{
osSyncPrintf("D318W: t=%d [%s] anim pin confirmed -- re-initializing "
"c%d's attack (sub_GAME_7F025560 atk=0x%x ent=%d) to break it\n",
(int)g_GlobalTimer, en->tag, (int)hit->chrnum,
(unsigned)hit->act_attack.attacktype,
(int)hit->act_attack.entityid);
sub_GAME_7F025560(hit, (s32)hit->act_attack.attacktype,
(s32)hit->act_attack.entityid);
s_fired[e] = TRUE;
s_fires[e] = 1;
s_cool[e] = 0;
}
}
else if (++s_cool[e] >= D318W_REFIRE_TICKS)
{
/* The beat re-rolled and re-pinned (see header). No cap: */
s_cool[e] = 0;
osSyncPrintf("D318W: t=%d [%s] re-pin rescued (%d) -- c%d off=%d "
"atk=0x%x ent=%d\n",
(int)g_GlobalTimer, en->tag, s_fires[e] + 1,
(int)hit->chrnum, (int)hit->aioffset,
(unsigned)hit->act_attack.attacktype,
(int)hit->act_attack.entityid);
sub_GAME_7F025560(hit, (s32)hit->act_attack.attacktype,
(s32)hit->act_attack.entityid);
s_fires[e]++;
}
}
else
{
s_run[e] = 0;
s_cool[e] = 0;
/* Once the signature has been clear for a while after a fire,
* re-arm so a later replay of the level in the same process
* (AllUnlocked) is still covered. */
if (s_fired[e] && ++s_absent[e] >= D318_REARM_TICKS)
{
s_fired[e] = FALSE;
s_absent[e] = 0;
s_fires[e] = 0;
osSyncPrintf("D318W: t=%d [%s] signature clear for %d ticks -- watchdog re-armed\n",
(int)g_GlobalTimer, en->tag, D318_REARM_TICKS);
}
}
}
}
@@ -303,6 +433,29 @@ void d318TimelineTick(void)
return;
}
/* Unified chr view: real guards live in g_ChrSlots[0..g_NumChrSlots) with
* model != NULL; the background script entities (chrnum 0xFE, list IDs
* >= 0x1000) live in g_ActiveChrs. Both are ticked by chrlvAllChrTick,
* so both must be scanned for pins / fingerprints. (D320, 2026-09-22:
* the detector originally walked g_ActiveChrs only and was blind to
* every real guard.) */
static ChrRecord *s_all[96];
static int s_nall = 0;
s32 i;
s_nall = 0;
for (i = 0; i < g_NumChrSlots && s_nall < 96; i++)
{
if (g_ChrSlots[i].model != NULL)
{
s_all[s_nall++] = &g_ChrSlots[i];
}
}
for (i = 0; i < g_ActiveChrsCount && s_nall < 96; i++)
{
s_all[s_nall++] = &g_ActiveChrs[i];
}
c78 = d318wFindChr(D318_OURUMOV_CHR);
c67 = d318wFindChr(D318_TREVELYAN_CHR);
@@ -413,12 +566,11 @@ void d318TimelineTick(void)
if (++s_det >= 60)
{
s32 h = 0x811C9DC5;
s32 i;
s_det = 0;
for (i = 0; i < g_ActiveChrsCount; i++)
for (i = 0; i < s_nall; i++)
{
ChrRecord *c = &g_ActiveChrs[i];
ChrRecord *c = s_all[i];
union { f32 f; u32 u; } xf, yf, zf, df;
xf.f = c->prop ? c->prop->pos.x : 0.0f;
@@ -436,7 +588,7 @@ void d318TimelineTick(void)
osSyncPrintf("D318T: DET t=%d seed=%08x%08x obj=0x%08x h=%08x nchrs=%d\n",
(int)g_GlobalTimer,
(unsigned)(u32)(g_randomSeed >> 32), (unsigned)(u32)g_randomSeed,
(unsigned)objectiveregisters1, (unsigned)h, (int)g_ActiveChrsCount);
(unsigned)objectiveregisters1, (unsigned)h, s_nall);
}
/* D320: generic D318-class pin DETECTION for the sweep's flagged lists
@@ -463,9 +615,9 @@ void d318TimelineTick(void)
static int s_npins = 0;
s32 i, j;
for (i = 0; i < g_ActiveChrsCount; i++)
for (i = 0; i < s_nall; i++)
{
ChrRecord *c = &g_ActiveChrs[i];
ChrRecord *c = s_all[i];
union { f32 f; u32 u; } fr;
D320Pin *p = NULL;
@@ -538,9 +690,9 @@ void d318TimelineTick(void)
{
bool alive = FALSE;
for (j = 0; j < g_ActiveChrsCount; j++)
for (j = 0; j < s_nall; j++)
{
ChrRecord *c = &g_ActiveChrs[j];
ChrRecord *c = s_all[j];
if (c->chrnum == s_pins[i].chrnum && c->actiontype == ACT_ATTACK
&& c->model != NULL
+422
View File
@@ -0,0 +1,422 @@
/* d320repro.c -- D320: forced-repro harness for the three high-risk
* D318-class aim-hold softlock lists (Facility ai_19, Control ai_9,
* Depot ai_12; findings D320).
*
* These beats are scripted cutscene moments that do not play in a bare
* headless boot (the scene is static with no input), so -- exactly like
* D318's mode-5 repro -- the probe forces them: at chosen ticks it can
* dump the chr roster, hijack a guard's AI pointer to a loaded list at a
* given byte offset, and/or set objective-register bits. The in-tree
* D320T pin detector (d318watchdog.c, GE_D318T=1) then reports any
* live-fire attack whose anim frame stays byte-stable for 600 ticks --
* the exact frozen shape.
*
* TEMP: investigation probe, remove when D320 concludes. Inert without
* the env var (zero cost).
*
* Usage: GE_D320R="tick:action;tick:action;..." where action is one of
* roster -- dump every active chr (num, act,
* list id, offset, alive)
* jump=<chr>,list=<hexid>,off=<n> -- set the chr's ailist to
* ailistFindById(hexid) at byte
* offset n, sleep=0
* bit=<hex> -- objectiveregisters1 |= hex
* rename=<chr>,<newnum> -- reassign a chr's chrnum (used to
* materialize an absent target, e.g.
* Control ai_9 aims at 0xFC which is
* not spawned in a bare boot)
* seed=<hi32>,<lo32> -- overwrite g_randomSeed (the boot
* seed is deterministic, so vary this
* between runs to sample different
* aim-variant branches)
* trace=<chr> -- from this tick on, log the chr's
* off/act/atk/ent/frame/endframe on
* any change + a 120-tick heartbeat
* while it is in ACT_ATTACK
*
* Example (Facility ai_19 hold block):
* GE_D320R="60:roster;900:jump=5,list=0x0414,off=341;900:trace=5"
*/
#include <stdlib.h>
#include <string.h>
#include "PR/os.h"
#include "bondtypes.h"
#include "chr.h"
#include "chrai.h"
#include "chraction.h"
#include "lv.h"
#include "model.h"
#ifdef PORT
/* chrai.c defines these but chrai.h does not declare them. */
extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList);
/* port/src/random.c; not declared in a shared header. */
extern u64 g_randomSeed;
#define D320R_MAX_ACTIONS 16
#define D320R_MAX_TRACES 4
typedef struct
{
s32 tick;
int kind; /* 0 roster, 1 jump, 2 bit, 3 trace */
s16 chr;
s32 listid;
u16 off;
u32 bit; /* also: new chrnum for rename */
int fired;
} D320RAction;
typedef struct
{
s16 chr;
s32 off;
s32 act;
s32 atk;
s32 ent;
u32 framebits;
s32 hb;
} D320RTrace;
static ChrRecord *d320rFindChr(s16 chrnum)
{
s32 i;
for (i = 0; i <g_NumChrSlots; i++)
{
if (g_ChrSlots[i].chrnum == chrnum)
{
return &g_ChrSlots[i];
}
}
for (i = 0; i < g_ActiveChrsCount; i++)
{
if (g_ActiveChrs[i].chrnum == chrnum)
{
return &g_ActiveChrs[i];
}
}
return NULL;
}
static void d320rDumpRoster(void)
{
s32 i;
osSyncPrintf("D320R: t=%d roster slots=%d bgchrs=%d\n", (int)g_GlobalTimer,
(int)g_NumChrSlots, (int)g_ActiveChrsCount);
for (i = 0; i < g_NumChrSlots; i++)
{
ChrRecord *c = &g_ChrSlots[i];
bool g = FALSE;
s32 aid;
if (c->model == NULL)
{
continue;
}
aid = c->ailist ? chraiGetAIListID(c->ailist, &g) : -1;
osSyncPrintf("D320R: S c%d act=%d aiid=0x%04x%s off=%d atk=0x%x ent=%d %s\n",
(int)c->chrnum, (int)c->actiontype, (unsigned)aid,
g ? "G" : "", (int)c->aioffset,
(unsigned)c->act_attack.attacktype,
(int)c->act_attack.entityid,
chrIsDead(c) ? "DEAD" : "");
}
for (i = 0; i < g_ActiveChrsCount; i++)
{
ChrRecord *c = &g_ActiveChrs[i];
bool g = FALSE;
s32 aid = c->ailist ? chraiGetAIListID(c->ailist, &g) : -1;
osSyncPrintf("D320R: B c%d act=%d aiid=0x%04x%s off=%d\n",
(int)c->chrnum, (int)c->actiontype, (unsigned)aid,
g ? "G" : "", (int)c->aioffset);
}
}
void d320ReproTick(void)
{
static int s_on = -1; /* -1 uncached, 0 off, 1 on */
static D320RAction s_a[D320R_MAX_ACTIONS];
static int s_na = 0;
static D320RTrace s_tr[D320R_MAX_TRACES];
static int s_nt = 0;
s32 i;
if (s_on < 0)
{
const char *e = getenv("GE_D320R");
s_on = (e && e[0]) ? 1 : 0;
if (s_on)
{
/* Parse "tick:action;..." -- one pass, fixed budget. */
char buf[512];
char *p, *save;
strncpy(buf, e, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = 0;
p = buf;
while (p && *p && s_na < D320R_MAX_ACTIONS)
{
char *semi = strchr(p, ';');
char *colon;
D320RAction *a = &s_a[s_na];
if (semi)
{
*semi = 0;
}
colon = strchr(p, ':');
if (!colon)
{
break;
}
*colon = 0;
a->tick = (s32)strtol(p, NULL, 10);
a->kind = -1;
a->chr = -1;
a->listid = 0;
a->off = 0;
a->bit = 0;
a->fired = 0;
if (strcmp(colon + 1, "roster") == 0)
{
a->kind = 0;
}
else if (strncmp(colon + 1, "jump=", 5) == 0)
{
const char *q = colon + 6;
while (q && *q)
{
char *eq = strchr(q, '=');
char *cm = strchr(q, ',');
const char *key;
if (!eq)
{
break;
}
if (cm && cm < eq)
{
break; /* malformed */
}
*eq = 0;
key = q; /* const-cast: parsed in place below */
if (strcmp(key, "chr") == 0) { a->chr = (s16)strtol(eq + 1, NULL, 0); }
else if (strcmp(key, "list") == 0){ a->listid = (s32)strtol(eq + 1, NULL, 0); }
else if (strcmp(key, "off") == 0) { a->off = (u16)strtol(eq + 1, NULL, 0); }
q = cm ? cm + 1 : NULL;
}
if (a->chr >= 0 && a->listid)
{
a->kind = 1;
}
}
else if (strncmp(colon + 1, "bit=", 4) == 0)
{
a->bit = (u32)strtol(colon + 5, NULL, 0);
a->kind = 2;
}
else if (strncmp(colon + 1, "trace=", 6) == 0)
{
a->chr = (s16)strtol(colon + 7, NULL, 0);
a->kind = 3;
}
else if (strncmp(colon + 1, "rename=", 7) == 0)
{
const char *q = colon + 8;
char *cm = strchr(q, ',');
if (cm)
{
*cm = 0;
a->chr = (s16)strtol(q, NULL, 0);
a->bit = (u32)strtol(cm + 1, NULL, 0);
a->kind = 4;
}
}
else if (strncmp(colon + 1, "seed=", 5) == 0)
{
const char *q = colon + 6;
char *cm = strchr(q, ',');
if (cm)
{
*cm = 0;
a->listid = (s32)(u32)strtol(q, NULL, 0); /* hi */
a->bit = (u32)strtol(cm + 1, NULL, 0); /* lo */
a->kind = 5;
}
}
if (a->kind >= 0)
{
s_na++;
}
p = semi ? semi + 1 : NULL;
}
osSyncPrintf("D320R: armed %d actions\n", s_na);
}
}
if (!s_on)
{
return;
}
for (i = 0; i < s_na; i++)
{
D320RAction *a = &s_a[i];
if (a->fired || (s32)g_GlobalTimer < a->tick)
{
continue;
}
a->fired = 1;
switch (a->kind)
{
case 0: /* roster */
d320rDumpRoster();
break;
case 1: /* jump */
{
ChrRecord *c = d320rFindChr(a->chr);
if (c)
{
AIRecord *list = ailistFindById(a->listid);
if (list)
{
c->ailist = list;
c->aioffset = a->off;
c->sleep = 0;
osSyncPrintf("D320R: t=%d JUMP c%d -> aiid=0x%04x off=%d\n",
(int)g_GlobalTimer, (int)c->chrnum,
(unsigned)a->listid, (int)a->off);
}
else
{
osSyncPrintf("D320R: t=%d JUMP FAILED c%d aiid=0x%04x not found\n",
(int)g_GlobalTimer, (int)c->chrnum,
(unsigned)a->listid);
}
}
else
{
osSyncPrintf("D320R: t=%d JUMP FAILED c%d not active\n",
(int)g_GlobalTimer, (int)a->chr);
}
break;
}
case 2: /* bit */
objectiveregisters1 |= a->bit;
osSyncPrintf("D320R: t=%d BIT |= 0x%08x (now 0x%08x)\n",
(int)g_GlobalTimer, (unsigned)a->bit,
(unsigned)objectiveregisters1);
break;
case 3: /* trace */
if (s_nt < D320R_MAX_TRACES)
{
s_tr[s_nt].chr = a->chr;
s_tr[s_nt].off = -1;
s_tr[s_nt].act = -1;
s_tr[s_nt].atk = -1;
s_tr[s_nt].ent = -1;
s_tr[s_nt].framebits = 0;
s_tr[s_nt].hb = 0;
s_nt++;
osSyncPrintf("D320R: t=%d TRACE c%d armed\n",
(int)g_GlobalTimer, (int)a->chr);
}
break;
case 4: /* rename */
{
ChrRecord *c = d320rFindChr(a->chr);
if (c)
{
osSyncPrintf("D320R: t=%d RENAME c%d -> c%d\n",
(int)g_GlobalTimer, (int)c->chrnum,
(int)a->bit);
c->chrnum = (s16)a->bit;
}
else
{
osSyncPrintf("D320R: t=%d RENAME FAILED c%d not found\n",
(int)g_GlobalTimer, (int)a->chr);
}
break;
}
case 5: /* seed */
g_randomSeed = ((u64)(u32)a->listid << 32) | (u32)a->bit;
osSyncPrintf("D320R: t=%d SEED := %08x%08x\n",
(int)g_GlobalTimer, (unsigned)(u32)(g_randomSeed >> 32),
(unsigned)(u32)g_randomSeed);
break;
default:
break;
}
}
for (i = 0; i < s_nt; i++)
{
D320RTrace *t = &s_tr[i];
ChrRecord *c = d320rFindChr(t->chr);
u32 fb = 0;
f32 fr = 0.0f, ef = -1.0f;
if (!c)
{
continue;
}
if (c->model)
{
union { f32 f; u32 u; } x;
x.f = modelGetAnimFrame(c->model);
fb = x.u;
fr = x.f;
ef = modelGetAnimEndFrame(c->model);
}
if (t->off != (s32)c->aioffset || t->act != (s32)c->actiontype
|| t->atk != (s32)c->act_attack.attacktype
|| t->ent != (s32)c->act_attack.entityid)
{
osSyncPrintf("D320R: t=%d c%d off=%d act=%d atk=0x%x ent=%d frame=%.2f endf=%.2f\n",
(int)g_GlobalTimer, (int)c->chrnum, (int)c->aioffset,
(int)c->actiontype, (unsigned)c->act_attack.attacktype,
(int)c->act_attack.entityid, (double)fr, (double)ef);
t->off = (s32)c->aioffset;
t->act = (s32)c->actiontype;
t->atk = (s32)c->act_attack.attacktype;
t->ent = (s32)c->act_attack.entityid;
}
else if (c->actiontype == ACT_ATTACK && fb != t->framebits)
{
/* frame is moving: note the range so a later stall is visible */
osSyncPrintf("D320R: t=%d c%d frame MOVED to %.2f (endf=%.2f off=%d)\n",
(int)g_GlobalTimer, (int)c->chrnum, (double)fr,
(double)ef, (int)c->aioffset);
}
t->framebits = fb;
if (c->actiontype == ACT_ATTACK && ++t->hb >= 120)
{
t->hb = 0;
osSyncPrintf("D320R: t=%d c%d hb off=%d act=%d atk=0x%x ent=%d frame=%.2f endf=%.2f\n",
(int)g_GlobalTimer, (int)c->chrnum, (int)c->aioffset,
(int)c->actiontype, (unsigned)c->act_attack.attacktype,
(int)c->act_attack.entityid, (double)fr, (double)ef);
}
}
}
#endif /* PORT */
+70
View File
@@ -230,6 +230,76 @@ f32 portLodDistanceMultiplier(void)
return 100.0f / pct;
}
/* D294: the room-model pool (mema, sized per level by boss.c's
* memallocstringtable `-maNNN` rows) was budgeted by Rare for the N64's fixed
* ~60deg FOV / 4:3 / authored far-clip. The PC visibility knobs above widen
* what is on screen at once (FovScale + WidescreenAuto widen the frustum;
* DrawDistance pushes the far clip; LodDistance keeps high-detail meshes out
* further), so more rooms stay resident simultaneously. When demand exceeds
* the authored pool, memaAlloc() returns NULL and bgLoadRoomModelData() bails
* silently -- the undrawn room's scissor region shows the framebuffer clear
* colour: a transient solid-black patch of ground that self-recovers when the
* player moves (D294, Statue monument plaza at FovScale 130 + max DD/Lod).
*
* This returns a pool-size multiplier for boss.c to apply to `-ma` on PC:
* - FOV term: manual FovScale composed with the WidescreenAuto aspect
* scale (same sqrt(aspect/4:3) factor portScaleFovY uses), since both
* widen the frustum horizontally and admit more side rooms;
* - DD/Lod terms: square-root of the distance multipliers -- room COUNT
* grows far slower than distance-squared, so sqrt keeps the bump modest;
* a linear-terms + 3.0-cap variant was tried (Statue's full room set is
* 487 KB = 2.2x its authored pool and starves even 2x) but regressed
* the renderer on the menu-driven path -- see the in-function note;
* capped at 2.0x (worst case `-ma350` level row -> 700 KB, well inside the
* PC's ~6 MB STAGE bank; see D95). NOTE: a live instrumented Statue playtest
* showed 2x still starves under whole-level room churn (the full set is
* 487 KB = 2.2x the authored `-ma220` pool), and the attempted 3x/linear
* fix REGRESSED the renderer on the menu-driven path -- reverted, see the
* in-function note + findings D294. Identity (1.0) when every knob is at its
* N64-faithful value, so all-100 settings keep the exact authored budget.
* Called once per stage load from bossMainloop (src/boss.c).
*
* GE_ROOMPOOL=<float> overrides the computed scale outright (test hook for
* before/after captures; also a user opt-out if the larger pool ever causes
* trouble on a level). */
f32 portRoomPoolScale(void)
{
const char *ov = getenv("GE_ROOMPOOL");
f32 scale = 1.0f;
f32 t;
if (ov && *ov) {
/* Test hook: values < 1.0 shrink the pool BELOW the authored size
* to stress-test the exhaustion path (D294 verification). */
scale = (f32)atof(ov);
if (scale > 2.0f) { scale = 2.0f; }
if (scale < 0.25f) { scale = 0.25f; }
return scale;
}
t = portFovScale;
if (cfgWidescreenAuto && gfx_current_dimensions.aspect_ratio > 0.01f) {
t *= sqrtf(gfx_current_dimensions.aspect_ratio / (4.0f / 3.0f));
}
if (t > scale) { scale = t; }
/* D294 follow-up (2026-09-22): linear terms + a 3.0 cap were tried to
* cover Statue's full 487 KB room set, but that build corrupted model
* anim state on the MENU-driven Statue path (D156 NaN frames; direct
* -level_22 boots stayed clean headless). Reverted to sqrt + 2.0 cap,
* the user-verified working state. Do not re-apply without the capture-bat
* repro data (scratch/d294_capture.bat -> D156 hexdump + GE_D294BANK). */
t = sqrtf(portDrawDistanceMultiplier());
if (t > scale) { scale = t; }
t = sqrtf(1.0f / portLodDistanceMultiplier());
if (t > scale) { scale = t; }
if (scale > 2.0f) { scale = 2.0f; }
if (scale < 1.0f) { scale = 1.0f; }
return scale;
}
PD_CONSTRUCTOR static void videoConfigInit(void)
{
configRegisterFloat("Game.ScreenShakeIntensity", &portScreenShakeScale, 0.0f, 10.0f);
+27
View File
@@ -466,7 +466,34 @@ void bossMainloop(void)
{
g_CurentMaMallocValue = (s32) (strtol(tokenFind(1, "-ma"), NULL, 0) * 1024);
}
#ifdef PORT
/* D294: PC visibility settings (Video.FovScale / WidescreenAuto /
* DrawDistance / LodDistance) keep more rooms on screen at once than
* the N64's fixed view, so the authored `-ma` room pool can exhaust;
* memaAlloc() then fails and undrawn rooms render as solid black.
* Scale the pool by portRoomPoolScale() (1.0 at all-N64-faithful
* settings, capped 2.0x) and never let it eat more than the STAGE
* bank minus 128 KB headroom for zbuf/object resources. */
{
extern f32 portRoomPoolScale(void);
f32 scale = portRoomPoolScale();
s32 avail;
u32 scaled;
if (scale < 0.9999f || scale > 1.0001f) {
scaled = (u32)(((s64)g_CurentMaMallocValue * (s64)(scale * 4.0f) + 3) / 4);
avail = mempGetBankSizeLeft(MEMPOOL_STAGE) - 0x20000;
if ((s32)scaled > avail && avail >= (s32)g_CurentMaMallocValue) {
scaled = (u32)avail;
}
g_CurentMaMallocValue = scaled;
}
}
#endif
#ifdef PORT
if (getenv("GE_D294")) { osSyncPrintf("D294 stage=%d ma=%d\n", (s32)g_StageNum, (s32)g_CurentMaMallocValue); }
#endif
memaReset(mempAllocBytesInBank(g_CurentMaMallocValue, MEMPOOL_STAGE), g_CurentMaMallocValue);
reset_play_data_ptrs();
#ifdef PORT
+74 -27
View File
@@ -591,6 +591,52 @@ s32 bgGet2dBboxByRoomId(s32 room_id, struct bbox2d *result)
*/
Gfx *sub_GAME_7F0B3C8C(Gfx *gdl)
{
#if defined(PORT)
/* TEMP D104/D294: once per frame (this function builds the whole bg DL),
* log the drawn-room list with load state + scissor boxes. */
{
static s32 d104f = 0;
static int d104on = -1; /* D302-class cache: once per frame */
#ifdef PORT
/* TEMP D294-BANK: dump memp bank state to find the STAGE overflow. */
static int d294b_on = -1;
static s32 d294b_lastma = -1;
extern s32 g_CurentMaMallocValue;
if (d294b_on < 0) d294b_on = getenv("GE_D294BANK") != NULL;
if (d294b_on && g_CurentMaMallocValue != d294b_lastma) {
d294b_lastma = g_CurentMaMallocValue;
d104f = 0; /* restart window on stage change */
}
if (d294b_on && d104f < 6) {
extern MemoryPool g_mempPools[];
extern s32 needmemallocation;
osSyncPrintf("D294B f=%d STAGE[%p..%p] pos=%p left=%d PERM[%p..%p] pos=%p left=%d needmm=%d\n",
d104f, (void*)g_mempPools[MEMPOOL_STAGE].start, (void*)g_mempPools[MEMPOOL_STAGE].end,
(void*)g_mempPools[MEMPOOL_STAGE].pos, (s32)(g_mempPools[MEMPOOL_STAGE].end - g_mempPools[MEMPOOL_STAGE].pos),
(void*)g_mempPools[MEMPOOL_PERMANENT].start, (void*)g_mempPools[MEMPOOL_PERMANENT].end,
(void*)g_mempPools[MEMPOOL_PERMANENT].pos, (s32)(g_mempPools[MEMPOOL_PERMANENT].end - g_mempPools[MEMPOOL_PERMANENT].pos),
(int)needmemallocation);
}
#endif
if (d104on < 0) d104on = getenv("GE_D104") != NULL;
if (d104on && (d104f++ % 30) == 0 && d104f < 240000) {
s32 rr;
osSyncPrintf("D104 f=%d curRoom=%d roomsDrawn=%d budget=%d :",
d104f, (s32)g_CurrentPlayer->curRoomIndex,
(s32)g_BgNumberOfRoomsDrawn, (s32)g_RoomLoadBudget);
for (rr = 0; rr < g_BgNumberOfRoomsDrawn && rr < 40; rr++) {
s32 rid = dword_CODE_bss_8007FFA0[rr].roomid;
osSyncPrintf(" r%d(ld=%d %d,%d-%d,%d)", rid,
(s32)g_BgRoomInfo[rid].model_bin_loaded,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.min.x,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.min.y,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.max.x,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.max.y);
}
osSyncPrintf("\n");
}
}
#endif
#ifdef VERSION_EU
s16 i;
#else
@@ -647,30 +693,6 @@ Gfx *sub_GAME_7F0B3C8C(Gfx *gdl)
}
gSPMatrix(gdl++, osVirtualToPhysical((void*)get_BONDdata_field_10E0()), (G_MTX_NOPUSH | G_MTX_LOAD | G_MTX_PROJECTION));
#ifdef PORT
/* TEMP D104: room render-pass geometry-visibility probe. */
{
static s32 d104c = 0;
static int d104on = -1; /* D302-class cache: this runs per room pass, per frame */
if (d104on < 0) d104on = getenv("GE_D104") != NULL;
if (d104on && (d104c++ % 600) == 0 && d104c < 12000) {
s32 rr;
osSyncPrintf("D104 tick=%d curRoom=%d roomsDrawn=%d budget=%d :",
d104c, (s32)g_CurrentPlayer->curRoomIndex,
(s32)g_BgNumberOfRoomsDrawn, (s32)g_RoomLoadBudget);
for (rr = 0; rr < g_BgNumberOfRoomsDrawn && rr < 24; rr++) {
s32 rid = dword_CODE_bss_8007FFA0[rr].roomid;
osSyncPrintf(" r%d(ld=%d %d,%d-%d,%d)", rid,
(s32)g_BgRoomInfo[rid].model_bin_loaded,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.min.x,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.min.y,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.max.x,
(s32)dword_CODE_bss_8007FFA0[rr].bbox.max.y);
}
osSyncPrintf("\n");
}
}
#endif
gdl = fogSetRenderFogColor(
bgScissorCurrentPlayerViewF(
gdl++,
@@ -2664,11 +2686,20 @@ void bgLoadRoomModelData(s32 roomID)
used = 0;
#if defined(PORT)
static int d104ld_on = -1; /* D302-class cache */
static s32 d104ld_n = 0;
if (d104ld_on < 0) d104ld_on = getenv("GE_D104") != NULL;
#define D104LD(...) do { if (d104ld_on && d104ld_n++ < 4000) osSyncPrintf(__VA_ARGS__); } while (0)
#else
#define D104LD(...) do {} while (0)
#endif
// Room ID is out of range?
if (roomID >= g_MaxNumRooms) goto end;
if (roomID >= g_MaxNumRooms) { D104LD("D104 LOAD room=%d FAIL oob\n", roomID); goto end; }
// Room is already loaded?
if (g_BgRoomInfo[roomID].model_bin_loaded) goto end;
if (g_BgRoomInfo[roomID].model_bin_loaded) { D104LD("D104 LOAD room=%d skip ld=%d\n", roomID, (s32)g_BgRoomInfo[roomID].model_bin_loaded); goto end; }
// Get the cached file size. Is zero when the size is not yet known.
allocsize = g_BgRoomInfo[roomID].cur_room_totalsize;
@@ -2692,7 +2723,7 @@ void bgLoadRoomModelData(s32 roomID)
*/
data = memaAlloc(allocsize);
if (data == NULL) goto end;
if (data == NULL) { D104LD("D104 LOAD room=%d FAIL alloc size=%d\n", roomID, allocsize); goto end; }
if (g_BgRoomInfo[roomID].csize_point_index_binary)
{
@@ -2742,6 +2773,10 @@ void bgLoadRoomModelData(s32 roomID)
g_BgRoomInfo[roomID].cur_room_totalsize = ((used + 0x20) & ~0xf);
g_BgRoomInfo[roomID].model_bin_loaded = 1;
D104LD("D104 LOAD room=%d OK used=%d vtx=%d prim=%d sec=%d\n", roomID, used,
(s32)g_BgRoomInfo[roomID].usize_point_index_binary,
(s32)g_BgRoomInfo[roomID].usize_primary_DL_binary,
(s32)g_BgRoomInfo[roomID].usize_secondary_DL_binary);
// If wasted space is detected, shrink allocated memory block.
if (allocsize != ((used + 0x20) & ~0xf))
@@ -2904,6 +2939,18 @@ Gfx *bgRenderRoomPrimary(Gfx *gdl, s32 room_index)
if (g_BgRoomInfo[room_index].model_bin_loaded == 0)
{
#if defined(PORT)
/* TEMP D104/D294: room is on the drawn list but not rendered this
* frame (no load budget / load failed) -- its scissor region shows
* whatever was behind it. Log every occurrence, capped. */
static int d104skip_on = -1;
static s32 d104skip_n = 0;
if (d104skip_on < 0) d104skip_on = getenv("GE_D104") != NULL;
if (d104skip_on && d104skip_n++ < 4000) {
osSyncPrintf("D104 SKIP room=%d budget=%d\n",
room_index, (s32)g_RoomLoadBudget);
}
#endif
return gdl;
}
else
+3 -1
View File
@@ -646,7 +646,9 @@ void bullet_spark_render(s_bullet_spark *thing, Gfx *gdlarg, s32 zbufferMode)
* the local directly instead of reading 12 bytes past a 4-byte global
* (harmless in practice so far, but real UB the PC compiler already
* flags with -Warray-bounds). */
memset(&vtx, 0, sizeof(vtx));
/* __builtin_memset: this TU sees no memset prototype (D324); the builtin
* needs none and generates identical code. */
__builtin_memset(&vtx, 0, sizeof(vtx));
#else
vtx = *((Vtx *) (&D_80040980));
#endif
+2
View File
@@ -1053,8 +1053,10 @@ void lvlManageMpGame(void)
{
extern void d318WatchdogTick(void);
extern void d318TimelineTick(void);
extern void d320ReproTick(void);
d318WatchdogTick(); /* D318 permanent port-side deadlock recovery (findings D318) */
d318TimelineTick(); /* D318T env-gated diagnostic timeline (GE_D318T=1) */
d320ReproTick(); /* D320R env-gated forced-repro harness (GE_D320R, TEMP) */
}
#endif
if ((g_CurrentStageToLoad != LEVELID_TITLE) && (D_80048394 == 0) && (g_ClockTimer > 0))
+10 -2
View File
@@ -3896,15 +3896,23 @@ void modelTickAnim(struct Model *model, s32 numticks, s32 update_chrstuff)
{
ge_d156_logged = 1;
fprintf(stderr,
"[D156] bad anim frame guarded: frame=%g animframe1=%g "
"[D156] bad anim frame guarded: model=%p frame=%g animframe1=%g "
"speed=%g playspeed=%g newspeed=%g oldspeed=%g "
"timespeed=%g elapsespeed=%g unkb0=%g unk88=%g anim=%p\n",
(double)frame, (double)model->animframe1,
(void *)model, (double)frame, (double)model->animframe1,
(double)model->speed, (double)model->playspeed,
(double)model->newspeed, (double)model->oldspeed,
(double)model->timespeed, (double)model->elapsespeed,
(double)model->unkb0, (double)model->unk88,
(void *)model->anim);
/* TEMP D294: hexdump the struct to identify the overwriter. */
{
const unsigned char *p = (const unsigned char *)model;
int i;
fprintf(stderr, "[D156] model=%p bytes:", (void *)model);
for (i = 0; i < 128; i++) { if ((i & 15) == 0) fprintf(stderr, "\n +%02x: ", i); fprintf(stderr, "%02x ", p[i]); }
fprintf(stderr, "\n");
}
fflush(stderr);
}
/* D311: heal the stored state BEFORE falling back to it -- if
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""LOD-binding audit: which bg room draw classes combine G_TL_LOD (other_mode_H
bit 16) with a texture that has AUTHORED mip levels (explicit-LOD header,
imagelist control byte bit 7).
Why this matters (D236-class follow-up): on N64, `texWriteTileLods` places each
authored LOD level at successive TMEM addresses (tile n @ cumulative size) and
the RDP's G_TL_LOD sampling selects the coarser tile as geometry minifies.
The port folds every G_TL_LOD draw to tile 0 (D107, with the D236 type-1
exception) and lets GL generate driver mips from the finest level instead. For
ordinary textures that is a subtle approximation; for 1-bit-alpha cut-outs it
is the D236 smear mechanism via a different binding type.
GE's GBI dialect (modified fast3d, see gmain.s): G_SETOTHERMODE_L = 0xB9 with
w0 bits 8-15 = shift, bits 0-7 = num_bits; OML is shift 3 / 29 bits
(0xB900031D), OMH is shift 16 / 16 bits (0xBA001010).
Usage: python tools_pc/bg_lod_audit.py [level ...] (no args: all levels)
"""
import collections
import io
import os
import struct
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
import bg_gdl_census as census
import texheader as th
G_NOOP = 0xC0
def walk_lod_binds(gdl):
"""Yield (oml, omh, texnum, type) for every G_NOOP texture bind.
GE's modified fast3d encodes G_SETOTHERMODE_L/H (0xB9/0xBA) as partial
field writes: w0 bits 8-15 = shift, bits 0-7 = num_bits. For OMH the value
sits at its ABSOLUTE bit position in w1 (fast3d calls
gfx_sp_set_other_mode(shift+32, nbits, w1<<32) -- an identity map, see
gfx_pc.cpp:3707-3708): e.g. w1=0x00010000 sets TEXTLOD (bit 16),
w1=0x00040000 sets TEXTDETAIL=G_TD_DETAIL (bits 17-18), w1=0x00100000
sets CYCLETYPE=G_CYC_2CYCLE (bit 20). For OML fast3d ORs w1 in at bits
3-31; the raw w1 is what probes log as `oml`, so keep it as the class key.
The texture TYPE of a G_NOOP bind is w0 & 7 (tex.c `switch (in->words.w0
& 7)`); texnum = w1 & 0xfff.
"""
cur_oml, cur_omh = 0, 0
n = len(gdl) // 8
for k in range(n):
w0, w1 = struct.unpack_from(">II", gdl, k * 8)
op = w0 >> 24
if op == 0xB9:
cur_oml = (cur_oml & 7) | w1
elif op == 0xBA:
shift = (w0 >> 8) & 0xFF
mask = ((1 << (w0 & 0xFF)) - 1) << shift
cur_omh = (cur_omh & ~mask) | (w1 & mask)
elif op == G_NOOP:
yield cur_oml, cur_omh, w1 & 0xFFF, w0 & 7
def main():
region = "ntsc-final"
levels = sys.argv[1:] or None
rom, rows = th.load(region)
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
import d43_emit as m
bg_files = sorted(k for k in m.fl_by_base if k.startswith("bg_") and k != "bgx")
if levels:
bg_files = ["bg_%s_all_p" % l for l in levels]
grand = collections.Counter()
alpha_hits = []
for name in bg_files:
level = name[3:-6] # strip 'bg_' and '_all_p'
try:
data = census.load_rom_file(name, region)
except SystemExit as e:
print("%s: %s" % (name, e))
continue
if not data:
print("%-28s (empty file-table entry -- level reuses another bg file)"
% name)
continue
rooms, _ = census.read_rooms(data)
lod_explicit = collections.Counter() # texnum -> binds under LOD-on
lod_total = 0
type1_explicit = collections.Counter()
for i in range(1, len(rooms)):
csz_pri, csz_sec = census.blob_sizes(rooms, i)
for off_raw, csz in ((rooms[i][1], csz_pri), (rooms[i][2], csz_sec)):
if not off_raw or csz <= 0:
continue
out = census.inflate_blob(data, census.seg_off(off_raw), csz)
if out is None or isinstance(out, tuple):
continue
for oml, omh, texnum, ttype in walk_lod_binds(out):
if omh is None or not (omh & (1 << 16)):
continue
lod_total += 1
off, size = rows[texnum]
blob = rom[off:off + size]
if not blob:
continue
d = th.parse(blob[:5])
if d["explicit_lods"]:
lod_explicit[texnum] += 1
if ttype == 1:
type1_explicit[texnum] += 1
n_exp = sum(lod_explicit.values())
print("%-28s LOD-on binds=%-5d explicit-LOD texnums=%-3d binds=%-4d"
% (name, lod_total, len(lod_explicit), n_exp))
if lod_explicit:
detail = []
for t, c in lod_explicit.most_common(12):
off, size = rows[t]
d = th.parse(rom[off:off + 5][:5])
tag = " T1" if t in type1_explicit else ""
detail.append("tex%d(%s x%d%s)" % (t, d["format_name"], d["lods"] + 1, tag))
print(" " + " ".join(detail))
grand[level] = (lod_total, n_exp)
for t in lod_explicit:
off, size = rows[t]
d = th.parse(rom[off:off + 5][:5])
if d["gbi_fmt"] == 0 and d["gbi_siz"] == 2:
alpha_hits.append((level, t))
print("\nlevels with explicit-LOD textures bound under G_TL_LOD:")
for lvl, (tot, n) in sorted(grand.items()):
if n:
print(" %-8s %d/%d LOD-on binds" % (lvl, n, tot))
print("\nRGBA5551 (1-bit alpha) explicit-LOD textures bound under G_TL_LOD:")
for lvl, t in sorted(set(alpha_hits)):
print(" %-8s tex%d" % (lvl, t))
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""D294: offline dump + structural analysis of a level's bg portal graph.
Decodes `bg/bg_<level>_all_p.seg` straight out of the ROM (same pipeline as
bg_gdl_census.py) and walks the portal table that `bg.c` uses for its
per-frame room-visibility walk (`bgDetermineVisibleRooms` ->
`sub_GAME_7F0B7F84` / `sub_GAME_7F0B5864`).
File layout (decompressed .seg, big-endian):
word[0] format flag (0 = normal path)
word[1] offset of the bg_room_data array (24 B/record; see bg_gdl_census)
word[2] offset of the g_BgPortals table: 8-byte records
u32 offset_portal (segment-0x0F file offset of the point data,
0 = end of table)
u8 connectedRoom1
u8 connectedRoom2
u8 controlbytes1 (bit0 PORTALFLAG_DISABLED, bit1 _SPECIAL)
u8 controlbytes2
word[3] envdata table offset (not needed here)
Point data at `offset_portal & 0x00FFFFFF` (bg_portal_entry):
u8 numPoints; u8 pad[3]; then numPoints * coord3d (f32 x,y,z).
The analysis section checks the walk's hard limits against the graph:
* portal count vs PORTMAX (200, src/game/bg.h)
* undirected BFS depth from every room vs the walk's `depth >= 16` /
D_8004489C (=0xF) cut-offs (sub_GAME_7F0B7F84)
* per-room portal degree vs the `bgIncrementRoomPortalVisitCount >= 9`
enqueue drop (bgQueuePortalTraversal, depth >= 2)
* worst-case single-frame enqueue volume vs BG_PORTAL_QUEUE_LEN (500 NTSC /
250 EU) -- a rough upper bound: every portal enqueued once per endpoint
room visit.
Usage: python tools_pc/bg_portals.py stat [ntsc-final]
"""
import os
import struct
import sys
import collections
import contextlib
import io
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
PORTMAX = 200
QUEUE_LEN_NTSC = 500
QUEUE_LEN_EU = 250
def load_bg_file(name, region):
sys.argv = ["", region]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
import d43_emit as m
key, row = m.find_row("bg_%s_all_p" % name)
if not key:
raise SystemExit("not in ROM file table: bg_%s_all_p" % name)
addr, size = row
raw = m.rom[addr:addr + size]
import zlib
try:
return zlib.decompress(raw[2:], -15)
except zlib.error:
return raw
def seg_off(v):
return v & 0x00FFFFFF
def main():
level = sys.argv[1] if len(sys.argv) > 1 else "stat"
region = sys.argv[2] if len(sys.argv) > 2 else "ntsc-final"
data = load_bg_file(level, region)
w0, roomlist_off, portal_off = struct.unpack_from(">III", data, 0)
print("word0=%#x rooms@%#x portals@%#x filelen=%d" % (w0, seg_off(roomlist_off), seg_off(portal_off), len(data)))
# --- room array (24 B records; same walk as bg_gdl_census.read_rooms) ---
nrooms = 0
off = seg_off(roomlist_off)
while off + 24 <= len(data):
point, pri, sec = struct.unpack_from(">III", data, off)
if nrooms > 1 and point == 0 and pri == 0 and sec == 0:
break
nrooms += 1
off += 24
print("rooms: %d" % nrooms)
# --- portal table (8 B records, N64 stride; offset_portal==0 terminates) ---
portals = []
poff = seg_off(portal_off)
while poff + 8 <= len(data):
optr, r1, r2, cb1, cb2 = struct.unpack_from(">IBBBB", data, poff)
if optr == 0:
break
portals.append((optr & 0x00FFFFFF, r1, r2, cb1, cb2))
poff += 8
print("portals: %d (PORTMAX=%d%s)" % (len(portals), PORTMAX, " <-- OVER" if len(portals) >= PORTMAX else ""))
# --- point data per portal ---
adj = collections.defaultdict(list) # room -> [portal index]
bad = 0
for i, (poff2, r1, r2, cb1, cb2) in enumerate(portals):
if poff2 + 4 > len(data):
print("portal %d: point data %#x past EOF" % (i, poff2))
bad += 1
continue
npts = data[poff2]
pts = struct.unpack_from(">%df" % (3 * npts), data, poff2 + 4) if npts else ()
for r in (r1, r2):
adj[r].append(i)
if bad:
print("WARNING: %d portals with unreadable point data" % bad)
# flag census
flags = collections.Counter()
for i, (_, _, _, cb1, _) in enumerate(portals):
f = []
if cb1 & 0x01:
f.append("DIS")
if cb1 & 0x02:
f.append("SPE")
flags["+".join(f) or "-"] += 1
print("flags:", dict(flags))
# point-count census (sub_GAME_7F0B5864's points[19] holds numPoints +
# near-plane clip points; >12 authored points is close to the edge)
ptc = collections.Counter()
for i, (poff2, _, _, _, _) in enumerate(portals):
if poff2 + 4 <= len(data):
ptc[data[poff2]] += 1
print("numPoints census:", dict(sorted(ptc.items())))
# per-room degree vs the visit-count limit of 9
hot = [(r, len(ps)) for r, ps in sorted(adj.items()) if len(ps) >= 9]
if hot:
print("rooms with >=9 portals (visit-count drop risk at depth>=2): %s" % hot)
else:
print("max room portal degree: %d (limit 9)" % max((len(p) for p in adj.values()), default=0))
# undirected BFS min-depth from every room
rooms = sorted(adj.keys())
depth_from = {}
for s in rooms:
d = {s: 0}
q = collections.deque([s])
while q:
u = q.popleft()
for pi in adj[u]:
_, r1, r2, _, _ = portals[pi]
v = r1 ^ r2 ^ u
if v not in d:
d[v] = d[u] + 1
q.append(v)
depth_from[s] = d
worst = []
for s in rooms:
d = depth_from[s]
missing = [r for r in rooms if r not in d]
maxd = max(d.values()) if d else -1
if maxd >= 15 or missing:
worst.append((s, maxd, missing))
if worst:
print("rooms unreachable (<16) from some start room (depth>=15 or disconnected):")
for s, maxd, missing in worst:
print(" from room %d: maxdepth=%d missing=%s" % (s, maxd, missing))
else:
allmax = max(max(d.values()) for d in depth_from.values())
print("all rooms pairwise reachable; max BFS depth over all start rooms: %d (walk cut-off 16)" % allmax)
# worst-case enqueue volume: each portal can be enqueued once per frame
# from each endpoint room's visit; the walk visits a room at most once per
# portal path... conservative bound = 2 * nportals.
print("conservative enqueue bound 2*P = %d vs queue %d (NTSC) / %d (EU)" %
(2 * len(portals), QUEUE_LEN_NTSC, QUEUE_LEN_EU))
# dump the full portal table for eyeballing
print()
print("%-4s %-6s %-6s %-4s %-4s %-3s points" % ("idx", "room1", "room2", "cb1", "cb2", "n"))
for i, (poff2, r1, r2, cb1, cb2) in enumerate(portals):
npts = data[poff2] if poff2 + 4 <= len(data) else -1
print("%-4d %-6d %-6d %#-5x %#-5x %-3d" % (i, r1, r2, cb1, cb2, npts))
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""D236 pass 19 (TEMP): count op-12 (GunfireRecord / dogfnegx billboard) and
op-13 (ShadowRecord / doshadow) nodes per model file, and dump op-12 record
fields (Offset/Size/Image) for files that contain them. Remove once D236 concludes."""
import struct, sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.argv = [sys.argv[0], "ntsc-final"] if len(sys.argv) < 2 else sys.argv
import d43_emit as m
def plaus_vma(v):
return v == 0 or (v >> 24) == 5
counts12, counts13 = {}, {}
for name in m.model_names:
key, row = m.find_row(name)
if not key:
continue
addr, size = row
src = __import__("zlib").decompress(m.rom[addr:addr + size][2:], -15)
D = len(src)
hkey = None
for cand in (key, key[1:], key[:-1], key[1:-1]):
if cand in m.nsnt:
hkey = cand
break
if not hkey:
continue
NS, NT = m.nsnt[hkey]
nodes, R0 = m.build_nodes(src, D, NS, NT)
if not nodes:
continue
placed = m.placement_order(nodes, src, R0)
n12 = n13 = 0
for (no, op) in placed:
if op == 12:
n12 += 1
d = m.be32o(src, no + 4)
raw = src[d:d + 0x28]
ox, oy, oz = struct.unpack_from(">fff", raw, 0)
sx, sy, sz = struct.unpack_from(">fff", raw, 12)
img = struct.unpack_from(">I", raw, 24)[0] & 0xFFFFFF
print(f"{name}: op12 node={no:#x} rec={d:#x} offset=({ox:.1f},{oy:.1f},{oz:.1f}) size=({sx:.1f},{sy:.1f},{sz:.1f}) img={img:#08x} vma?{plaus_vma(img)}")
elif op == 13:
n13 += 1
if n12 or n13:
print(f"== {name}: op12={n12} op13={n13}")
counts12[name] = n12
counts13[name] = n13
print(f"\ntotal files with op12: {len(counts12)}, total op12 nodes: {sum(counts12.values())}")
print(f"total files with op13: {len(counts13)}, total op13 nodes: {sum(counts13.values())}")
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""D236 pass 17c (TEMP): dump every op-13 (ShadowRecord) record's raw bytes
from the ROM model files, to settle the field-layout question (does the ROM
put HeaderNode at +0x14 as the C struct says, or +0x18 as d43_emit.py's emit
assumes?). Prints per file: node offset, record offset, and the 16 bytes at
d+0x10..d+0x20 with both interpretations annotated. Remove once D236 concludes."""
import struct, sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.argv = [sys.argv[0], "ntsc-final"] if len(sys.argv) < 2 else sys.argv
import d43_emit as m
def plaus_vma(v):
# file-relative seg-5 vma: 0x05xxxxxx, or null
return v == 0 or (v >> 24) == 5
def plaus_f32(b):
f = struct.unpack_from(">f", b)[0]
return abs(f) < 1e6 and f == f # finite, sane magnitude
n_found = 0
for name in m.model_names:
key, row = m.find_row(name)
if not key:
continue
addr, size = row
src = __import__("zlib").decompress(m.rom[addr:addr + size][2:], -15)
D = len(src)
hkey = None
for cand in (key, key[1:], key[:-1], key[1:-1]):
if cand in m.nsnt:
hkey = cand
break
if not hkey:
continue
NS, NT = m.nsnt[hkey]
nodes, R0 = m.build_nodes(src, D, NS, NT)
if not nodes:
continue
placed = m.placement_order(nodes, src, R0)
for (no, op) in placed:
if op != 13:
continue
n_found += 1
d = m.be32o(src, no + 4)
raw = src[d + 0x10:d + 0x20]
v_10 = struct.unpack_from(">I", raw, 0)[0] & 0xFFFFFF
v_14 = struct.unpack_from(">I", raw, 4)[0] & 0xFFFFFF
v_18 = struct.unpack_from(">I", raw, 8)[0] & 0xFFFFFF
v_1c = struct.unpack_from(">I", raw, 12)[0] & 0xFFFFFF
f_18 = struct.unpack_from(">f", raw, 8)[0]
f_1c = struct.unpack_from(">f", raw, 12)[0]
print(f"{name}: node={no:#x} rec={d:#x}")
print(f" +0x10 image = {v_10:#08x} vma?{plaus_vma(v_10)}")
print(f" +0x14 (A:Header) = {v_14:#08x} vma?{plaus_vma(v_14)}")
print(f" +0x18 (B:Header) = {v_18:#08x} vma?{plaus_vma(v_18)} as f32={f_18!r}")
print(f" +0x1C (A:Scale) = as f32={f_1c!r} raw={v_1c:#08x} vma?{plaus_vma(v_1c)}")
print(f"\ntotal op13 records: {n_found}")