docs(D222): new — high FovScale breaks culling, NPCs/objects vanish near screen edges

Split out of D211's 'cosmetic edge-cull-early' caveat - user confirms
this is a real functional bug, not cosmetic: NPCs and some world objects
stop rendering near screen edges at high FovScale. Root cause: frustum
cull / visibility math still runs at nominal unscaled fovy while the
render projection is portFovScale-widened. Fix direction + a real risk
flagged: some of these cull call sites may double as AI
awareness/visibility checks, not just render culling - must audit before
changing so portFovScale never leaks into NPC behavior (non-negotiable
#2, game logic unmodified).

Claude-Session: https://claude.ai/code/session_016TaKy6AAsVUTfjkhrX6CjG
This commit is contained in:
James Dansereau committed 2026-09-10 22:14:37 -04:00
1 parent 4e96a60b3f
commit 9016cf183c
2 files changed
+3 -1

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ D200,FIXED,-level_09 segfault: reverb's ALDelay.input/output are u32 —,FIXED
D196,OPEN,"OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).","OPEN — root-caused, 1-line fix identified, not applied (M-49)"
D192,OPEN,Front-end mission/level-select grid pointer still can't reach the outer cells —,"OPEN — root-caused, not fixed (M-49)"
D221,OPEN,SELECT FILE screen: the mouse hit-box for a file slot is vertically offset from its visible box —,"OPEN — observed, not investigated."
D222,OPEN,High Video.FovScale breaks culling —,"OPEN — root cause known, not fixed."
D204,FIXED,PC audio ran ~2 % below real time permanently:,"FIXED (port/src/audio.c, port/src/libultra.c, port/include/audio.h) —"
D203,OPEN,"Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle:",OPEN — negative idle repros only; not yet reproduced with input.
D201,FIXED,"src/libultra/audio/bnkf.c bank/instrument/sound/wavetable relocation offsets were s32, truncating the real 64-bit base pointer.",FIXED (src/libultra/audio/bnkf.c) —
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
29 D196
30 D192
31 D221
32 D222
33 D204
34 D203
35 D201
+2 -1
View File
@@ -402,6 +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. | OPEN — root-caused, 1-line fix identified, not applied (M-49) |
| 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) — likely adjacent, same front-end grid-coordinate family. | **OPEN — observed, not investigated.** Next: find the SELECT FILE slot hit-test rects (`front.c`, mission/file-select grid split points, same neighborhood as D192's `front.c:516/519/3180/3194`) vs. the rects used to draw the highlight/box; compare row-height/origin assumptions — a hit-test rect keyed to a baseline/bottom origin while the drawn box uses a top origin would produce exactly "click below to select." |
| 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). |
| D204 | **PC audio ran ~2 % below real time permanently: GE's AI feedback loop (`src/audi.c:531`) has a ~3 ms setpoint that PC scheduling jitter clears, so the SDL queue starves and the device pads playback with silence (FIXED, measured).** `amMain` wakes at 30 Hz (`sched.c:334` forwards every 2nd retrace to the audio client) and asks for `g_MinFrameSize`=720 samples per block = 21600/s against a 22050 Hz device — structurally 2 % short, relying on 784-sample top-up blocks that `audi.c` only requests once the reported AI length falls under ~69 frames (3 ms). Fine on N64 (double-buffered AI, exact VI interrupt); on PC the queue empties before the loop reacts, and the padded silence is unrecoverable time. **Fix (F5, port-only):** `audioGetAiLengthBytes()` subtracts a 1024-frame (~46 ms) cushion before reporting, so the loop tops up while slack remains; `audi.c`'s control law untouched (rule #2 clean). Measured A/B in one binary via `GE_D204_OLD`: **rt 0.980 → 1.000, q min 0 → 368, drop 0.** Also: F1 correct AI_LEN_REG single-buffer semantics (robustness, measures as a no-op), F3 `Audio.QueueLimit` 8192→2880 + non-silent drops (NB: an existing `ge007.ini` pins it), F4 oversize invariant guard, and `GE_D204=1` cheap audio-health monitor (`rt`/`q`/`drop`/`max`, safe to leave on for a full playtest). **Falsified in-session, do not re-open:** the u32 wrap at `audi.c:531` is real (high-water 2064 frames > the 789 threshold) but harmless — `frameSamples` is `s16` (`audi.c:145`), so it lands negative and audi.c's own lower clamp catches it; max block stays 3136 B vs the 3156 B allocation, no heap overrun. **Corrects two M-63 claims:** `soundIndex=109` is guards' return fire (count was double-logged; the "32.6 ms cadence" is just the 2880-byte block quantum every `dumppos` is rounded to) — closed negative; and M-63's "13 % of real time" was `GE_MIXERTRACE`'s own 25 MB unbuffered log starving the audio thread (same repro without it: 58.0 s / 60 s). Does NOT explain D202. | FIXED (`port/src/audio.c`, `port/src/libultra.c`, `port/include/audio.h`) — measured; a by-ear pass on a real playthrough still owed |
| D203 | **Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle: Facility (`-level_34`) crashes "after loading in as James Bond" (user bug report).** Idle repro attempts on WSL Ubuntu with the actual `dist/goldeneye-pc-port-0.1.0-linux-x86_64.tar.gz` are **negative**: 120 s idle → frame 3300+ clean exit (the intro auto-advances, so "sitting as Bond" is covered), and a ~4-min stick-forward-only run → frame 6900+ also survived. So the trigger needs real gameplay input (fire/action) or is Deck-environment-specific. **Leading suspect: the D191 family** — both known v0.1.0 field crashes are SIGSEGVs in `modelGetNodeRwData` (`model.c:478`) over a truncated/NULL `root->Parent` node link, and both were triggered by *gameplay state* (first guard kill on Bunker ii; post-cutscene on Statue), never by idling; Facility opens into the Ourumov + soldier-squad scripted beat (D193a instance), which fits the trigger profile. Alternatives: Deck GL driver path (RADV/zink vs llvmpipe) or 40/50/60 Hz timing (D193 family). **Next:** (1) WSL run under gdb with a walk+fire `GE_INPUTSCRIPT` — generate the script into a file first (earlier attempts were blocked by a `bash -lc` quoting quirk that emptied the loop var, so no fire buttons ever went out); on SIGSEGV compare the fault PC against `modelGetNodeRwData`; (2) if not reproducible on WSL, get `ge007.crash.log` from the Deck (the Linux build writes it — proven by a stale one in the smoke tree) + ask what Bond was doing at crash time; optionally a gdb run from the SteamOS terminal. | OPEN — negative idle repros only; not yet reproduced with input. |
| D201 | **`src/libultra/audio/bnkf.c` bank/instrument/sound/wavetable relocation offsets were `s32`, truncating the real 64-bit base pointer.** `alSeqFileNew`/`alBnkfNew` compute `offset = (s32) base` from a heap pointer and thread it through `_bnkfPatchBank/Inst/Sound/WaveTable` as `s32 offset, table` parameters, used to rebase every embedded pointer field in a loaded bank/inst/sound/wavetable/book/loop record. On N64 (32-bit pointers) the truncation is a no-op; on PC, sign-extension of the truncated `s32` during the rebase arithmetic reconstructs the wrong 64-bit address whenever the base allocation's low 32 bits have the top bit set (~50% of allocations, allocator-dependent) — corrupting every relocated pointer in the bank file, a plausible contributor to bad model/audio pointers surfacing downstream (D191-family symptoms are a node-tree walk, not this, but the class is the same). **Fix (ABI-layer, no logic change):** widen `offset`/`table`/`woffset` and all four `_bnkfPatch*` parameters from `s32` to `uintptr_t` (`<stdint.h>`) — already this codebase's idiom for pointer-safe integers in `src/game/*.c`. No behavior change on the 32-bit N64 build. | FIXED (`src/libultra/audio/bnkf.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation |
@@ -428,7 +429,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D180 | **Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).** Three parts, all config-gated with default = prior behaviour: **(WI-1) `Input.MouseCaptureMode`** (0 = legacy always-grab; 1 = Quake-style click-to-lock) in `port/src/input.c` + `port/src/video.c` — in mode 1 the OS cursor is free until a click lands in the game window (`SDL_MOUSEBUTTONDOWN` → `inputNotifyClick`), and ESC / focus-loss / entering any front-end menu (`current_menu != MENU_RUN_STAGE`) frees it again; re-entering a stage while still "armed" re-locks so unpausing needs no click. `reconcileGrab()` runs once per controller-0 poll. Mouse buttons are suppressed from the game while the cursor is free in-stage (no phantom fire). Controller paths untouched. **(WI-2) absolute-cursor menu tracking** — when capture mode is on and the cursor is free in a menu, the D165/D169 pointer P-controller takes its target from the real cursor's absolute window position mapped onto the live virtual front-end rect (`getPlayer_c_screen*`), giving true 1:1 tracking instead of the relative-delta estimator. Relative path unchanged for legacy/grabbed. **(B3) `Input.MouseAimSpeed` default 25 → 16** (aim mode still overshot at 25 per the backlog). | LANDED, port-only, no `#ifdef PORT`. Build 241/241, `-level_09` framediff 3/3 within threshold (unregressed), `GE_STARTMENU` menu boot crash-free. **Feel-checks owed** (headless can't drive the mouse): click-to-lock ergonomics, absolute menu tracking, the new aim-speed default. |
| D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** `src/fr.c viShake()` gains a single `#ifdef PORT` line — `intensity *= portScreenShakeScale;` (extern `f32`, defined + `configRegisterFloat`'d 0.0–10.0 in `port/src/video.c`) — before the existing clamp. Default `1.0f` ⇒ exact no-op, every headless golden dump byte-unaffected; `0.0` disables explosion/effect screen shake, up to `10.0` exaggerates it. N64 build (no `-DPORT`) keeps the original line verbatim. Precedent per `docs/BACKLOG.md` "Fun features → screen-shake intensity slider" and AGENTS.md #2's documented-exception path. Same policy class as a future FOV hook. | LANDED behind config, default = original. Build green. |
| D184 | **F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** New `port/src/optionsoverlay.c` + `port/include/optionsoverlay.h`: an immediate-mode overlay drawn as its own fast3d 2D DL, appended after the game DL in `gfx_pc.cpp gfx_run()` (`optionsOverlayEmit()` → NULL when closed ⇒ **zero bytes appended, golden dumps byte-identical**). F10 toggles (`video.c videoPumpEvents`, next to F12); while open, `input.c inputComputePad(0)` returns a neutral pad and routes arrows/enter/wheel/mouse-buttons to `optionsOverlayHandleInput()` (mirrors the WI-1/D180 swallow). ESC closes; `configSave()` on close. `config.c` gains `configForEachOption()` + a `configSetOptionMeta()` label/step/enum side table (no per-key knowledge in config.c). Live knobs (VSync/FpsCap/TextureFilter via `videoRequestLiveConfig()`; mouse + screen-shake via the registered pointer) apply immediately; MSAA tagged "(restart)". v1 rows per plan §3: VSync, FpsCap, MSAA, TextureFilter, MouseAimSpeed, MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity. | **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241. **Runtime verification NOT run this session** — the headless env kills the GUI process after ~8 s and `gfx_opengl_dump_bound_fbo` writes 0-byte PPMs here, so framediff / 60 s soak / overlay-frame eyeball are all owed on an interactive machine. Overlay-closed no-op is code-evident (emit returns NULL before touching the DL buffer). |
| D211 | **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). | **PARTIAL — M-84 refix: game-side FOV scale (`src/fr.c`, `#ifdef PORT`), front-end gated, user-verified.** Original M-83 note follows. LANDED, port-only; build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **M-84 refix — the fast3d hook only scaled *pure* perspective loads; GE draws the world through `g_CurrentPlayer->field_10E0`, a projection × view matrix pre-combined on the CPU (`bondview2.c:8224`) that failed the `gfx_apply_fov_scale` guard, so only the pause/watch model + sky FOV changed (user report).** Moved the scale game-side to the `guPerspectiveF` chokepoint (`fr.c:718`, `#ifdef PORT`, `frFovY *= portFovScale`, clamp 160°), which lands *before* the CPU combine; fast3d `gfx_apply_fov_scale`/`gfx_set_fov_scale` removed; `port/src/video.c` sets `f32 portFovScale`. Also gated to `lvlGetCurrentStageToLoad() != LEVELID_TITLE` — the front end (main menu / file+mission select / briefing) renders through the same path (`lv.c lvlRender`) and its 3D is authored at a fixed FOV. **User-verified: front end unaffected + in-level view widens (Dam tunnel at high FovScale).** M-85 headless nit check (`-level_09`, FovScale 100 vs 120): **HUD-static confirmed** — the ammo counter ("7 | 93") sits at the identical bottom-right screen position in both, and the door-arrow stays square (no 4:3 stretch); viewmodel scales down + stays bottom-right anchored. **aim-zoom-compose — M-86 static analysis: correct by construction.** `bondview2.c:3123` lerps `zoominfovy` then `viSetFovY()` stores it in `g_ViBackData->fovy`; `fr.c:737` applies `frFovY = fovy * portFovScale` **once**, at the per-frame chokepoint, *after* the zoom lerp. Hipfire and zoomed states scale by the same factor so the zoom magnification ratio is preserved exactly; no double-application; the smooth zoom interpolation is unaffected (scale is post-lerp). Residual human eyeball (low risk, cosmetic) at FovScale ~120–150 + full aim-zoom: the viewmodel (`c_perspfovy`, `bondview.c:644`) and frustum-cull (`currentPlayerSetCameraScale`) use the *unscaled* fovy, so a subtle viewmodel-scale or edge-cull-early mismatch is possible; the scope reticle is 2D screen-space, unaffected. Caveat (same root): edge geometry culls a hair early at large scale (cosmetic). **User-verified good at big FovScale.** New adjacent report → **D220**: 1P muzzle flash position shifts upward at higher FovScale (Jungle, M16) — likely the same unscaled-viewmodel-fovy root as the caveat above. Wide FOV also exposes the per-level draw-distance boundary → **D218** (logged, parked). |
| D211 | **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). | **PARTIAL — M-84 refix: game-side FOV scale (`src/fr.c`, `#ifdef PORT`), front-end gated, user-verified.** Original M-83 note follows. LANDED, port-only; build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **M-84 refix — the fast3d hook only scaled *pure* perspective loads; GE draws the world through `g_CurrentPlayer->field_10E0`, a projection × view matrix pre-combined on the CPU (`bondview2.c:8224`) that failed the `gfx_apply_fov_scale` guard, so only the pause/watch model + sky FOV changed (user report).** Moved the scale game-side to the `guPerspectiveF` chokepoint (`fr.c:718`, `#ifdef PORT`, `frFovY *= portFovScale`, clamp 160°), which lands *before* the CPU combine; fast3d `gfx_apply_fov_scale`/`gfx_set_fov_scale` removed; `port/src/video.c` sets `f32 portFovScale`. Also gated to `lvlGetCurrentStageToLoad() != LEVELID_TITLE` — the front end (main menu / file+mission select / briefing) renders through the same path (`lv.c lvlRender`) and its 3D is authored at a fixed FOV. **User-verified: front end unaffected + in-level view widens (Dam tunnel at high FovScale).** M-85 headless nit check (`-level_09`, FovScale 100 vs 120): **HUD-static confirmed** — the ammo counter ("7 | 93") sits at the identical bottom-right screen position in both, and the door-arrow stays square (no 4:3 stretch); viewmodel scales down + stays bottom-right anchored. **aim-zoom-compose — M-86 static analysis: correct by construction.** `bondview2.c:3123` lerps `zoominfovy` then `viSetFovY()` stores it in `g_ViBackData->fovy`; `fr.c:737` applies `frFovY = fovy * portFovScale` **once**, at the per-frame chokepoint, *after* the zoom lerp. Hipfire and zoomed states scale by the same factor so the zoom magnification ratio is preserved exactly; no double-application; the smooth zoom interpolation is unaffected (scale is post-lerp). Residual human eyeball (low risk, cosmetic) at FovScale ~120–150 + full aim-zoom: the viewmodel (`c_perspfovy`, `bondview.c:644`) and frustum-cull (`currentPlayerSetCameraScale`) use the *unscaled* fovy, so a subtle viewmodel-scale or edge-cull-early mismatch is possible; the scope reticle is 2D screen-space, unaffected. Caveat (same root): edge geometry culls a hair early at large scale (cosmetic). **User-verified good at big FovScale.** New adjacent report → **D220**: 1P muzzle flash position shifts upward at higher FovScale (Jungle, M16) — likely the same unscaled-viewmodel-fovy root as the caveat above. Wide FOV also exposes the per-level draw-distance boundary → **D218** (logged, parked). **M-87: the frustum-cull residual is a real bug, not cosmetic** → split to **D222**. |
| D212 | **Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** The fast3d GL backend already implemented `set_anisotropy_level` / `get_max_anisotropy_level` and applied `GL_TEXTURE_MAX_ANISOTROPY` on mipmapped tiles with a hardcoded `current_anisotropy_level = 4`; there was no way to configure it. Added the `gfx_set_anisotropy_level()` extern-C wrapper in `gfx_pc.cpp` (clamps to `[1, GL max]`, `reset_texture_state()` so live changes take), declared in `gfx_api.h`, and `Video.Anisotropy` (1–16) in `port/src/video.c` wired through `videoApplyImageOptions()` on init + live-config. **Default 4 = the value fast3d already applied ⇒ no visual delta at defaults.** F10 overlay row "Anisotropic". | **LANDED, port-only.** Build clean. **Owed:** eyeball at 16× on a grazing surface (Depot roof / Dam walkway) vs 1×. |
| D213 | **On-screen FPS readout — `Video.DisplayFPS` (M-83 QoL wave, port-only, PD parity).** `port/src/optionsoverlay.c` samples frame time once per emitted frame (`fpsTick()` via `sysGetMicroseconds()`, ~0.5 s window) and, when enabled, emits a small top-right green "N FPS" DL even while the F10 panel is closed. Config-only knob (kept off the 13-row panel, which is at its documented layout limit — matches PD, whose `DisplayFPS` is also file-only). **Default 0 ⇒ `optionsOverlayEmit()` still returns NULL when the panel is closed ⇒ golden dumps byte-identical.** | **LANDED, port-only.** Build clean; `bunker1` no-op confirmed. **Owed:** eyeball that the counter reads a sane ~30 and doesn't overlap the HUD. |
| D214 | **Keyboard rebinding — `[Input.Bind]` config section (M-83, port-only, PD parity, Phase 4 debt).** `port/src/input.c` gains 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each a comma-separated list of up to 4 SDL scancode names (`Input.Bind.Forward = W,Up` …). `inputRebuildBinds()` (called from `inputInit()`, after `configLoad()`) parses them via `SDL_GetScancodeFromName`; unknown names log a warning and are skipped. The old hardcoded `keyDown(ks, SDL_SCANCODE_*)` block in `inputComputePad` is replaced by `actHeld(ks, IA_*)`. Mouse buttons (fire = LMB, aim = RMB) stay hardwired; the mouse-wheel weapon-cycle A-pulse is untouched. **Defaults reproduce the previous FPS layout exactly — verified: all 20 default key names round-trip to the identical `SDL_Scancode` the old code used (`SDL_GetScancodeFromName` test, 0 mismatches).** Config-file only (no in-overlay text entry — matches PD, whose rebinding is also file-driven); a fresh `ge007.ini` gets the full `[Input.Bind]` block with the defaults visible. | **PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.** LANDED, port-only, no `#ifdef PORT`; build clean. One doc bug fixed (no code change): `config.c` groups keys by the substring *before the last `.`*, so the section header is **`[Input.Bind]`**, not `[Bind]` — earlier finding/commit/QOL text was wrong. Working syntax: put `Fire = Left Alt` under `[Input.Bind]`, or write a fully-qualified `Input.Bind.Fire = Left Alt` line anywhere (`config.c:307`). Separately: the pause→**watch menu** does not wire Esc→back (still Enter to open/close/back) — pre-existing, `options.c`, minor. |