diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 3c8cc82f..51a3cf07 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -29,7 +29,7 @@ D200,FIXED,-level_09 segfault: reverb's ALDelay.input/output are u32 —,FIXED D196,FIXED,"OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).","FIXED (6e758f1d, merged to main) —" D192,OPEN,Front-end mission/level-select grid pointer still can't reach the outer cells —,"OPEN — root-caused, not fixed (M-49)" D221,FIXED,SELECT FILE screen: the mouse hit-box for a file slot is vertically offset from its visible box —,FIXED (M-91) — root cause was NOT a coordinate/origin mismatch: -D222,OPEN,High Video.FovScale breaks culling —,"OPEN — root cause known, not fixed." +D222,FIXED,High Video.FovScale breaks culling —,"FIXED, awaiting visual verification (M-121)." D223,FIXED,QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter —,"FIXED (M-105, port/src/input.c) —" D224,FIXED,"QoL gap (M-87, from a PD-PC-port survey):","DEPRIORITIZED (M-87, user call)." D225,OPEN,"Dev-tooling QoL (M-87, user ask):","OPEN — root-caused, not implemented." @@ -73,7 +73,7 @@ D214,PARTIAL,"Keyboard rebinding — [Input.Bind] config section (M-83, port-onl D215,FIXED,1P weapon viewmodel never drew in-level —,"FIXED (src/game/gunfire.c, #ifdef PORT)." D216,PARTIAL,"Game.SkipIntro — boot straight to the file-select menu (M-83, port + one #ifdef PORT game-code line, PD parity, Phase 4 quick win).",LANDED + VERIFIED this session. D217,FIXED,1P weapon viewmodel + third-person Bond model textures render garbled —,FIXED (M-90/M-92/M-97; PR #47 texpool alignment + PR #49 CI-cache palette key) — -D218,PROPOSED,Wide FOV exposes the per-level draw-distance / fog boundary —,"Logged (M-84), fix proposed + parked." +D218,FIXED,Wide FOV exposes the per-level draw-distance / fog boundary —,"Logged (M-84), fix proposed + parked." D219,RESOLVED,"PPK explosions render purple on Dam (M-87, user QA, ""not 3D but noting"").","RESOLVED (M-115, 2026-09-12) —" D220,PARTIAL,"1P muzzle flash position shifts upward at higher Video.FovScale (M-87, user QA, Jungle/M16).","ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL." D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)" diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 9af44016..325cd452 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -402,7 +402,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D196 | **OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).** `Input.MouseCaptureMode=1`. Open F10 overlay → `optionsOverlayToggle()` (`optionsoverlay.c:391`) calls `inputSuspendForOverlay()` which forces `SDL_ShowCursor(SDL_ENABLE)` + drops the grab. While open, `inputComputePad(0)` early-returns at `input.c:512` (overlay owns controller 0) **before** `reconcileGrab()` / any cursor-visibility call. On close: the next poll runs `reconcileGrab(menuMode)` (`input.c:522`) which re-grabs + hides the cursor **only if `captureArmed && !menuMode`** — i.e. only if you had already clicked-to-lock in a stage before opening F10. If you opened the overlay from a menu, or in a stage you hadn't clicked-locked yet, `want` computes 0, `mouseGrabbed` stays 0, and **nothing calls `applyCursorVisibility()`** — so the cursor `inputSuspendForOverlay()` made visible never gets re-hidden (correct free-but-focused state per `applyCursorVisibility()` `input.c:842-846` is *hidden*). **Fix (port-only, ~1 line):** call `applyCursorVisibility()` right after the `reconcileGrab(menuMode)` at `input.c:522` (self-heals every poll once the overlay-open early-return stops firing), or add an `inputResumeFromOverlay()` that runs `reconcileGrab` + `applyCursorVisibility` and call it from `optionsOverlayToggle()` on close. D180/D184/D165 input lineage. Minor. | **FIXED (`6e758f1d`, merged to `main`) — the first option above was applied:** `applyCursorVisibility()` now runs right after `reconcileGrab(menuMode)` in the controller-0 poll, so once the overlay-open early-return stops firing the cursor visibility self-heals on the next poll. Status cell was left stale at OPEN; corrected M-105. | | D192 | **Front-end mission/level-select grid pointer still can't reach the outer cells — but only under `Input.MouseCaptureMode=0` (legacy always-grab) (M-49, user playtest).** D169 was marked FIXED (M-33) by replacing the hard-coded 320×240 pointer clamp with the live `getPlayer_c_screenwidth/height/left/top()` rect; the feel-check was flagged "owed" on both D169 and D180 and never done. It now reproduces: with `MouseCaptureMode=0` the menu pointer runs the relative-delta P-controller path (`port/src/input.c:659-690`), which clamps target+estimate to `getPlayer_c_screen*` — but that accessor returns `g_CurrentPlayer->c_screenwidth` (`src/game/bondview.c:882`), which in the front end is the ~320×240 stage viewport, **not** the front end's actual 440×330 (`front.c:8570` `viSetViewSize(440,330)`). So the effective clamp is still ~300/220 while the mission-select grid's outer split points sit at 317 / 235.5 (`front.c:516/519/3180/3194`) — outer columns + bottom row unreachable, P-controller winds up and never settles. `MouseCaptureMode=1` (the shipped default, click-to-lock) writes `cursor_h_pos`/`cursor_v_pos` directly from the absolute OS cursor (`input.c:629-657`) and *feels* fine, though it shares the bad `hiH` so the last few px at the right/bottom edge may still be short. **Not a v0.1.0 blocker** — default mode is acceptable, always-grab is opt-in via INI, front-end roughness is already in the release notes. **Fix (port-only, small):** in the `menuMode` branch, when `current_menu` is a front-end menu use the real front-end rect (hard-code `front.c`'s cursor clamp `[20,420]×[20,310]`, or read the front-end viewport global) instead of routing through `g_CurrentPlayer`. Confirm with one `GE_INPUTLOG=1` capture in mission-select — sweep the mouse to each corner, watch where `cursor=(x,y)` plateaus. D165 · D169 · D180 lineage. | OPEN — root-caused, not fixed (M-49) | | D221 | **SELECT FILE screen: the mouse hit-box for a file slot is vertically offset from its visible box — you have to click *below* a file to select it, hovering directly over it doesn't (M-87, user QA, default `MouseCaptureMode=1`).** Different symptom from D192 (D192 is an unreachable-outer-cells *clamp range* bug under legacy mode 0; this is a per-row hit-test/highlight *offset* bug under the shipped default mode 1). | **FIXED (M-91) — root cause was NOT a coordinate/origin mismatch: it's a compiler stack-layout dependency in `interface_menu05_fileselect` (`front.c`).** The hit-test projects each wallet's model extents via `projectRectCornersTo2D`, which takes `{min,max}` `coord2d` *pairs* — but the file-select caller passed `&xmin, &ymin` (four separate f32 locals), relying on the N64 compiler laying out the max adjacent to each min. On PC (declaration-order locals) `arg1->f[1]` reads **ymax** as an X coordinate and `arg2->f[1]` reads an unrelated stack float (observed: `2.25734`, later `nan`) — so the vertical hit band collapsed to a ~14 px strip at the bottom edge of each wallet, sometimes NaN (never selectable that frame). Exactly "click below the file." The other caller (`objective_status.c:525`) passes real `coord2d` structs and was always fine. Fix: pass explicit `{xmin,xmax}`/`{ymin,ymax}` pairs under `#ifdef PORT` (identical values to what the N64 binary used; N64 path untouched). Verified headless: pre-fix A-press at cursor (154.7,127.8) over folder 0 never selected; post-fix same press selects folder 0 and advances. **Human mouse-playtest (this session): confirmed clicking directly over each visible file wallet selects it immediately, no aiming low required; consistent across all slots and the Copy/Erase icons; no crash.** Full details in §H "D221 — M-91". | -| D222 | **High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).** Split out of D211's noted residual: `currentPlayerSetCameraScale()` / the game's frustum-cull and screen↔world math still run at the **nominal, unscaled** fovy while the actual render projection is widened by `portFovScale` (`fr.c:737`) — so objects that are on-screen in the widened view but outside the nominal-FOV frustum get culled before they reach the renderer. Confirmed **not cosmetic** — a real, noticeable functional regression at high FovScale, not just "culls a hair early." | **OPEN — root cause known, not fixed.** Fix direction: find every frustum-cull / visibility-test call site that derives its frustum from the nominal fovy (`currentPlayerSetCameraScale` and friends, `bondview2.c`/`fr.c`) and feed it `frFovY` (the `portFovScale`-adjusted value) instead, gated the same way as the D211 render-path change (`!= LEVELID_TITLE`, `#ifdef PORT`). Must not change cull behavior at `FovScale=100` (no-op parity). Risk: some of these paths may double as gameplay-affecting AI visibility/awareness checks, not just render culling — audit which before changing (portFovScale must never leak into NPC AI *behavior*, only rendering-adjacent visibility). | +| D222 | **High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).** Split out of D211's noted residual: `currentPlayerSetCameraScale()` / the game's frustum-cull and screen↔world math still run at the **nominal, unscaled** fovy while the actual render projection is widened by `portFovScale` (`fr.c:737`) — so objects that are on-screen in the widened view but outside the nominal-FOV frustum get culled before they reach the renderer. Confirmed **not cosmetic** — a real, noticeable functional regression at high FovScale, not just "culls a hair early." | **FIXED, awaiting visual verification (M-121).** Root cause traced one step further than the original write-up: `currentPlayerSetCameraScale()` (`game/bondview.c`) derives both the frustum-cull plane normals (`c_cameratopnorm`/`c_cameraleftnorm`) *and* the fog/LOD distance-visibility-fade scale (`c_scalelod`/`c_lodscalez`, consumed by `chrobjFogVisRangeRelated()` et al. in `propobj.c`) from `c_perspfovy`, which every `viSetFovY`/`viSetAspect`/`viSetFov`/`viSetZRange` call in `fr.c` was setting from the **nominal** `g_ViBackData->fovy` — never the `portFovScale`-widened value D211 already computes for the actual render projection two lines away in `viSetupCurrentPlayerView`. Fix: factored the existing widen-FOV math out of `fr.c` into `f32 portScaleFovY(f32 fovy, s32 isTitleScreen)` (`port/src/video.c`, D222 — identical logic, same `LEVELID_TITLE` guard, no game-code duplication) and fed it into all 4 `currentPlayerSetPerspective()` call sites via a small `#ifdef PORT` local helper in `fr.c` (`frCullFovY`), same as the existing D211 projection-matrix chokepoint now also calls the shared helper instead of its own inline copy. Identity (bit-for-bit no game-code-visible change) at `FovScale=100` by construction — `portScaleFovY` returns `fovy` unchanged whenever `portFovScale == 1.0f`. Audited per the original risk note: `c_cameratopnorm`/`c_cameraleftnorm`/`c_scalelod`/`c_lodscalez` are consumed only in `propobj.c`'s fog-visibility-fade math and `bondview.c`'s own screen-space transform — no AI-visibility/awareness call sites found reading these fields, so `portFovScale` still only touches rendering-adjacent visibility, not NPC behavior. Rebuilt clean, crash-free at `FovScale=150` on `-level_33` (Dam). `bunker1` golden framediff was inconclusive in this dev box — the *unmodified* baseline build also fails it by a similar or larger margin (confirmed via an A/B stash/rebuild), matching the documented pre-existing frame nondeterminism (`porting-notes.md` §E), not a regression from this change. **Needs a human's eyes**: does high-`FovScale` screen-edge NPC/object culling actually look fixed now. | | D223 | **QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter — wheel up = next weapon, wheel down = previous — instead of both directions cycling forward (M-87, user QA).** **Root-caused, port-only fix, no game-logic change needed** — GE's engine already has separate forward/backward weapon-cycle signals: `bondview2.c` computes `moveData.weaponForwardOffset = edge(invButtons) && !(shootButtons held)` and `moveData.weaponBackOffset = (invButtons held) && edge(shootButtons)` (`bondview2.c:5338-5347`, the classic N64 "hold A, tap Z to cycle backward" trick). Default control scheme (the `else` branch used unless a Domino/Goodhead/Galore/Plenty preset is active, `bondview2.c:5177-5179`): `invButtons = A_BUTTON` (= port `GE_CONT_A`, `input.c:86`), `shootButtons = Z_TRIG` (= port `GE_CONT_G`, `input.c:88`, the fire button). `moveData.triggerOn` is explicitly suppressed while `invButtons` is held, so holding A never causes an accidental shot when the Z edge lands. The port's `inputPostWheel()` (`input.c:977`) currently does `if (notches < 0) notches = -notches` — **discards wheel direction** — and only ever synthesizes a bare `GE_CONT_A` pulse (`wheelPulse`, `input.c:631-634`), i.e. always "forward." | **FIXED (M-105, `port/src/input.c`) — directional wheel cycling implemented per the plan below.** Wheel-up keeps the existing 2-poll `A` pulse (`weaponForwardOffset`: fresh A edge, Z released). Wheel-down runs a 2-poll backward sequence: poll 1 presents `A` only, poll 2 presents `A`+`Z_TRIG` — the fresh-Z-edge-while-A-held combo `weaponBackOffset` requires (`bondview2.c:5337-5340`); ordering matters because the edge is computed against the game's own previous poll. `moveData.triggerOn` (`bondview2.c:5419`) requires A to be released, so the synthesized Z edge can never fire a shot. Roles are stable: the port forces `CONTROLLER_CONFIG_HONEY` (`bondview.c:1484`), whose scheme gives `invButtons=A_BUTTON`/`shootButtons=Z_TRIG` — the same roles the pre-fix forward pulse already relied on. Known limitation (pre-existing, not a regression): while RMB aim is held the game flips `controldef` to KISSY (`bondview2.c:6106-6112`), under which *neither* direction cycles — exactly as the old forward-only pulse behaved in that state. Owes a human play-test (wheel up = next weapon, wheel down = previous, no accidental shots). Plan: keep `wheelPulse` (or a new `wheelPulseFwd`) for wheel-up as today; add a `wheelPulseBack` path for wheel-down that instead holds `GE_CONT_A` across ≥2 polls while presenting a fresh `GE_CONT_G` edge partway through (mirroring the invButtons-held + shootButtons-edge combo) — sequence matters (A must already read "held" `oldbuttons`-wise before G's edge, per `weaponBackOffset`'s check order). Must confirm which N64 control preset (`controldef`) the port's save data actually carries — `invButtons`/`shootButtons` differ under KISSY/GOODNIGHT/SOLITARE/etc; if the port doesn't force one, the synthesized combo needs to match whatever's active, or the port should just force the default (HONEY-equivalent) preset since its own `Input.Bind` system already replaces N64-style scheme selection. Port-only, `#ifdef PORT` in `input.c`, no `src/` game-logic touch. **M-87: PD-PC-port precedent checked (user asked what PD did about N64 control presets).** PD does **not** route mouse/gamepad through the N64 preset system at all — it defines its own logical control-key layer (`CK_*` in `port/src/input.c`/`input.h`, e.g. `CK_DPAD_L`/`CK_Y`) fully decoupled from the N64 controller-config enum, and even treats mouse-wheel-up/-down as **two independent bindable virtual keys** (`VK_MOUSE_WHEEL_UP`/`_DN`, defaulted to distinct `CK_*` actions) rather than one "wheel = cycle" event — exactly the shape of the fix this row wants. PD's `controldef` field is hardcoded (`player.c`/`playermgr.c` `controldef = 2`), not left to the original in-game control-style picker. **This GE port already has the equivalent layer for this purpose: D214's `Input.Bind` system.** Recommendation for D223 (and the user's broader ask — N64 presets aren't meaningful for mouse/keyboard, and modern-gamepad (Xbox/PS) support should be a first-class port concept, not routed through N64 presets either): add two new `Input.Bind`-able logical actions (e.g. `IA_WEAPON_NEXT` / `IA_WEAPON_PREV`) that synthesize the invButtons/shootButtons combos under the hood, default-bound to wheel-up/-down (keyboard/mouse) and left open for a future bumper/shoulder-button default under real gamepad support; and have the port force a single fixed `controldef` (matching PD's approach) so the synthesis target never depends on the original N64 control-style menu. That menu itself becomes a candidate for hiding/no-op in a future QoL pass once `Input.Bind` fully supersedes it — not proposed as a change here, just noted as the natural conclusion. **M-87 — D194 gives a concrete answer for *which* preset: `CONTROLLER_CONFIG_SOLITARE` (1.2).** It's the scheme that gives free continuous full-stick mouselook (see D194) — and conveniently, `SOLITARE` is **not** in the `KISSY`/`GOODNIGHT` button-role group (`bondview2.c:5168-5179`), so it keeps the **same** `shootButtons=Z_TRIG`/`invButtons=A_BUTTON` roles this row's plan already assumed — no change needed to the wheel-cycle synthesis. (`GOODNIGHT` (1.4) also gets natural pitch/turn but groups with `KISSY` for button roles — `shootButtons=A_BUTTON`, `invButtons=L_TRIG|R_TRIG` — so it would need this row's combo re-derived; prefer SOLITARE unless something else needs GOODNIGHT specifically.) **M-87 survey note, filed for later (candidate 4, no action needed now):** GE's port has no per-player control-*mode* concept — keyboard/mouse is always controller 0 (`inputOpenPads()`, `connectedMask=0x1`), covering the common "P1 mouse+kb, P2+ pads" local-MP case fine. PD adds a 5th per-player control-mode entry ("PC controls") with a save-compatibility shim (written as an existing N64 mode so the save format never changes, overridden back to PC mode on read by config) to let *any* player be on mouse+keyboard, not just P1. Only relevant if "P1 on pad, P2 on keyboard" (or more than one keyboard player) is ever a real ask — bigger feature than anything currently planned; not worth a Dxx yet. | | D224 | **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** GE genuinely has a rumble subsystem (`src/motor.c`, `src/joy.c`) but the port's `osMotorInit/Start/Stop` (`port/src/libultra.c:1220-1223`) are all hard stubs — "no accessories on the PC," `osMotorStart`/`osMotorStop` return `-1` unconditionally, `osPfsFindFile`-family returns `PFS_ERR_NOPACK`. So every in-game rumble event (weapon recoil, explosions, etc.) computes and calls into a dead path. **PD's PC port wires the equivalent straight through to real gamepad rumble:** `inputRumbleSupported(idx)` checks `SDL_GameControllerHasRumble`/`SDL_JoystickIsHaptic` (with a Windows fallback: some pads report no haptics but rumble anyway), `inputRumble(idx, strength, time)` calls `SDL_GameControllerRumble()` scaled by a per-pad `RumbleScale` config value (options-menu slider, `Input.PadN.RumbleScale`), and `osPfsFindFile` reports a "Rumble Pak" present exactly when a real pad supports rumble (`libultra.c` — same "pretend the N64 accessory is there" pattern GE's port already uses for `osMemSize`/Expansion Pak, just applied to a real capability instead of faked). Cross-ref `QOL-INVENTORY.md` (folded into the existing "per-pad tuning" row). | **DEPRIORITIZED (M-87, user call).** Gamepad-only — irrelevant to mouse/keyboard, which is the primary target for this next release; no keyboard/mouse play is affected by NOT doing this. Root cause + fix still valid whenever gamepad support becomes a priority (no `src/` game-logic touch: GE's `motor.c`/`joy.c` call sites already exist and already call the `osMotor*` shims; the fix is entirely in `port/src/libultra.c` + `input.c` — add `inputRumbleSupported`/`inputRumble` per the PD shape, wire `osMotorStart`/`osMotorStop` to them, add `Input.PadN.RumbleScale` config). **M-87 PD-legacy-survey addendum (`docs/dev/notes/PD-LEGACY-SURVEY.md` candidate 1, high confidence) — the D224 fix as originally stated is NOT sufficient by itself.** Two more upstream blockers sit before the stubbed `osMotorInit/Start/Stop` ever gets reached: (1) `port/src/libultra.c:1096-1098` sets `g_contStatus[i].status = 0` for every pad — `CONT_CARD_ON` is never set, and `src/joy.c:184` gates the *entire* rumble-pak init path on that bit, so `joyRumblePakInit()` never even calls `osPfsInit`. (2) `src/joy.c:186-190` only attempts `osMotorInit` if `osPfsInit`'s return is `PFS_ERR_ID_FATAL` or `PFS_ERR_DEVICE` — but the port's stub (`libultra.c:1214`) returns `PFS_ERR_NOPACK`, so motor init is never attempted even once (1) is fixed. PD's exact trick (`libultra.c:213-227,349-351`): set `CONT_CARD_ON` for connected pads, and make `osPfsInitPak()` return `PFS_ERR_DEVICE` specifically when the pad supports rumble. **Whenever D224 gets implemented, both of these must land alongside the `SDL_GameControllerRumble` wiring** or the game's state machine parks at `RUMBLEPAKINITSTATE_NOT_READY` forever and nothing audibly changes despite looking wired up correctly. | | D225 | **Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.** Root-caused a clean single choke point — **both** gates route through the same function: `bool fileGetIsCheatUnlocked(save_data *save, s32 cheat)` (`src/game/file2.c:391`) reads the save's `unlocked_cheats_1/2/3` bitfields and returns whether bit `cheat` is set. Mission-select lock (`src/game/file.c:59`, gates whether a briefing page's mission is selectable) and the cheat-menu availability check (`frontCheckIfCheatIsUnlocked`, `front.c:1009`, itself calling `fileGetIsCheatUnlocked` for the per-level `SP_LEVEL_*` cheat IDs) **both** call it — it's a pure, side-effect-free bitfield read (no save-data mutation), so short-circuiting it to always-true under a debug env var unlocks mission-select *and* the cheat menu in one change, matching exactly what was asked ("those two go a long way"). **Bonus, free once cheats unlock:** the ROM already contains real Rare-era developer cheats that are otherwise unreachable in normal play — `CHEAT_LINEMODE` (wireframe render — would make D222-style culling bugs trivial to see), `CHEAT_BONDPHASE`, `CHEAT_DEBUG_POS`, `CHEAT_DEBUG_UNK5` (`cheat.c` ~L687-707, `CHEAT_MASK_GLOBAL`) — worth trying once this lands, may be useful dev tools in their own right. | **OPEN — root-caused, not implemented.** Plan: `#ifdef PORT`, env-gated (e.g. `GE_DEBUG_UNLOCKALL=1`, catalogue in `GE-ENV-PROBES.md` once added) short-circuit at the top of `fileGetIsCheatUnlocked` — `if (getenv-cached-flag) return TRUE;` before the bitfield read. Default off ⇒ zero behavior change for normal play; when set, purely a query-time override (save file itself is never touched, so it can't corrupt a save or leak into the shipped default). No `src/` logic change beyond the guarded early-return (same class of edit as other `#ifdef PORT` dev-convenience hooks, e.g. `GE_STARTMENU`). | @@ -447,7 +447,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D215 | **1P weapon viewmodel never drew in-level — `gunRenderFirstPersonGunModels` builds its `ModelRenderData` from a reinterpret across separately-declared adjacent globals that only works on N64 (M-83, D3x/ABI class; retires VIEWMODEL-RESEARCH.md).** The N64 line `renderdata = *(ModelRenderData *)&D_80035CC0;` (`gunfire.c`) casts the lone `u32 D_80035CC0 = 0;` to a whole `ModelRenderData` and reads on into the next declared global, `u32 D_80035CC4[] = {1, 3, 0, 0, …}` (`gun.c`) — yielding `{ basemtx=0, zbufferenabled=1, flags=3, rest=0 }` **only because the N64 linker lays the two globals out contiguously in declaration order and pointers are 4 B**. On x86-64 it breaks twice: `ModelRenderData`'s three pointer members (`basemtx`/`gdl`/`mtxlist`) widen 4→8 B so `flags` is no longer at struct offset 8, and C guarantees nothing about the two globals' relative placement. Measured (`GE_DVM=1`, BUNKER1): `renderdata.flags == 0`, so **every** weapon-model DL node was gated off at `modelRenderNodeGundl` (`(renderdata->flags & 1)`), and `subdraw()` for the weapon emitted just its `gSPSegment` — 1 gfx command instead of ~150. All three VIEWMODEL-RESEARCH.md ranked suspects were ruled out by the same probe first: `field_87F==1` (gate open), `RootNode` valid, `numRecords 22 < 192` (pool fine), node walk visits 24+ valid `DL`/`SWITCH`/`BSP` nodes. **Fix:** `#ifdef PORT` → set the three fields explicitly (`zbufferenabled=TRUE; flags=3;`), N64 line verbatim under `#else`. ABI/layout only, no logic change. | **FIXED (`src/game/gunfire.c`, `#ifdef PORT`).** Build clean. `GE_DVM=1` post-fix: `flags=0x3`, weapon `subdraw()` now emits **153** gfx commands (was 1). Regression sweep crash-free. `GE_DVM` gate+render probes left in, env-gated (cf. `GE_D193*`). **Owed:** one BUNKER1 eyeball that the gun model is actually visible + correctly posed (headless can't see pixels; the DL is now provably generated). The committed `bunker1` golden will need re-baselining — the gun now renders in-frame. | | D216 | **`Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).** New `s32 portSkipIntro` (`port/src/video.c`, `configRegisterInt("Game.SkipIntro", 0, 1)`), read once in `src/game/lv.c` at `LEVELID_TITLE` stage load — in the `else` of the existing `GE_STARTMENU` `#ifdef PORT` block. When set it does what every intro stage's own `!is_first_time_on_main_menu` branch does (`front.c`): `is_first_time_on_main_menu = FALSE; prev_keypresses = TRUE; maybe_is_in_menu = TRUE; menu_update = MENU_FILE_SELECT;` — i.e. it reuses the game's own post-intro route rather than inventing one, so the legal screen + Nintendo/Rare/GoldenEye logo attract loop is skipped. `GE_STARTMENU` still wins when both are set. Default 0 ⇒ untouched. | **LANDED + VERIFIED this session.** Build clean. Headless: default boot frame 90 = the TWYCROSS classification screen (10% non-black), `SkipIntro=1` frame 90 = the **SELECT FILE menu** (folders/photos/cursor all render, 89% non-black), no crash across repeated boots. Screenshots captured + eyeballed. **Owed:** nothing critical — a human boot to confirm the menu is fully interactive (cursor moves, a save slot opens). | | D217 | **1P weapon viewmodel + third-person Bond model textures render garbled — wrong/stale palette on model-DL CI textures (M-84, user QA; surfaced by D215 now that the viewmodel draws).** BUNKER1/Facility: the PP7 viewmodel has yellow+blue scrambled texels and a solid **green** patch by the hammer; Bond's in-game **watch face** shows the same green mismatch; user reports the **third-person Bond world model** does this "sometimes" too. Signature matches the **D161 family** — a CI-format tile drawn without the expected TLUT state, so fast3d's `import_texture` indexes a **stale `rdp.palette`**. D161 fixed the explicit `G_TT_NONE` case; this looks like model DLs not emitting `gDPLoadTLUT` through the port path, or the palette segment not resolving for weapon/character geometry. **Not a D215 regression** — D215 only un-gated the DL. | **FIXED (M-90/M-92/M-97; PR #47 texpool alignment + PR #49 CI-cache palette key) — the green-texture defect is fixed and merged, confirmed by two independent human playtests. The white missing-texture residual (AK47 / NPC-held world-model weapons) is split out as D228.** Status cell had gone stale at OPEN; corrected M-105. History: root cause characterised M-86; headless repro: `-level_34` FACILITY ~frame 300–400, PP7 **grip = flat green block** + green ring at the barrel joint + green-cast hand; barrel/slide upper correct black. Also green on `-level_30` DEPOT. **Clean on `-level_09` BUNKER1** (A/B control — same PP7, identical DL). **Two separate defects found:** ① **Wrong palette bound (the visible green — upstream of fast3d).** The grip is a CI4 solid-fill of palette index 0. Green `(24,65,0)` == RGBA5551 `0x1A00` == `rdp.palette[0]` at grip-import time. The grip's `count=1` model `G_LOADTLUT` is fed **different source bytes per level** for the same tile: BUNKER `00 01`→`PD_BE16`→`0x0001` (black ✓), FACILITY `1a 00`→`0x1A00` (green ✗). fast3d byteswaps + stores faithfully (proven correct by BUNKER) — the wrong bytes *arrive* from the model-DL / segment-address path. Strong tell: every *larger* model TLUT has `raw[0]==raw[1]==0x0000` with real colour from index 2 ⇒ GE's model-TLUT payload likely carries a **4-byte (2-entry) leading zero pad**, OR the port's model-TLUT source pointer is **4 bytes / 2 entries too low** (then the `count=1` grip load reads the tail of the preceding CI blob — BUNKER lucky, FACILITY not; fits "widespread across levels + weapons"). Ruled out this session: not tail-overflow (index 0 is in-range; matches M-85 `GE_D217Z`), not the fast3d byteswap. **Next:** dump the grip TLUT `G_SETTIMG` target vs the ROM/`pcmodels` sidecar PP7-grip material bytes; confirm the 4-byte offset vs N64; fix in model-DL segment resolution or `tools_pc/d43_emit.py` blob placement (sidecar regen). ② **CI texture-cache collision (proven, real, separate; fix landed).** `gfx_texture_cache_lookup` (`gfx_pc.cpp:522`) keyed CI textures on `{addr, palette_addrs[0/1], palidx, size_bytes}` with **no palette-content check**; GE reissues `gDPLoadTLUT` from repeated scratch-arena addresses with different content, so a later material could take a stale HIT and sample another material's decoded palette. **Fix on branch `fix/d217-model-texture-palette` (commit `f6fc0399`):** FNV-1a hash of the 512-byte `rdp.palette` computed only in `gfx_dp_load_tlut()`, added to the CI cache key (`port/fast3d/gfx_pc.{cpp,h}`, fast3d-internal, no `#ifdef`). Cheap, cannot worsen a decode; **owes a full 21-level `verify.sh sweep`** before merge (touches every palettized world texture's import path — cache-churn risk only). Does **not** fix defect ①. Screenshots: session scratchpad `d217/`, `fac_gun_crop.png`. Cross-ref `docs/dev/GRAPHICS-BACKLOG.md`. **User playtest matrix (M-87, raw notes — not yet triaged):** DAM/PPK: watch still green; AK47 viewmodel has white (missing-texture) patches on the magazine + barrel. FACILITY: PPK garbled **green AND blue**; 3rd-person Bond world model shows greenness on level load-in; AK47 has "weird blue / glitched" textures. JUNGLE: M16 viewmodel is orange in spots. Confirms multi-weapon (PP7/AK47/M16), multi-color (green/blue/white/orange) — consistent with the upstream model-TLUT-source theory (different trailing garbage per weapon/material) rather than one single fixed offset. **Scope clarified (M-87):** not just Bond's 1P viewmodel / 3P world model — **NPC-held weapon models (other characters' guns in third person) show the same glitched-color/white-missing texture symptom.** So the defect is in the shared weapon-model-DL/TLUT path used by *any* character holding that weapon, not something specific to Bond's model or the 1P viewmodel rig — widens the likely fix surface (weapon model loading in general) beyond "Bond's gun." **M-87 clarification:** not uniform across levels — severity and glitch *type* both vary per level (some levels show it lightly, some not at all in a given session, and the specific wrong-color/missing pattern differs), consistent with the per-material trailing-garbage theory rather than one fixed global offset. **M-88 update: defect ① is NOT a `tools_pc/d43_*.py` sidecar bug — the theory in this row is refuted; the model converter never touches texture/palette bytes at all.** Root cause narrowed to the runtime `texWriteLoadToTmemAddr`/`gfx_dp_load_tlut` TLUT-address path (`src/game/tex.c` + `port/fast3d/gfx_pc.cpp`, both out of this session's file scope). See full write-up: §H "D217 — M-88 defect ① root-cause narrowing". **M-89 update: the `gfx_dp_load_tlut` pitch/width=1 hypothesis is REFUTED** (hand-derived: it already computes `pitch=1` for every model-DL TLUT call via the GBI `width-1` packing — a proposed `pitch=1` patch would be a no-op, do not apply it) — **the addressing math is correct by inspection**. New leads, neither yet probed: (a) does `g_Textures[texturenum]`/`_imagesSegmentRomStart` actually resolve to identical ROM bytes for this weapon's grip texture on BUNKER1 vs FACILITY (an image-table/asset-resolution path distinct from the already-ruled-out per-model `TextureID`/`d43_emit.py`/`Globalimagetable`/D68 path), or (b) does the shared `texpool` arena (`image.c` `texLoad`, two-ended `leftpos`/`rightpos` allocator) serve a stale/aliased `tex->data` for what should be a fresh per-level decode. See full write-up: §H "D217 — M-89: the `gfx_dp_load_tlut` width=1/pitch hypothesis is REFUTED". **M-90: defect ① ROOT CAUSE FOUND + FIXED** — neither lead (a)/(b) was it: the texpool arena base for the weapon pools lands **%8==4 on PC** (memp bump drift from pointer-bloat allocation sizes), and `texAlignIndices` pads CI rows to *absolute* 8-byte boundaries, so every palette in a misaligned pool sits **4 bytes early** while the model-DL `G_LOADTLUT` still reads the N64 offset — the grip's count=1 load lands on the next tenant's header slot (`texnum=26` → LE s16 → `0x1A00` = the green), and larger loads read two zero-pad entries then the real palette (this is M-86's unexplained "leading zero pad" tell). **Fix:** `texInitPool` rounds the arena base up to 8 under `#ifdef PORT` (no-op on N64; ≤7-byte pool shrink) — ABI/layout-class edit, no logic touched. Verified: FACILITY 188/188 decodes aligned + draw reads `pal0=0001` (was `1a00`) + 0 green pixels in frames 250–450 (was ~1300); BUNKER1 + DEPOT still clean. **Pending:** full 21-level sweep result + human playtest eyeball of the M-87 weapon matrix. See §H "D217 — M-90". **M-92: sweep 21/21 PASS + human eyeball done — PP7/PPK/watch CONFIRMED FIXED by this alignment fix. AK47 (viewmodel + world model) and NPC-held world-model weapons (white/missing-texture patches, e.g. magazine/receiver) are a DISTINCT, still-OPEN residual — not fixed by this PR, not yet root-caused. PR #47 should still merge (real, confirmed partial fix); reopen defect ① scoped to the AK47/NPC-world-model white-texture failure mode. See §H "D217 — M-92". **M-97: independently re-confirmed by a second human playtest** (this branch merged up to date with `main`, fresh build, `-level_34` FACILITY): PP7/PPK grip+hammer clean, watch face clean, no green anywhere; **NPC-held AK47 still shows white missing-texture patches.** Two independent playtests, same split — the green defect is fixed, the white defect is not. The white residual is now split out under its own label, **D228** (rather than leaving defect ① ambiguously half-open), so this row can close on the green mechanism it actually describes. | -| D218 | **Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** BUNKER→**Dam main tunnel** at `FovScale` ~150: geometry / backdrop past the level's fog-end distance shows at the widened frustum edges (blue = Dam's fog tint). Not a render bug — `fogLoadCurrentEnvironment` (`bgfog.c:305`) does `viSetZRange(Visibility.BlendMultiplier, Visibility.FarFog)`, so **`Visibility.FarFog` is both the far clip plane and the fog-saturation distance**; levels tune it for the stock ~60° FOV. | **Logged (M-84), fix proposed + parked.** Plan: `Video.DrawDistance` (percent, 100–400, default 100 ⇒ no-op) `#ifdef PORT` multiplier on `FarFog` at `bgfog.c:305` (+ the direct `arg0->Visibility.FarFog` reads just below, so fog stays consistent) plus `Video.DrawDistanceAutoFov` (0/1, default 1 — couples draw distance to `FovScale` unless `DrawDistance` is set explicitly). Clamp effective multiplier ≲2.5× (small znear ⇒ far-field z-fighting risk). **M-87: user confirms still occurring** at high FOV, and — reversing the earlier "not implemented by user request" — **now wants `Video.DrawDistance` implemented as a real QoL feature**, plus scope it to cover **LOD/detail-distance in general**, not just the fog/far-clip boundary this row root-caused. Open question for that broader ask: does GE have a *separate* geometry/object LOD-swap or draw-culling distance mechanism (distinct from `Visibility.FarFog`) worth exposing too, or is fog-distance the only meaningful "how far can you see" knob in this engine? Check for a per-object visibility-radius / simple-model-swap system before assuming `FarFog` is the whole story. | +| D218 | **Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** BUNKER→**Dam main tunnel** at `FovScale` ~150: geometry / backdrop past the level's fog-end distance shows at the widened frustum edges (blue = Dam's fog tint). Not a render bug — `fogLoadCurrentEnvironment` (`bgfog.c:305`) does `viSetZRange(Visibility.BlendMultiplier, Visibility.FarFog)`, so **`Visibility.FarFog` is both the far clip plane and the fog-saturation distance**; levels tune it for the stock ~60° FOV. | **Logged (M-84), fix proposed + parked.** Plan: `Video.DrawDistance` (percent, 100–400, default 100 ⇒ no-op) `#ifdef PORT` multiplier on `FarFog` at `bgfog.c:305` (+ the direct `arg0->Visibility.FarFog` reads just below, so fog stays consistent) plus `Video.DrawDistanceAutoFov` (0/1, default 1 — couples draw distance to `FovScale` unless `DrawDistance` is set explicitly). Clamp effective multiplier ≲2.5× (small znear ⇒ far-field z-fighting risk). **M-87: user confirms still occurring** at high FOV, and — reversing the earlier "not implemented by user request" — **now wants `Video.DrawDistance` implemented as a real QoL feature**, plus scope it to cover **LOD/detail-distance in general**, not just the fog/far-clip boundary this row root-caused. Open question for that broader ask: does GE have a *separate* geometry/object LOD-swap or draw-culling distance mechanism (distinct from `Visibility.FarFog`) worth exposing too, or is fog-distance the only meaningful "how far can you see" knob in this engine? Check for a per-object visibility-radius / simple-model-swap system before assuming `FarFog` is the whole story. **FIXED as designed, awaiting visual verification (M-121).** Implemented exactly the parked plan: `f32 portDrawDistanceMultiplier(void)` (`port/src/video.c`) — an explicit `Video.DrawDistance != 100` wins outright, otherwise `Video.DrawDistanceAutoFov` (default on) couples the multiplier 1:1 to `Video.FovScale`, clamped to `[1.0, 2.5]` (never shrinks draw distance; the 2.5x ceiling is the finding's own far-field z-fighting caution). Applied in `fogLoadCurrentEnvironment` (`bgfog.c`) to a new local (`scaledFarFog`) used at *both* the `viSetZRange` far-clip call and the `g_CurFogDetails.scaled_far_fog_dist` fog-blend read just below it, so the clip plane and the fog math stay consistent per the original plan's own note — the level's `EnvironmentRecord` itself is never mutated (it's shared/reloaded, not a per-frame copy). Both new config keys added to the F10 overlay (`Video.DrawDistance` slider, `Video.DrawDistanceAutoFov` toggle) alongside the existing `FovScale` row. Identity at both defaults (`DrawDistance=100`, `FovScale=100` ⇒ `portFovScale==1.0f`) by construction. **Broader LOD-swap question from M-87 ANSWERED — yes, and now exposed too (same session, M-121 continued).** `src/game/model.c` has a real, distinct geometry-LOD system: `MODELNODE_OPCODE_LOD` nodes whose children are shown/hidden per-frame by `modelUpdateDistanceRelations()` testing a view-space `distance` (scaled by `getPlayer_c_lodscalez()`, itself now FOV-consistent per this session's D222 fix) against each node's `MinDistance`/`MaxDistance` — a genuine detail-swap mechanism, separate from the fog-visibility-fade this row already fixed. It even has an existing ground-truth multiplier hook, `g_ModelDistanceScale`, already used transiently by the game itself (`chr.c`'s 0.3125f close-up override, `front.c`/`title.c`'s menu-model `modelSetDistanceDisabled`). Rather than write to that shared global (would fight those existing overrides), added a second, composed multiplier at the same call site: `f32 portLodDistanceMultiplier(void)` (`port/src/video.c`), driven by new `Video.LodDistance` (percent, 25-400, default 100) and `Video.LodDistanceAutoFov` (**default OFF**, unlike `DrawDistanceAutoFov` — this is meant as a standalone performance lever the user can pull *down*, not something that silently adds cost as FovScale widens) — `distance *= portLodDistanceMultiplier()`, inverse-scaled so `LodDistance=200` keeps full detail twice as far (more cost) and `LodDistance=50` drops to the lower LOD twice as soon (less cost). Both new keys in the F10 overlay alongside `DrawDistance`. Identity at `LodDistance=100`. Rebuilt clean, crash-free at `LodDistance=40` (aggressive) on Dam and at defaults on bunker1. **M-121 live playtest, iterated to a working state.** Two real issues found and fixed while testing live on Dam with the user: (1) **regression** — the D222 fix (above) correctly widened `c_lodscalez` for the screen-edge cull planes, but that same value is *also* read directly by `propobj.c`'s `chrobjFogVisRangeRelated()`/`sub_GAME_7F054C58()` (the character/prop fog-visibility-fade cutoff) — uncompensated, this made guards fade/disappear *sooner* at wide FOV than before D222, the opposite of the intent. Fixed by dividing that same `c_lodscalez` reading by `portDrawDistanceMultiplier()` at both call sites (`#ifdef PORT`), so the "how far can you see" question these two share is answered consistently. (2) **tuning** — a straight 1:1 `Video.FovScale` auto-coupling on `portDrawDistanceMultiplier()` was not aggressive enough; live-tested up twice (1x → 2x → 4x `FovScale`, clamp ceiling raised 2.5x → 4.0x → 6.0x each time) before guards held up and the Dam tunnel's blue-glow artifact receded to the user's satisfaction. Re-verified end-to-end that the fog *ramp* itself (not just the far-clip cutoff) scales with the multiplier — `g_ScaledFarFogIntensity`/`scaled_far_fog_dist` both derive from the same scaled far value — so if a similar artifact resurfaces on another level, the multiplier magnitude or a level-specific tunnel-length edge case are the first things to check, not this math. **User confirms this is good for now.** All three (D222 cull fix, D218 draw-distance + fog-visibility-fade fix, D249 LOD-distance) verified crash-free and landing in the same PR. | | D219 | **PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** Effect-color defect, not a texture-decode one — likely the D172 particle-color family (`gfx_lod_tile_offset` / `G_CC_INTERFERENCE` channel-order class) recurring on a different particle type/weapon than the D172 fix covered, or a fresh instance of the same class. | **RESOLVED (M-115, 2026-09-12)** — byte-order fix in `src/game/image.c` scoped by pixel format (16-bit family bswap16-at-store, 32-bit identity); user playtest-confirmed. Superseded history below, which began: **OPEN — observed, not investigated.** Next: `GE_PCDUMP`/`GE_TEXDUMP` a Dam PPK explosion; check whether it's the same `gfx_lod_tile_offset` tile-sampling defect D172 fixed for blood, on an explosion DL D172's fix didn't touch. **Status: re-checked at runtime M-90 — mechanism covered by the D172 fix on current `main`; close pending a human by-eye pass (folds into PR #47 playtest).** **M-89 triage (local-Qwen, static analysis only, not yet runtime-confirmed):** the explosion DLs (`assets/oddtextures.c` `globalDL_0x078/0x120/0x1c8/0x270…`) use the same `G_CYC_2CYCLE`+`G_CC_INTERFERENCE,G_CC_MODULATEIA2`+`G_TL_TILE`, tile0=IA smoke/tile1=RGBA fire mechanism as the already-fixed D172 blood/spark records — no separate/unfixed code path found in `explosion.c` or the asset DLs. If still purple after D172's fix (which the user's Dam report postdates — needs re-confirming on current `main`), the next suspect is the GLSL combiner implementation of `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` for cycle 2 specifically (`gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`), not the tile-offset fix. **Next, concrete:** run Dam (`-level_33`) with `GE_D172=1` while triggering a PPK/grenade explosion and check for "BUG" lines in the log (silence = D172's fix already covers this DL and the row can likely close on a re-test; needs a scripted-fire repro, not yet built). **M-90 runtime re-check DONE (scripted-fire repro built with `GE_INPUTSCRIPT`): the D172 fix demonstrably covers Dam's particle DLs — row can close on a human by-eye pass.** Ran `-level_33` headless with `GE_D172=1`, 64 scripted Z-pulses (`GE_INPUTSCRIPT="200:Z;215:Z;..."`, ~15 controller-reads apart) and a `GE_PCDUMP` window over the same span. Result: **40/40 particle multitex tris log `SAMPLED1=tile1 (tmem=392)`** — the correct RGBA fire tile, i.e. exactly the post-D172-fix behaviour (pre-fix these read `SAMPLED1 = tile0 (tmem=0 fmt=IA)`, the smoke texture); **zero** "BUG"/unknown-GBI/D146-abort lines; **0** purple/magenta pixels (`r>120, b>120, g<90, r-g>50, b-g>50`) across all 28 captured frames. Caveat: the user's M-87 "purple" report may predate the D172 fix on their binary (playtests run on whatever build was current); this re-check proves the sampling path is correct on current `main`, which is as far as a headless pass can take it — a final by-eye look at a live Dam PPK firefight is the honest closer (cheap for the user, folds into the D217 PR #47 playtest). **M-108 — REOPENED, the by-eye closer FAILED.** User played a live Dam (`-level_33`) PPK firefight on the current `main` build (post-D233/D234 fixes, same session as D243) and confirmed explosions are **still blue/purple**, directly contradicting M-90's clean scripted-repro result (40/40 correct-tile samples, 0 purple pixels, 28 frames). This means the M-90 `GE_INPUTSCRIPT` scripted-fire repro was very likely **not exercising the same explosion instance/DL the live firefight hits** — plausible gaps: a different explosion variant (grenade vs. bullet-impact spark vs. enemy-death vs. PPK-specific muzzle/hit effect) using a DL the scripted test's Z-pulses didn't trigger, or a state/lighting/prim-color condition (M-89's alternate suspect: the `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` cycle-2 GLSL combiner implementation, `gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`) that only manifests under conditions the synthetic repro didn't reproduce. **Do not trust the M-90 all-clear for this finding going forward** — it proved one particle path clean, not that Dam's live-fire purple is fixed. Next: a live `GE_PCDUMP`/`GE_D172=1` capture during an actual purple explosion in real play (not a scripted approximation) is now the only way forward — need to pin down exactly what triggers it (PPK gunshots hitting a surface/enemy? a thrown/tossed explosive? which). **User has now confirmed (2026-09-12) this is NOT Dam-exclusive — game-wide, not just suspected.** Treat as confirmed scope, not pending a second-level check. **User wants this actively fixed, not just triaged** — next session should prioritize a live `GE_PCDUMP`/`GE_D172=1` capture during a real explosion (any level) over further static analysis, since the M-89/M-90 static passes are exhausted on this finding without producing a fix. **New HIGH-interest lead (M-110): `explosion.c` never clears `G_LIGHTING` before drawing any of the 15 particle DLs** (`globalDL_0x078` through `0x9a8` in `assets/oddtextures.c`, all structurally identical, all `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2`) — grepped every particle-draw call site in `explosion.c` (`gSPClearGeometryMode` calls at lines 883/1495/1718/2227 only ever clear `G_CULL_BOTH`/`G_FOG`/`G_CULL_FRONT`, never `G_LIGHTING`) and confirmed `G_CC_INTERFERENCE,G_CC_MODULATEIA2` is used nowhere else in the codebase, so it's safe to key a probe on it. The port's vertex path (`port/fast3d/gfx_pc.cpp:1301-1346`) branches hard on `rsp.geometry_mode & G_LIGHTING`: if lighting is on when these particles draw, the authored RGBA tint in `v->v.cn[]` gets reinterpreted as a normal vector, and the resulting SHADE color (fed into `G_CC_MODULATEIA2`'s `COMBINED*SHADE`) becomes a scene-light color instead of the DL's actual red/orange fire tint — a mechanism that would plausibly read as "wrong color, state-dependent, game-wide" exactly as reported, and would explain why a synthetic scripted-fire repro (M-90, no nearby lit models) came back clean while live firefights (guards/lit models on screen) don't. **Not yet confirmed** — this depends on whatever last touched `geometry_mode` before the particle draw in a given frame, which the game's own N64-accurate command order controls; it is not yet established whether this is a genuine port bug (fast3d misordering/dropping a `G_GEOMETRYMODE` command relative to real hardware) or something that would reproduce identically on N64 (in which case it isn't actually the D219 mechanism, since N64 doesn't show purple explosions). **Instrumented, not fixed:** added a `#ifdef PORT`/`GE_D172=1`-gated log line to the existing D172 multitex-tri probe (`port/fast3d/gfx_pc.cpp` ~line 1740, `gfx_sp_tri1`) that now also prints `geometry_mode` and flags `LIGHTING=ON <-- D219 SUSPECT` whenever one of these particle triangles draws with lighting on. Sanity-checked against the known-clean M-90 repro (headless `-level_33`, 40 scripted Z-pulses): probe fires exactly 40/40 as before, all show `LIGHTING=off` — consistent with the already-known-clean case, confirming the probe works and doesn't false-positive. **Next, concrete:** run `build-pc/d172_silo.bat` (or equivalent, `GE_D172=1`) and trigger a real explosion (any level, any explosive source) near visible character models; grep the log for `D219 SUSPECT`. A hit confirms the mechanism and turns this into a real fix (clear `G_LIGHTING` in the port's particle-draw path or in fast3d's geometry-mode tracking, whichever turns out to be the actual divergence from N64); a miss across a real purple-explosion capture rules this whole lead out. **M-112 (this session): decode-chain code review + two new inert diagnostic probes; runtime capture NOT obtained (environment limitation, see below). Confidence: LOW-MEDIUM on a narrowed next step, no fix applied.** Read the full N64 texture decompression ground truth for these assets end to end: `texLoadFromDisplayList`→`texLoad`→`texInflateNonZlib`/`texInflateZlib`→(`texReadUncompressed` | `texInflateHuffman`/`texInflateRle`+`texChannelsToPixels` | `texBuildLookup`+`texInflateLookup`/`texInflateLookupFromBuffer`) (`src/game/image.c`), plus `gimgFixupGlobalimagetable`/`gimgSyncCompiledGlobalDLs` (`port/src/gimgfixup.c`) and `import_texture_rgba16` (`port/fast3d/gfx_pc.cpp:653-681`). Findings: (1) `IMAGE_FIRE_0..14` are **not** `sImageTableEntry`-table images (unlike `IMAGE_IMPACTMULTI`/`IMAGE_IMPACTREDBRICK*`/flare/ammo images, which live in the 32 `sImageTableEntry` arrays at segment offset 0xAC8+ and get their `G_SETTIMG` marker synthesized at runtime by `texSelect`, `src/game/othermodemicrocode.c:318`) — FIRE_N's `gsDPSetTextureImage(...IMAGESEG(IMAGE_FIRE_N))` is baked directly into the compiled `globalDL_0x078..0x9a8` display lists (segment offset 0x000-0xA50) alongside the interleaved, correctly-rendering `IMAGE_SMOKE_0..14` (IA16). Both FIRE's and SMOKE's markers go through the *same* `gimgFixupGlobalimagetable` part-1 DL-bswap step and the *same* `texLoadFromDisplayList`→`texLoad` resolution — no separate/divergent table-offset math applies to FIRE_N specifically (this narrows away part of the open brief's option (a): there is no `sImageTableEntry`/`s_tbl_offs[]` bounds bug possible here, because FIRE_N never goes through that array class at all). (2) `texChannelsToPixels`'s `TEXFORMAT_RGBA16` case and `texReadUncompressed`'s `TEXFORMAT_RGBA16` case are decomp ground truth (byte-exact, `#ifdef PORT` free) and are format-generic, not texture-specific — a bug there would require a same-format sibling to also be wrong, and IMPACT/FLARE RGBA16 sprites (same `G_IM_FMT_RGBA,G_IM_SIZ_16b`, same underlying `texLoad`) are the only same-format comparison point on hand, **but this session did not runtime-confirm whether those actually render correct colors in game** — flagging as an unverified assumption inherited from earlier sessions' "shared decoder is proven correct" framing, not re-proven here. (3) `texSwapAltRowBytes` is already an unconditional no-op on `PORT` (D159, `src/game/image.c:2219`, `return;` before any format switch) so it cannot be format- or texture-selective on the port — ruled out as a contributor for FIRE specifically (it's equally a no-op for every other texture). **New, added, NOT yet exercised:** two matching `GE_D219RAW`-gated diagnostic probes (inert unless the env var is set, `#ifdef PORT`, no behavior change): (a) in `texInflateNonZlib` (`src/game/image.c`, right after the per-image header read, and again right after decode) — logs `texnum`, `format`, `width`, `height`, `compmethod` from the bitstream header, then the first row of decoded RGBA5551 pixel words, for every image shaped 16x14 (the FIRE tile shape); (b) in `import_texture_rgba16` (`port/fast3d/gfx_pc.cpp`) — logs the same pixel words as read back at upload time, so a session can diff "as decoded" vs "as consumed" to rule pool corruption/mis-addressing in or out. **Environment blocker hit this session:** could not get the built `ge007.x86_64.exe` to produce *any* stderr/log output when launched from this delegated agent's shell, across four different invocation strategies (direct foreground exec, backgrounded `&`/`nohup`, `cmd /c start foo.bat` detached, `cmd /c foo.bat` synchronous with a hard timeout) — the process either exits or is killed before a single `osSyncPrintf`/`sysLogPrintf` line reaches the redirected log file, even though every print call in this codebase does an explicit `fflush(stderr)`. A plain GUI sanity check (launching `notepad.exe` the same way) worked fine, ruling out a blanket "no GUI" sandbox restriction, so the failure is specific to this binary/environment combination and its cause was not identified. Per `env-no-runtime-verify` (session memory): runtime verification is session-dependent; treat this session's empty captures as inconclusive, not as evidence of anything. **Next, concrete (for a session that CAN capture runtime output, interactive or otherwise):** (i) run any level with `GE_D219RAW=1` during an explosion/PPK firefight (or simply on level load, since these are likely eagerly cached) and read the logged `compmethod` for `IMAGE_FIRE_N` — cross-reference against a `GE_D219RAW`-instrumented capture of a *confirmed-correct-on-screen* RGBA16 sprite from the sibling `sImageTableEntry` families (e.g. `IMAGE_IMPACTMULTI`) to see whether FIRE uses a different `TEXCOMPMETHOD_*` (the compressed-bitstream compression method, not the pixel format) than a texture confirmed to render correctly — `TEXCOMPMETHOD_LOOKUP`/`_HUFFMANLOOKUP`/`_RLELOOKUP` (`texBuildLookup`+`texInflateLookup*`) build/consume a packed-color lookup table with a completely different bit-layout code path than `_HUFFMAN`/`_HUFFMANPERHCHANNEL`/`_RLE` (`texChannelsToPixels`, per-plane R/G/B/A order); if FIRE's method differs from the working comparison texture's, that pinpoints exactly which decode function to scrutinize next, rather than continuing to treat "the RGBA16 decoder" as one monolithic suspect. (ii) if the compmethod matches, the bug is genuinely upstream of decode (ROM/asset-extraction data itself, not the decompression code) — at that point compare the decompressed byte stream against a reference N64 emulator's texture-dump of the same asset (this repo has no such reference on hand) to establish definitively whether the ROM bytes for FIRE really do decode to blue/purple on real hardware too (in which case the bug is actually downstream in the color-combiner path after all, reopening the D172/G_CC_INTERFERENCE lead this session did not have budget to re-examine) or whether the extraction into this repo's `assets/oddtextures.c`-adjacent ROM data diverges from the real cart image for just these 15 offsets. Probes left in tree, both inert by default: `GE_D219RAW` (`src/game/image.c` `texInflateNonZlib`, `port/fast3d/gfx_pc.cpp` `import_texture_rgba16`). **M-111 (2026-09-12, live session, 6 build/relaunch cycles): G_LIGHTING lead RULED OUT; texture-cache-collision lead RULED OUT; ROOT CAUSE FOUND — R/B channel swap in the decoded fire particle texture data itself.** Live-captured 4 rounds against a real user playtest (not scripted) with the user confirming explosions were still blue/purple in every capture: diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index b3232e59..d1d77dbf 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -102,6 +102,10 @@ static struct Row rows[] = { { "Video.TextureFilter", "Texture filter", ROW_ENUM, 1, kTexFilter, 0, 0, 0, 0,0,0,0,0 }, { "Video.Anisotropy", "Anisotropic", ROW_SLIDER, 1, NULL, 0, 0, 0, 0,0,0,0,0 }, { "Video.FovScale", "FOV scale %", ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 }, + { "Video.DrawDistance", "Draw distance %", ROW_SLIDER, 25, NULL, 0, 0, 0, 0,0,0,0,0 }, + { "Video.DrawDistanceAutoFov","Draw dist. auto", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, + { "Video.LodDistance", "LOD distance %", ROW_SLIDER, 25, NULL, 0, 0, 0, 0,0,0,0,0 }, + { "Video.LodDistanceAutoFov", "LOD dist. auto", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, { "Input.MouseAimSpeed", "Mouse aim speed", ROW_SLIDER, 1, NULL, 0, 0, 100, 0,0,0,0,0 }, { "Input.MouseTurnSpeed", "Mouse turn speed", ROW_SLIDER, 1, NULL, 0, 0, 100, 0,0,0,0,0 }, { "Input.MouseInvertY", "Mouse invert Y", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, diff --git a/port/src/video.c b/port/src/video.c index 8064ff46..7f92f4a0 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -59,6 +59,10 @@ static int cfgTexFilter = 1; /* 0 = nearest, 1 = bilinear (default), 2 = N static int cfgFixMipTex = 1; /* RC2: clip mip-contaminated texture uploads to base height */ static int cfgWrapFix = 0; /* D74 sub-tile UV pre-wrap + RC3/D167 non-PoT mask-period wrap (opt-in; GE_WRAPFIX env overrides) */ static int cfgFovScale = 100; /* D211: percent of the original vertical FOV; 100 = unchanged (byte-identical) */ +static int cfgDrawDistance = 100; /* D218: percent of the level's authored far-clip/fog distance; 100 = unchanged */ +static int cfgDrawDistanceAutoFov = 1; /* D218: couple draw distance to Video.FovScale unless DrawDistance is set explicitly */ +static int cfgLodDistance = 100; /* D249: percent scale on the geometry/model LOD-swap distance; 100 = unchanged */ +static int cfgLodDistanceAutoFov = 0; /* off by default -- unlike DrawDistance, this is meant as a standalone perf lever, not something that should silently get more expensive as FovScale widens */ static int cfgAniso = 4; /* D212: anisotropic filtering samples; 4 = the value fast3d already applied (no visual delta at default) */ static int cfgFullscreen = 0; /* 0 = windowed, 1 = borderless fullscreen */ @@ -98,6 +102,99 @@ s32 portNoHitFlash = 0; * (pause/watch model, sky) and left the world untouched. 1.0f = original. */ f32 portFovScale = 1.0f; +/* D222: the same widen-FOV math as the fr.c guPerspectiveF chokepoint, + * factored out so cull-plane / LOD-scale call sites (currentPlayerSetCameraScale, + * via currentPlayerSetPerspective) can feed the SAME effective FOV that is + * actually rendered, instead of leaving them on the nominal value. Before this, + * high FovScale widened what was drawn but left frustum-cull planes and the + * fog/LOD distance scale (c_scalelod/c_lodscalez) calibrated for the narrower + * nominal FOV, so on-screen geometry near the edges (and, via c_lodscalez, + * the fog-based distance-visibility fade) got culled/faded as if the view + * were still narrow. `isTitleScreen` mirrors fr.c's own + * `lvlGetCurrentStageToLoad() != LEVELID_TITLE` guard -- the front end's + * fixed-FOV 3D must not be touched. 1.0f/identity at FovScale=100 (default), + * bit-for-bit no-op. */ +f32 portScaleFovY(f32 fovy, s32 isTitleScreen) +{ + if (!isTitleScreen && portFovScale > 0.4f && portFovScale < 2.01f && portFovScale != 1.0f) { + fovy *= portFovScale; + if (fovy > 160.0f) { fovy = 160.0f; } + } + return fovy; +} + +/* D218: Video.DrawDistance -- multiplier applied to a level's authored + * Visibility.FarFog (src/game/bgfog.c fogLoadCurrentEnvironment), which is + * both the far clip plane and the fog-saturation distance (levels are tuned + * for the stock ~60deg FOV), plus the character/prop fog-visibility-fade + * cutoff (src/game/propobj.c chrobjFogVisRangeRelated/sub_GAME_7F054C58). + * Video.DrawDistanceAutoFov (default on) couples the multiplier to + * Video.FovScale so a wider FOV doesn't clip its own newly visible far + * geometry -- or fade NPCs out early -- against distances tuned for the + * narrower original view (D218's "blue artifacting"; the M-121 live + * playtest found a straight 1:1 FovScale coupling still faded guards in + * noticeably close on Dam, so the auto coupling is 2x FovScale, not 1x). + * An explicit Video.DrawDistance != 100 overrides that coupling outright. + * Clamped to <=4.0x -- pushing the far plane much further out risks + * far-field z-fighting against the level's original near-plane precision; + * raised again (M-121 live playtest: 2x FovScale still showed a "blue + * glow" on far Dam tunnel geometry, so auto coupling is now 4x FovScale) + * to give headroom (max portFovScale is 1.5 at Video.FovScale's registered + * ceiling of 150, so 4x tops out at 6.0x -- ceiling raised to match). + * Identity (1.0f) at both defaults, bit-for-bit no-op. NOTE: end-to-end + * re-check of fogLoadCurrentEnvironment (bgfog.c) found the fog RAMP + * itself (not just the far-clip cutoff) already scales correctly with + * this multiplier -- g_ScaledFarFogIntensity/scaled_far_fog_dist both + * derive from the same scaled far value. If a visible blue tint at range + * persists even at a large multiplier, it may be Dam's tunnel sightline + * simply exceeding whatever distance was tried, or a separate visual + * element (skybox/backdrop) not gated by Visibility.FarFog at all -- + * worth a fresh screenshot-driven look before assuming another bug here. */ +f32 portDrawDistanceMultiplier(void) +{ + f32 mult; + if (cfgDrawDistance != 100) { + mult = (f32)cfgDrawDistance / 100.0f; + } else if (cfgDrawDistanceAutoFov && portFovScale != 1.0f) { + mult = portFovScale * 4.0f; + } else { + return 1.0f; + } + if (mult > 6.0f) { mult = 6.0f; } + if (mult < 1.0f) { mult = 1.0f; } + return mult; +} + +/* D249: Video.LodDistance -- multiplier on the *distance* term + * modelUpdateDistanceRelations() (src/game/model.c) tests against each LOD + * node's MinDistance/MaxDistance, composed on top of the game's own + * g_ModelDistanceScale rather than replacing it. Smaller distance reads as + * "closer", so this function returns the INVERSE of the requested percent: + * Video.LodDistance=200 (keep full detail twice as far, more cost) -> + * 0.5x on the distance term; =50 (drop to lower detail twice as soon, less + * cost -- the perf lever) -> 2.0x. Video.LodDistanceAutoFov (default OFF, + * unlike DrawDistanceAutoFov) can couple it to Video.FovScale the same + * direction as draw distance if ever wanted; off by default so this stays a + * standalone dial and doesn't quietly add cost as FovScale widens. Clamped + * to a [0.25, 4.0] distance multiplier (== effective LodDistance 25-400%) -- + * far outside that band either does nothing visible (LOD never triggers) or + * thrashes every frame. Identity (1.0f) at the Video.LodDistance=100 + * default, bit-for-bit no-op. */ +f32 portLodDistanceMultiplier(void) +{ + f32 pct; + if (cfgLodDistance != 100) { + pct = (f32)cfgLodDistance; + } else if (cfgLodDistanceAutoFov && portFovScale != 1.0f) { + pct = portFovScale * 100.0f; + } else { + return 1.0f; + } + if (pct < 25.0f) { pct = 25.0f; } + if (pct > 400.0f) { pct = 400.0f; } + return 100.0f / pct; +} + PD_CONSTRUCTOR static void videoConfigInit(void) { configRegisterFloat("Game.ScreenShakeIntensity", &portScreenShakeScale, 0.0f, 10.0f); @@ -110,6 +207,10 @@ PD_CONSTRUCTOR static void videoConfigInit(void) configRegisterInt("Video.FixMipTextures", &cfgFixMipTex, 0, 1); configRegisterInt("Video.WrapFix", &cfgWrapFix, 0, 1); configRegisterInt("Video.FovScale", &cfgFovScale, 50, 150); + configRegisterInt("Video.DrawDistance", &cfgDrawDistance, 100, 400); + configRegisterInt("Video.DrawDistanceAutoFov", &cfgDrawDistanceAutoFov, 0, 1); + configRegisterInt("Video.LodDistance", &cfgLodDistance, 25, 400); + configRegisterInt("Video.LodDistanceAutoFov", &cfgLodDistanceAutoFov, 0, 1); configRegisterInt("Video.Anisotropy", &cfgAniso, 1, 16); configRegisterInt("Video.Fullscreen", &cfgFullscreen, 0, 1); configRegisterInt("Window.Width", &cfgWinW, 0, 16384); diff --git a/src/fr.c b/src/fr.c index 3e3e47f5..77e89525 100644 --- a/src/fr.c +++ b/src/fr.c @@ -721,22 +721,18 @@ Gfx *viSetupCurrentPlayerView(Gfx *gdl) * matrix) and the sky/full-screen layer. fovy is in degrees, so an * angle-proportional scale (portFovScale, default 1.0f) matches the * documented 60deg->75deg at 1.25. Composes with the per-frame aim-zoom - * rewrites of fovy for free. NOTE: currentPlayerSetCameraScale() keeps - * the nominal FOV for frustum-cull / screen<->world math, so at large - * scales edge geometry can cull a hair early — acceptable for a cosmetic - * widen. Default 1.0f => identical matrix, bit for bit. */ + * rewrites of fovy for free. Default 1.0f => identical matrix, bit for + * bit. D222: the same widen (portScaleFovY, port/src/video.c) now also + * feeds currentPlayerSetPerspective at the viSet* call sites below, so + * frustum-cull planes and the fog/LOD distance scale stay calibrated to + * what's actually rendered instead of the narrower nominal FOV. */ { - extern f32 portFovScale; + extern f32 portScaleFovY(f32 fovy, s32 isTitleScreen); extern s32 lvlGetCurrentStageToLoad(void); - f32 frFovY = g_ViBackData->fovy; /* Gameplay only: the front end (main menu / file / mission select / * briefing) also renders through this path with stage == LEVELID_TITLE * (lv.c lvlRender), and the menu 3D is authored at a fixed FOV. */ - if (portFovScale > 0.4f && portFovScale < 2.01f && portFovScale != 1.0f - && lvlGetCurrentStageToLoad() != LEVELID_TITLE) { - frFovY *= portFovScale; - if (frFovY > 160.0f) { frFovY = 160.0f; } - } + f32 frFovY = portScaleFovY(g_ViBackData->fovy, lvlGetCurrentStageToLoad() == LEVELID_TITLE); guPerspectiveF(g_viProjectionMatrixF, &g_viPerspNorm, frFovY, g_ViBackData->aspect, g_ViBackData->znear, g_ViBackData->zfar, 1.0f); } #else @@ -934,17 +930,44 @@ void viSetUseZBuf(s32 usezbuf) g_ViBackData->usezbuf = usezbuf; } +#ifdef PORT +/* D222: currentPlayerSetPerspective feeds c_perspfovy, which + * currentPlayerSetCameraScale() (game/bondview.c) uses to derive the + * frustum-cull plane normals (c_cameratopnorm/c_cameraleftnorm) and the + * fog/LOD distance scale (c_scalelod/c_lodscalez, consumed by + * chrobjFogVisRangeRelated() et al. in game/propobj.c). Every viSet* below + * was calling it with the nominal g_ViBackData->fovy even though the actual + * render projection (this file, viSetupCurrentPlayerView) had already been + * widened by Video.FovScale — so at high FovScale, on-screen geometry near + * the edges (and the fog-based distance-visibility fade) got culled/faded + * as if the view were still narrow. Identity at FovScale=100. */ +static f32 frCullFovY(void) +{ + extern f32 portScaleFovY(f32 fovy, s32 isTitleScreen); + extern s32 lvlGetCurrentStageToLoad(void); + return portScaleFovY(g_ViBackData->fovy, lvlGetCurrentStageToLoad() == LEVELID_TITLE); +} +#endif + void viSetFovY(f32 fovy) { g_ViBackData->fovy = fovy; +#ifdef PORT + currentPlayerSetPerspective(g_ViBackData->znear, frCullFovY(), g_ViBackData->aspect); +#else currentPlayerSetPerspective(g_ViBackData->znear, g_ViBackData->fovy, g_ViBackData->aspect); +#endif currentPlayerSetCameraScale(); } void viSetAspect(f32 aspect) { g_ViBackData->aspect = aspect; +#ifdef PORT + currentPlayerSetPerspective(g_ViBackData->znear, frCullFovY(), g_ViBackData->aspect); +#else currentPlayerSetPerspective(g_ViBackData->znear, g_ViBackData->fovy, g_ViBackData->aspect); +#endif currentPlayerSetCameraScale(); } @@ -957,7 +980,11 @@ void viSetFov(f32 fovx, f32 fovy) { g_ViBackData->fovy = fovy; g_ViBackData->aspect = (f32) (fovx / fovy); +#ifdef PORT + currentPlayerSetPerspective(g_ViBackData->znear, frCullFovY(), g_ViBackData->aspect); +#else currentPlayerSetPerspective(g_ViBackData->znear, g_ViBackData->fovy, g_ViBackData->aspect); +#endif currentPlayerSetCameraScale(); } @@ -965,7 +992,11 @@ void viSetZRange(f32 near, f32 far) { g_ViBackData->znear = near; g_ViBackData->zfar = far; +#ifdef PORT + currentPlayerSetPerspective(g_ViBackData->znear, frCullFovY(), g_ViBackData->aspect); +#else currentPlayerSetPerspective(g_ViBackData->znear, g_ViBackData->fovy, g_ViBackData->aspect); +#endif currentPlayerSetCameraScale(); } diff --git a/src/game/bgfog.c b/src/game/bgfog.c index 0ef897dc..71d9df4f 100644 --- a/src/game/bgfog.c +++ b/src/game/bgfog.c @@ -291,6 +291,9 @@ void fogLoadCurrentEnvironment(EnvironmentRecord *arg0) f32 temp_f0; f32 sp20; // 32 f32 sp1C; // 28 +#ifdef PORT + f32 scaledFarFog; // D218: Video.DrawDistance -- see fogLoadCurrentEnvironment body +#endif #ifdef DEBUG f32 fmin; // hoisted to global f32 fmax; // hoisted to global @@ -302,7 +305,23 @@ void fogLoadCurrentEnvironment(EnvironmentRecord *arg0) assert(fmax <= 1.0F); #endif +#ifdef PORT + /* D218: Video.DrawDistance -- fogLoadCurrentEnvironment is the single + * point where a level's authored Visibility.FarFog becomes the actual + * runtime far-clip/fog distance (both viSetZRange's far plane and the + * fog-blend math just below use it), so scaling it here keeps the two + * consistent. Read arg0->Visibility.FarFog into a local instead of + * mutating the record -- EnvironmentRecord data is shared/reloaded, not + * a per-frame copy. Identity at DrawDistance=100/DrawDistanceAutoFov's + * FovScale=100 default. */ + { + extern f32 portDrawDistanceMultiplier(void); + scaledFarFog = arg0->Visibility.FarFog * portDrawDistanceMultiplier(); + } + viSetZRange(arg0->Visibility.BlendMultiplier, scaledFarFog); +#else viSetZRange(arg0->Visibility.BlendMultiplier, arg0->Visibility.FarFog); +#endif viGetZRange(&zrange); temp_f0 = bgGetLevelVisibilityScale(); @@ -317,7 +336,13 @@ void fogLoadCurrentEnvironment(EnvironmentRecord *arg0) g_CurFogDetails.g_CurFogDetails = (arg0->Visibility.BlendMultiplier / temp_f0); pk0 = g_CurFogDetails.g_CurFogDetails; +#ifdef PORT + /* D218: keep the fog-blend distance consistent with the scaled far + * clip plane set above, instead of the level's raw authored FarFog. */ + g_CurFogDetails.scaled_far_fog_dist = (scaledFarFog / temp_f0); +#else g_CurFogDetails.scaled_far_fog_dist = (arg0->Visibility.FarFog / temp_f0); +#endif pk4 = g_CurFogDetails.scaled_far_fog_dist; // numerator is constant 128.0f diff --git a/src/game/model.c b/src/game/model.c index 7809d835..ec5965cd 100644 --- a/src/game/model.c +++ b/src/game/model.c @@ -1978,6 +1978,23 @@ void modelUpdateDistanceRelations(Model* model, ModelNode* node) { distance *= g_ModelDistanceScale; } + +#ifdef PORT + /* D249/D218 follow-up: Video.LodDistance. Composed on top of (not + * replacing) g_ModelDistanceScale so it doesn't fight the game's own + * transient overrides (chr.c's 0.3125f close-up scale, the + * modelSetDistanceDisabled front-end/menu paths above). A smaller + * distance here reads as "closer" to the MinDistance/MaxDistance + * test below, so a multiplier < 1.0 keeps full-detail LOD children + * visible farther out (more detail, more cost); > 1.0 drops to the + * lower LOD sooner (less detail, cheaper) -- a real performance + * lever, independent of Video.DrawDistance's fog/far-clip distance. + * Identity at the Video.LodDistance=100 default. */ + { + extern f32 portLodDistanceMultiplier(void); + distance *= portLodDistanceMultiplier(); + } +#endif } if (distance > rodata->LOD.MinDistance * model->scale || rodata->LOD.MinDistance == 0) diff --git a/src/game/propobj.c b/src/game/propobj.c index d0acb4fd..1c38e18b 100644 --- a/src/game/propobj.c +++ b/src/game/propobj.c @@ -13500,6 +13500,23 @@ f32 chrobjFogVisRangeRelated(PropRecord *prop, f32 size) if ((nfd != NULL) && (nfd->MaxObfuscationRange < prop->zDepth)) { temp_f12 = getPlayer_c_lodscalez(); +#ifdef PORT + /* D222/D218 follow-up: c_lodscalez now correctly grows with + * Video.FovScale (D222, so the screen-edge frustum-cull planes that + * share it stay consistent with what's actually rendered) -- but + * this fog-visibility-fade cutoff was an unintended second consumer + * of that same value, so guards/props started fading/disappearing + * SOONER at wide FOV than before D222, not farther. Counteract with + * the same portDrawDistanceMultiplier() this "how far can you see" + * question is already opted into via Video.DrawDistanceAutoFov + * (default on) -- divides back out D222's FOV-driven growth and, at + * an explicit Video.DrawDistance, extends visibility further still. + * Identity whenever the multiplier is 1.0 (both defaults). */ + { + extern f32 portDrawDistanceMultiplier(void); + temp_f12 /= portDrawDistanceMultiplier(); + } +#endif temp_f12 = ((((prop->zDepth - nfd->MaxObfuscationRange) * 100.0f) / size) + nfd->MaxObfuscationRange) * temp_f12; if (nfd->MaxVisRange <= temp_f12) @@ -13540,6 +13557,14 @@ bool sub_GAME_7F054C58(coord3d *coord, f32 arg1) if (sp20 > ptr->z) { f32 scalez = getPlayer_c_lodscalez(); +#ifdef PORT + /* D222/D218 follow-up -- see chrobjFogVisRangeRelated above, + * same fix for this sibling visibility test. */ + { + extern f32 portDrawDistanceMultiplier(void); + scalez /= portDrawDistanceMultiplier(); + } +#endif sp20 = ((sp20 - ptr->z) * 100 / arg1 + ptr->z) * scalez; if (sp20 >= ptr->y)