From 97ac5327c9a132478c631f5ea331a0c6d2991cd2 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:02:47 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(D211-D213):=20PC=20QoL=20image=20optio?= =?UTF-8?q?ns=20=E2=80=94=20FOV=20slider,=20anisotropic=20filtering,=20on-?= =?UTF-8?q?screen=20FPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three port-only knobs, each defaulting to byte-identical behaviour: - Video.FovScale (D211): multiplicative half-angle scale on every perspective projection matrix as it is loaded (gfx_apply_fov_scale, gfx_pc.cpp). Columns 0+1 scaled together so H/V FOV widen uniformly and aspect is preserved; ortho (HUD/menu) matrices untouched. Composes with the game's per-frame aim-zoom rewrites for free. Percent of vertical FOV, 50-150, default 100 => guarded early-return (zero FP ops, golden dumps byte-identical). WIDESCREEN-FOV-PLAN Phase 4, landed standalone. - Video.Anisotropy (D212): expose fast3d's already-implemented GL anisotropy hook as config (1-16). New gfx_set_anisotropy_level() extern-C wrapper clamps to the GL max. Default 4 = the value fast3d already hardcoded => no delta. - Video.DisplayFPS (D213): PD-parity top-right FPS readout, sampled once per emitted frame (optionsoverlay.c fpsTick). Config-only; default 0 => emit still returns NULL with the panel closed. F10 overlay gains 'Anisotropic' and 'FOV scale %' rows (DisplayFPS is file-only - the panel is at its 13-row layout limit, matching PD). Build clean (ntsc-final); bunker1 framediff worst_cell 12.219 vs 12.221 baseline = render noise, no-op confirmed. Interactive eyeballs owed (see docs/HANDOFF.md M-83). findings.md: D211-D213 rows + index regen. WIDESCREEN-FOV-PLAN Phase 4 and QOL-INVENTORY rows marked done. --- docs/dev/QOL-INVENTORY.md | 11 +++--- docs/dev/WIDESCREEN-FOV-PLAN.md | 13 +++++-- docs/dev/findings-index.csv | 3 ++ docs/dev/findings.md | 3 ++ port/fast3d/gfx_api.h | 2 ++ port/fast3d/gfx_pc.cpp | 63 +++++++++++++++++++++++++++++++++ port/src/optionsoverlay.c | 56 ++++++++++++++++++++++++++++- port/src/video.c | 18 ++++++++-- 8 files changed, 159 insertions(+), 10 deletions(-) diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md index 5b2a5ae4..83998c0f 100644 --- a/docs/dev/QOL-INVENTORY.md +++ b/docs/dev/QOL-INVENTORY.md @@ -1,7 +1,10 @@ # QoL Inventory — PD-vs-GE options diff -Status: **read-only research, parked** (input to project Phase 4). No code -changed. Companion to `WIDESCREEN-FOV-PLAN.md` and `UNLOCKED-FPS-PLAN.md`. +Status: **partially implemented.** M-83 landed three port-only quick wins — +DisplayFPS (D213), anisotropic filtering (D212), and the FOV slider (D211, via +`WIDESCREEN-FOV-PLAN.md` Phase 4). Rebinding / SkipIntro / per-pad tuning / +window HiDpi still open. Companion to `WIDESCREEN-FOV-PLAN.md` and +`UNLOCKED-FPS-PLAN.md`. Method: diffed the config surface of both ports — every `configRegister*` key in `pd_port/port/src/*.c` vs `port/src/config.c` + the F10 overlay @@ -27,8 +30,8 @@ ScreenShakeIntensity. Debug: FrameDump, InputLog. |---|---|---|---|---| | **Key rebinding** (config-string binds per controller/action) | `input.c:69,627-675,1548` (`bindStrs`, `inputParseBindString`) | P | M | **High.** Explicit Phase 4 debt ("rebinding UI" — note PD has *no* UI either; config-file driven). Port-only in `port/src/input.c`. | | **SkipIntro** | `main.c:139` (STAGE_TITLE → STAGE_CITRAINING) | P | S | **High.** GE analogue: stage-override path already exists (`boss.c:385-393`, `g_StageNum = LEVELID_TITLE` at :98); wire a flag + arg. 10-min check on what feeds `tokenFindLevel`. | -| **DisplayFPS** (+ interval) | config `Video.DisplayFPS(Interval)` | P | S | Cheap; useful for FPS-plan verification too. | -| **Anisotropic filtering** (≤16×), MipmapFilter, TextureFilter2D | `gfx_opengl` texparam path | P | S–M | **High at 4K.** GE's `gfx_opengl.cpp` has mipmap nY but no aniso (`GL_TEXTURE_MAX_ANISOTROPY_EXT`). Port-only. | +| **DisplayFPS** _(DONE M-83, D213)_ | config `Video.DisplayFPS` | P | S | Top-right readout, config-only; ~0.5 s sample window. | +| **Anisotropic filtering** _(DONE M-83, D212)_ | `Video.Anisotropy` (1–16, dflt 4) | P | S | fast3d already had the GL hook + a hardcoded 4×; M-83 exposed it as config. MipmapFilter / TextureFilter2D still not surfaced. | | Per-pad tuning: per-stick deadzones, stick scale, rumble scale, device index, swap sticks, C-button mapping | `input.c` padsCfg block | P | M | Medium. Matters for real-controller users; PD pattern is a config section per pad. | | FakeGamepads / FirstGamepadNum / UseHIDAPI | `input.c` | P | S–M | Niche (local MP on PC); defer until MP is actually played. | | Window polish: DefaultFullscreen/Maximize, CenterWindow, AllowHiDpi, ExclusiveFullscreen | `video.c` vid* block | P | S | HiDpi + center are the useful ones on modern Windows. | diff --git a/docs/dev/WIDESCREEN-FOV-PLAN.md b/docs/dev/WIDESCREEN-FOV-PLAN.md index ea71e384..31bedbca 100644 --- a/docs/dev/WIDESCREEN-FOV-PLAN.md +++ b/docs/dev/WIDESCREEN-FOV-PLAN.md @@ -213,10 +213,17 @@ reason. Whole-feature fallback to A only if B fails verification overall. verification at 4:3 + 16:9, finding entry. Fallback-to-A decisions happen here, per surface, with documented reason. -### Phase 4 — FOV slider (port-only) +### Phase 4 — FOV slider (port-only) — **LANDED (M-83, D211)** -- [ ] RSP projection post-scale; `Video.FovScale` config + F10 overlay; - verify composition with aim zoom at non-100 scale. +- [x] RSP projection post-scale (`gfx_apply_fov_scale()` in + `port/fast3d/gfx_pc.cpp`, load-path only); `Video.FovScale` config + (percent of vertical FOV, 50–150, default 100 = byte-identical) + F10 + overlay row. Perspective-only (ortho/HUD untouched); columns 0+1 scaled + together so aspect is preserved. Build clean, `bunker1` no-op confirmed. +- [ ] Owed: interactive eyeball of composition with aim-zoom at scale ≠ 100. + +This phase does **not** depend on the Phase 2/3 game-code exception — it +shipped standalone ahead of the rest of the plan. ### Phase 5 — Verification & sign-off diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 9db80291..1675116c 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -53,6 +53,9 @@ D179,FIXED,Packaged build crashes after the logos —,"Diagnosed, not fixed —" D180,PARTIAL,"Native-PC input pass (M-XX QoL run, qol/native-pc-input-menu, port-only).","LANDED, port-only, no #ifdef PORT." D181,PARTIAL,Game.ScreenShakeIntensity —,"LANDED behind config, default = original." D184,PARTIAL,F10 port-layer options overlay —,"LANDED, port-only, no #ifdef PORT in src/." +D211,PARTIAL,"PC FOV slider — Video.FovScale (M-83 QoL wave, port-only, WIDESCREEN-FOV-PLAN.md Phase 4).","LANDED, port-only, no #ifdef PORT." +D212,PARTIAL,Anisotropic-filtering config wiring —,"LANDED, port-only. Build clean." +D213,PARTIAL,On-screen FPS readout —,"LANDED, port-only. Build clean; bunker1 no-op confirmed." D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)" D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure,resolved D88.4,RESOLVED,propDefs polymorphic record stream not byteswapped → setupDoor crash,resolved (d88_propdefs.py); layout audit M-20 → D132 diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 824ecd9c..c5fd96d5 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -427,6 +427,9 @@ 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). | **LANDED, port-only, no `#ifdef PORT`.** Build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **Owed:** an interactive eyeball at FovScale ≠ 100 (world widens, HUD unmoved, aim-zoom still composes). | +| 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. | | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | | D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved | diff --git a/port/fast3d/gfx_api.h b/port/fast3d/gfx_api.h index 74dd57d5..a2db5672 100644 --- a/port/fast3d/gfx_api.h +++ b/port/fast3d/gfx_api.h @@ -48,6 +48,8 @@ void gfx_set_texture_filter(enum FilteringMode mode); void gfx_set_mipmap_filter(enum MipmapFilteringMode mode); void gfx_set_fix_mip_textures(int on); void gfx_set_wrap_fix(int on); +void gfx_set_fov_scale(float scale); /* D211: 1.0 = original vertical FOV */ +void gfx_set_anisotropy_level(int level); /* 1 = off; clamped to GL max */ void gfx_texture_cache_clear(void); void gfx_texture_cache_delete(const uint8_t *orig_addr); void gfx_texture_cache_delete_range(const uint8_t *start, const uint8_t *end); diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index df76f5b3..2f6913bb 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -916,6 +916,50 @@ bool g_fix_mip_textures = true; * off. Video.WrapFix = 1. */ bool g_wrap_fix = false; +/* D211 port-only FOV slider. Multiplies the half-angle of every *perspective* + * projection matrix as it is loaded, so it composes with the game's per-frame + * aim-zoom rewrites for free (see docs/dev/WIDESCREEN-FOV-PLAN.md "FOV + * adjustment design"). 1.0 = byte-identical to the original (guarded skip); + * Video.FovScale is a percent, 100 = 1.0. */ +static float g_fov_scale = 1.0f; + +extern "C" void gfx_set_fov_scale(float s) { + if (!(s > 0.4f) || !(s < 2.01f)) { + s = 1.0f; + } + g_fov_scale = s; +} + +/* Widen/narrow a freshly-loaded projection matrix in place. No-op unless the + * matrix is a perspective projection (row-vector guPerspective layout: only + * P[2][3] == -1, P[3][3] == 0 — an ortho matrix has P[3][3] == 1 and is left + * alone so HUD/menu 2D is untouched). Scaling columns 0 and 1 by the same + * factor scales the effective cotangent, i.e. widens H and V FOV uniformly — + * aspect ratio is preserved, no stretch. */ +static void gfx_apply_fov_scale(float m[4][4]) { + if (g_fov_scale == 1.0f) { + return; + } + if (!(fabsf(m[3][3]) < 0.01f) || !(fabsf(m[2][3] + 1.0f) < 0.05f)) { + return; /* not a perspective projection */ + } + const float cot = m[1][1]; + if (!(cot > 1e-4f)) { + return; + } + const float half = atanf(1.0f / cot); + float nhalf = half * g_fov_scale; + const float LIMIT = 1.48f; /* ~85 deg half-angle; keep cot well away from 0 */ + if (nhalf > LIMIT) { + nhalf = LIMIT; + } + const float f = tanf(half) / tanf(nhalf); + for (int k = 0; k < 4; k++) { + m[k][0] *= f; + m[k][1] *= f; + } +} + /* D183 source-pitch de-stride (see import_texture). Default on; * GE_TEXPITCH=0 restores the old flat read for A/B. */ static bool gfx_tex_pitch_fix(void) { @@ -1212,6 +1256,11 @@ static void gfx_sp_matrix(uint8_t parameters, const int32_t* addr) { if (parameters & G_MTX_PROJECTION) { if (parameters & G_MTX_LOAD) { memcpy(rsp.P_matrix, matrix, sizeof(matrix)); + /* D211: apply the FOV slider on the load path only. GE always + * (re)loads its view projection with G_MTX_LOAD (src/fr.c:725), so + * a relative G_MTX_MUL onto an already-scaled matrix never + * happens and cannot compound. */ + gfx_apply_fov_scale(rsp.P_matrix); } else { gfx_matrix_mul(rsp.P_matrix, matrix, rsp.P_matrix); } @@ -3227,6 +3276,20 @@ extern "C" void gfx_set_mipmap_filter(enum MipmapFilteringMode mode) { } extern "C" void gfx_set_fix_mip_textures(int on) { g_fix_mip_textures = !!on; } + +/* D212: expose the (already-implemented) rendering-API anisotropy hook to the + * port layer. Clamp to [1, GL max] so a stale ini value can't feed an invalid + * GL_TEXTURE_MAX_ANISOTROPY. 1 = isotropic (driver default). */ +extern "C" void gfx_set_anisotropy_level(int level) { + reset_texture_state(); + int max = gfx_rapi->get_max_anisotropy_level ? gfx_rapi->get_max_anisotropy_level() : 1; + if (max < 1) max = 1; + if (level < 1) level = 1; + if (level > max) level = max; + if (gfx_rapi->set_anisotropy_level) { + gfx_rapi->set_anisotropy_level(level); + } +} extern "C" void gfx_set_wrap_fix(int on) { const char* e = getenv("GE_WRAPFIX"); /* RC3 test override */ g_wrap_fix = e ? (atoi(e) != 0) : !!on; diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 018412dc..b3232e59 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -100,6 +100,8 @@ static struct Row rows[] = { { "Video.FpsCap", "Frame cap", ROW_SLIDER, 10, NULL, 0, 0, 360, 0,0,0,0,0 }, { "Video.MSAA", "MSAA", ROW_MSAA, 0, NULL, 1, 0, 0, 0,0,0,0,0 }, { "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 }, { "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 }, @@ -112,6 +114,40 @@ static int s_inited = 0; static volatile int s_open = 0; static int s_sel = 0; +/* D213: optional on-screen FPS readout (PD parity: Video.DisplayFPS). Drawn + * top-right whenever enabled, independent of the F10 panel. Config-only knob + * (kept off the 13-row panel, which is at its layout limit) -- matches PD, + * whose DisplayFPS is also file-only. Default 0 => emit path unchanged => + * golden dumps byte-identical. */ +static int s_showFps = 0; +static char s_fpsText[16] = ""; + +PD_CONSTRUCTOR static void overlayConfigInit(void) +{ + configRegisterInt("Video.DisplayFPS", &s_showFps, 0, 1); +} + +/* Sampled once per emitted frame; recomputes the string every ~0.5 s. */ +static void fpsTick(void) +{ + static uint64_t winStartUs = 0; + static int frames = 0; + + uint64_t nowUs = sysGetMicroseconds(); + if (winStartUs == 0) { + winStartUs = nowUs; + return; + } + frames++; + uint64_t dtUs = nowUs - winStartUs; + if (dtUs >= 500000) { + int fps = (int)((double)frames * 1e6 / (double)dtUs + 0.5); + snprintf(s_fpsText, sizeof(s_fpsText), "%d FPS", fps); + winStartUs = nowUs; + frames = 0; + } +} + /* Layout (game 2D pixel space = viGetX() x viGetY(), ~320x240). Shared by the * emit path and the mouse hit-testing in optionsOverlayHandleInput(). * BankGothic caps are ~9 units tall here, so rows need ~16 units of pitch and @@ -543,8 +579,26 @@ Gfx *optionsOverlayEmit(void) if (!s_inited) { overlayInit(); } + + fpsTick(); + if (!s_open) { - return NULL; /* nothing appended -> golden dumps byte-identical */ + if (!s_showFps || !s_fpsText[0]) { + return NULL; /* nothing appended -> golden dumps byte-identical */ + } + /* D213: FPS-only mini DL (top-right), panel closed. */ + const s32 fw = viGetX(); + const s32 fh = viGetY(); + Gfx *fgdl = s_buf; + gDPPipeSync(fgdl++); + gDPSetCycleType(fgdl++, G_CYC_1CYCLE); + gDPSetTexturePersp(fgdl++, G_TP_NONE); + gDPSetScissor(fgdl++, G_SC_NON_INTERLACE, 0, 0, fw, fh); + fgdl = microcode_constructor(fgdl); + fgdl = drawTextR(fgdl, fw - 6, 6, s_fpsText, 0x40ff60ff); + gDPPipeSync(fgdl++); + gSPEndDisplayList(fgdl++); + return s_buf; } const s32 W = viGetX(); diff --git a/port/src/video.c b/port/src/video.c index 9ebcb634..6454c4e1 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -58,6 +58,8 @@ static int cfgMSAA = 1; /* 1/2/4/8 samples; 1 = off static int cfgTexFilter = 1; /* 0 = nearest, 1 = bilinear (default), 2 = N64 3-point + trilinear */ 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 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 */ /* @@ -89,6 +91,8 @@ PD_CONSTRUCTOR static void videoConfigInit(void) configRegisterInt("Video.TextureFilter", &cfgTexFilter, 0, 2); configRegisterInt("Video.FixMipTextures", &cfgFixMipTex, 0, 1); configRegisterInt("Video.WrapFix", &cfgWrapFix, 0, 1); + configRegisterInt("Video.FovScale", &cfgFovScale, 50, 150); + configRegisterInt("Video.Anisotropy", &cfgAniso, 1, 16); configRegisterInt("Video.Fullscreen", &cfgFullscreen, 0, 1); configRegisterInt("Window.Width", &cfgWinW, 0, 16384); configRegisterInt("Window.Height", &cfgWinH, 0, 16384); @@ -101,6 +105,14 @@ PD_CONSTRUCTOR static void videoConfigInit(void) * scheduler thread in videoStartFrame() where the GL context is bound. */ static volatile int liveCfgDirty = 0; +/* D211/D212: push the port-only image knobs into fast3d. Both are safe to + * call every frame; gfx_set_fov_scale(1.0) is a guarded no-op. */ +static void videoApplyImageOptions(void) +{ + gfx_set_fov_scale((float)cfgFovScale / 100.0f); + gfx_set_anisotropy_level(cfgAniso); +} + static void videoApplyTexFilter(void) { if (cfgTexFilter >= 2) { @@ -271,6 +283,7 @@ int videoInit(void) gfx_set_wrap_fix(cfgWrapFix); videoApplyTexFilter(); + videoApplyImageOptions(); /* The GL context is currently current on this (host main) thread, but all * rendering happens on the game's scheduler thread. WGL only allows a @@ -311,9 +324,10 @@ void videoStartFrame(void) wmAPI->set_swap_interval(cfgVSync ? 1 : 0); gfx_set_target_fps(cfgFpsCap); /* 0 = uncapped */ videoApplyTexFilter(); + videoApplyImageOptions(); sysLogPrintf(LOG_INFO, "video: live config applied " - "(vsync=%d fpscap=%d texfilter=%d)", - cfgVSync, cfgFpsCap, cfgTexFilter); + "(vsync=%d fpscap=%d texfilter=%d fov=%d aniso=%d)", + cfgVSync, cfgFpsCap, cfgTexFilter, cfgFovScale, cfgAniso); } gfx_start_frame(); From a04b8193783891c9dd83527c73ef6c5a65b5372d Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:09:26 -0400 Subject: [PATCH 2/3] feat(D214): keyboard rebinding via [Bind] config section port/src/input.c: 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/ TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each bound to a comma-separated list of up to 4 SDL scancode names via Input.Bind.* keys. inputRebuildBinds() parses them in inputInit() after configLoad(); SDL_GetScancodeFromName resolves names, unknowns warn + skip. The hardcoded keyDown(ks, SDL_SCANCODE_*) block in inputComputePad is replaced with actHeld(ks, IA_*). Mouse buttons (fire/aim) stay hardwired. File-driven, no UI -- matches the PD port. A fresh ge007.ini gets the full [Bind] block with defaults visible. Defaults reproduce the previous FPS layout exactly: all 20 default key names round-trip to the identical SDL_Scancode the old code used (0 mismatches in a SDL_GetScancodeFromName test). Build clean; verify.sh dam/facility/bunker1 PASS crash-free. findings.md D214 + index; QOL-INVENTORY row marked done. --- docs/dev/QOL-INVENTORY.md | 2 +- docs/dev/findings-index.csv | 1 + docs/dev/findings.md | 1 + port/src/input.c | 113 +++++++++++++++++++++++++++++++----- 4 files changed, 100 insertions(+), 17 deletions(-) diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md index 83998c0f..de671ddc 100644 --- a/docs/dev/QOL-INVENTORY.md +++ b/docs/dev/QOL-INVENTORY.md @@ -28,7 +28,7 @@ ScreenShakeIntensity. Debug: FrameDump, InputLog. | Feature | PD location | Class | Effort | Notes / priority | |---|---|---|---|---| -| **Key rebinding** (config-string binds per controller/action) | `input.c:69,627-675,1548` (`bindStrs`, `inputParseBindString`) | P | M | **High.** Explicit Phase 4 debt ("rebinding UI" — note PD has *no* UI either; config-file driven). Port-only in `port/src/input.c`. | +| **Key rebinding** _(DONE M-83, D214)_ | `port/src/input.c` `[Bind]` section | P | M | Keyboard only, 12 actions, `Input.Bind.* = Key,Key` (SDL scancode names), file-driven like PD. Defaults = prior FPS layout (verified). Per-controller pad rebinding + a UI still open. | | **SkipIntro** | `main.c:139` (STAGE_TITLE → STAGE_CITRAINING) | P | S | **High.** GE analogue: stage-override path already exists (`boss.c:385-393`, `g_StageNum = LEVELID_TITLE` at :98); wire a flag + arg. 10-min check on what feeds `tokenFindLevel`. | | **DisplayFPS** _(DONE M-83, D213)_ | config `Video.DisplayFPS` | P | S | Top-right readout, config-only; ~0.5 s sample window. | | **Anisotropic filtering** _(DONE M-83, D212)_ | `Video.Anisotropy` (1–16, dflt 4) | P | S | fast3d already had the GL hook + a hardcoded 4×; M-83 exposed it as config. MipmapFilter / TextureFilter2D still not surfaced. | diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 1675116c..5d0d078d 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -56,6 +56,7 @@ D184,PARTIAL,F10 port-layer options overlay —,"LANDED, port-only, no #ifdef PO D211,PARTIAL,"PC FOV slider — Video.FovScale (M-83 QoL wave, port-only, WIDESCREEN-FOV-PLAN.md Phase 4).","LANDED, port-only, no #ifdef PORT." D212,PARTIAL,Anisotropic-filtering config wiring —,"LANDED, port-only. Build clean." D213,PARTIAL,On-screen FPS readout —,"LANDED, port-only. Build clean; bunker1 no-op confirmed." +D214,PARTIAL,"Keyboard rebinding — [Bind] config section (M-83, port-only, PD parity, Phase 4 debt).","LANDED, port-only, no #ifdef PORT." D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)" D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure,resolved D88.4,RESOLVED,propDefs polymorphic record stream not byteswapped → setupDoor crash,resolved (d88_propdefs.py); layout audit M-20 → D132 diff --git a/docs/dev/findings.md b/docs/dev/findings.md index c5fd96d5..b61e9e12 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -430,6 +430,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | 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). | **LANDED, port-only, no `#ifdef PORT`.** Build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **Owed:** an interactive eyeball at FovScale ≠ 100 (world widens, HUD unmoved, aim-zoom still composes). | | 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 — `[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 `[Bind]` block with the defaults visible. | **LANDED, port-only, no `#ifdef PORT`.** Build clean. Default binds byte-equivalent to the prior mapping (scancode test). **Owed:** interactive check that a *non-default* bind (e.g. `Fire = Left Alt`) actually takes, and that ESC still cancels menus (it's in the default `Cancel` bind + a separate `video.c` path). | | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | | D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved | diff --git a/port/src/input.c b/port/src/input.c index aa725afa..680af48b 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -399,6 +399,8 @@ static unsigned scriptApply(unsigned button) return m; } +static void inputRebuildBinds(void); /* D214; defined below with keyDown() */ + int inputInit(void) { if (!SDL_WasInit(SDL_INIT_GAMECONTROLLER)) { @@ -413,6 +415,8 @@ int inputInit(void) } inputOpenPads(); + inputRebuildBinds(); /* D214: parse [Bind] now that configLoad() has run */ + /* Relative mouse mode for mouse-look. In click-to-lock mode we start * released and wait for a click in the window (video.c -> inputNotifyClick). */ mouseGrabbed = mouseEnabled && !mouseCaptureMode; @@ -486,7 +490,79 @@ static int scaleAxis(int v) static int keyDown(const Uint8 *ks, SDL_Scancode sc) { - return ks && ks[sc]; + return ks && sc != SDL_SCANCODE_UNKNOWN && ks[sc]; +} + +/* ------------------------------------------------------------------------ + * D214 — keyboard rebinding (PD parity: config-string driven, no UI). + * + * Each gameplay action maps to a comma-separated list of SDL scancode names + * (as printed by SDL_GetScancodeName: "W", "Up", "Left Ctrl", "Space", ...). + * The defaults reproduce the previously-hardcoded FPS layout exactly, so a + * fresh or [Bind]-less ini changes nothing. Parsed once in inputInit(), after + * configLoad(). Mouse buttons (fire = LMB, aim = RMB) stay hardwired. + * ---------------------------------------------------------------------- */ +enum { + IA_FORWARD, IA_BACK, IA_STRAFE_L, IA_STRAFE_R, IA_TURN_L, IA_TURN_R, + IA_FIRE, IA_AIM, IA_ACTION, IA_CANCEL, IA_LEAN_L, IA_START, IA_COUNT +}; + +static const struct { const char *key; const char *def; } kBindDefs[IA_COUNT] = { + [IA_FORWARD] = { "Input.Bind.Forward", "W,Up" }, + [IA_BACK] = { "Input.Bind.Back", "S,Down" }, + [IA_STRAFE_L] = { "Input.Bind.StrafeLeft", "A" }, + [IA_STRAFE_R] = { "Input.Bind.StrafeRight", "D" }, + [IA_TURN_L] = { "Input.Bind.TurnLeft", "Left" }, + [IA_TURN_R] = { "Input.Bind.TurnRight", "Right" }, + [IA_FIRE] = { "Input.Bind.Fire", "Left Ctrl" }, + [IA_AIM] = { "Input.Bind.Aim", "Left Shift" }, + [IA_ACTION] = { "Input.Bind.Action", "Space,Z,E" }, + [IA_CANCEL] = { "Input.Bind.Cancel", "X,R,F,Escape" }, + [IA_LEAN_L] = { "Input.Bind.LeanLeft", "Q" }, + [IA_START] = { "Input.Bind.Start", "Return,Tab" }, +}; + +#define BIND_MAX_KEYS 4 +static char g_bindStr[IA_COUNT][64]; +static SDL_Scancode g_bind[IA_COUNT][BIND_MAX_KEYS]; + +static void inputRebuildBinds(void) +{ + for (int a = 0; a < IA_COUNT; a++) { + for (int k = 0; k < BIND_MAX_KEYS; k++) { + g_bind[a][k] = SDL_SCANCODE_UNKNOWN; + } + char buf[64]; + strncpy(buf, g_bindStr[a][0] ? g_bindStr[a] : kBindDefs[a].def, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + + int n = 0; + for (char *tok = strtok(buf, ","); tok && n < BIND_MAX_KEYS; tok = strtok(NULL, ",")) { + while (*tok == ' ' || *tok == '\t') tok++; + char *end = tok + strlen(tok); + while (end > tok && (end[-1] == ' ' || end[-1] == '\t')) *--end = 0; + if (!*tok) continue; + SDL_Scancode sc = SDL_GetScancodeFromName(tok); + if (sc == SDL_SCANCODE_UNKNOWN) { + sysLogPrintf(LOG_WARNING, "input: %s: unknown key name '%s'", + kBindDefs[a].key, tok); + continue; + } + g_bind[a][n++] = sc; + } + if (n == 0) { + sysLogPrintf(LOG_WARNING, "input: %s has no valid keys; action unbound", + kBindDefs[a].key); + } + } +} + +static int actHeld(const Uint8 *ks, int act) +{ + for (int k = 0; k < BIND_MAX_KEYS; k++) { + if (keyDown(ks, g_bind[act][k])) return 1; + } + return 0; } /* Fill button mask + stick for controller idx. Returns the 16-bit mask. */ @@ -538,32 +614,29 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) /* GE default control (1.1): stick Y = move fwd/back, stick X = turn, * C-left/right = sidestep, C-up/down = look. FPS layout: W/S move, * A/D strafe (C-buttons), mouse X turns (stick X), mouse Y looks. */ - if (keyDown(ks, SDL_SCANCODE_W) || keyDown(ks, SDL_SCANCODE_UP)) sy = STICK_MAX; - if (keyDown(ks, SDL_SCANCODE_S) || keyDown(ks, SDL_SCANCODE_DOWN)) sy = -STICK_MAX; - if (keyDown(ks, SDL_SCANCODE_A)) button |= GE_CONT_C; /* strafe left */ - if (keyDown(ks, SDL_SCANCODE_D)) button |= GE_CONT_F; /* strafe right */ - if (keyDown(ks, SDL_SCANCODE_LEFT)) sx = -STICK_MAX; /* keyboard turn */ - if (keyDown(ks, SDL_SCANCODE_RIGHT)) sx = STICK_MAX; + if (actHeld(ks, IA_FORWARD)) sy = STICK_MAX; + if (actHeld(ks, IA_BACK)) sy = -STICK_MAX; + if (actHeld(ks, IA_STRAFE_L)) button |= GE_CONT_C; /* strafe left */ + if (actHeld(ks, IA_STRAFE_R)) button |= GE_CONT_F; /* strafe right */ + if (actHeld(ks, IA_TURN_L)) sx = -STICK_MAX; /* keyboard turn */ + if (actHeld(ks, IA_TURN_R)) sx = STICK_MAX; - if ((mb & SDL_BUTTON(SDL_BUTTON_LEFT)) || keyDown(ks, SDL_SCANCODE_LCTRL)) + if ((mb & SDL_BUTTON(SDL_BUTTON_LEFT)) || actHeld(ks, IA_FIRE)) button |= GE_CONT_G; - int aimHeld = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) || - keyDown(ks, SDL_SCANCODE_LSHIFT); + int aimHeld = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) || actHeld(ks, IA_AIM); if (aimHeld) button |= GE_CONT_R; - if (keyDown(ks, SDL_SCANCODE_SPACE) || keyDown(ks, SDL_SCANCODE_Z) || - keyDown(ks, SDL_SCANCODE_E)) + if (actHeld(ks, IA_ACTION)) button |= GE_CONT_A; if (wheelPulse > 0) { /* mouse-wheel weapon cycle -> A pulse */ button |= GE_CONT_A; wheelPulse--; } - if (keyDown(ks, SDL_SCANCODE_X) || keyDown(ks, SDL_SCANCODE_R) || - keyDown(ks, SDL_SCANCODE_F) || keyDown(ks, SDL_SCANCODE_ESCAPE)) /* D145: ESC = B (back/cancel) */ + if (actHeld(ks, IA_CANCEL)) /* D145: Escape is in the default Cancel bind */ button |= GE_CONT_B; - if (keyDown(ks, SDL_SCANCODE_Q)) + if (actHeld(ks, IA_LEAN_L)) button |= GE_CONT_L; - if (keyDown(ks, SDL_SCANCODE_RETURN) || keyDown(ks, SDL_SCANCODE_TAB)) + if (actHeld(ks, IA_START)) button |= GE_CONT_START; /* Mouse-look. Mode-dependent (see the tuning-constants comment): @@ -952,4 +1025,12 @@ PD_CONSTRUCTOR static void inputConfigInit(void) configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); configRegisterInt("Input.PadTriggerPct", &padTriggerPct, 1, 99); configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 0, 1); + + /* D214: keyboard rebinding. Seed each buffer with its default so the knob + * is visible/editable in a fresh ge007.ini; configLoad() overwrites any the + * user set, then inputInit() calls inputRebuildBinds(). */ + for (int a = 0; a < IA_COUNT; a++) { + strncpy(g_bindStr[a], kBindDefs[a].def, sizeof(g_bindStr[a]) - 1); + configRegisterString(kBindDefs[a].key, g_bindStr[a], sizeof(g_bindStr[a])); + } } From 01376660eec775de1159d01cd2669d96944d7f55 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:30:21 -0400 Subject: [PATCH 3/3] M-84: fold D211 FOV refix + user-test findings into the QoL wave D211 didn't affect the in-level view (user test): the fast3d gfx_apply_fov_scale hook only recognised *pure* perspective matrix loads, but GE draws the world through g_CurrentPlayer->field_10E0 -- a projection x view matrix pre-combined on the CPU (bondview2.c:8224) -- which failed the guard. Only the pause/watch model + sky FOV changed. Fix: scale fovy game-side at the guPerspectiveF chokepoint (src/fr.c, #ifdef PORT, frFovY *= portFovScale, clamp 160deg), which lands before the CPU combine. Removed the fast3d hook + gfx_api.h decl; port/src/video.c now sets f32 portFovScale. Gated to lvlGetCurrentStageToLoad() != LEVELID_TITLE so the front end (which renders through the same path) keeps its authored FOV. User-verified: menu unaffected, in-level view widens (Dam tunnel). Caveat: currentPlayerSetCameraScale keeps nominal FOV for frustum-cull / screen<->world math (edge geometry culls slightly early at high scale). Adds src/fr.c as a #ifdef PORT game-code touch (D181 policy class). Docs (this test pass): - D214: config.c sections by the last dot, so binds live in [Input.Bind] not [Bind]. Doc-only fix (finding, index, QOL-INVENTORY); code unchanged. - D217 (OPEN): viewmodel + 3rd-person Bond model textures render garbled -- stale-palette CI-texture bug (D161 family), surfaced by D215. - D218 (LOGGED, parked): wide FOV exposes the per-level FarFog draw-distance boundary; Video.DrawDistance fix proposed. --- docs/dev/QOL-INVENTORY.md | 2 +- docs/dev/findings-index.csv | 6 +++-- docs/dev/findings.md | 6 +++-- port/fast3d/gfx_api.h | 1 - port/fast3d/gfx_pc.cpp | 49 ------------------------------------- port/src/video.c | 13 +++++++--- src/fr.c | 26 ++++++++++++++++++++ 7 files changed, 45 insertions(+), 58 deletions(-) diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md index de671ddc..1b0d61bd 100644 --- a/docs/dev/QOL-INVENTORY.md +++ b/docs/dev/QOL-INVENTORY.md @@ -28,7 +28,7 @@ ScreenShakeIntensity. Debug: FrameDump, InputLog. | Feature | PD location | Class | Effort | Notes / priority | |---|---|---|---|---| -| **Key rebinding** _(DONE M-83, D214)_ | `port/src/input.c` `[Bind]` section | P | M | Keyboard only, 12 actions, `Input.Bind.* = Key,Key` (SDL scancode names), file-driven like PD. Defaults = prior FPS layout (verified). Per-controller pad rebinding + a UI still open. | +| **Key rebinding** _(DONE M-83, D214)_ | `port/src/input.c` `[Input.Bind]` section | P | M | Keyboard only, 12 actions, `Input.Bind.* = Key,Key` (SDL scancode names), file-driven like PD. Defaults = prior FPS layout (verified). Per-controller pad rebinding + a UI still open. | | **SkipIntro** | `main.c:139` (STAGE_TITLE → STAGE_CITRAINING) | P | S | **High.** GE analogue: stage-override path already exists (`boss.c:385-393`, `g_StageNum = LEVELID_TITLE` at :98); wire a flag + arg. 10-min check on what feeds `tokenFindLevel`. | | **DisplayFPS** _(DONE M-83, D213)_ | config `Video.DisplayFPS` | P | S | Top-right readout, config-only; ~0.5 s sample window. | | **Anisotropic filtering** _(DONE M-83, D212)_ | `Video.Anisotropy` (1–16, dflt 4) | P | S | fast3d already had the GL hook + a hardcoded 4×; M-83 exposed it as config. MipmapFilter / TextureFilter2D still not surfaced. | diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 5d0d078d..c067b0a6 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -53,10 +53,12 @@ D179,FIXED,Packaged build crashes after the logos —,"Diagnosed, not fixed —" D180,PARTIAL,"Native-PC input pass (M-XX QoL run, qol/native-pc-input-menu, port-only).","LANDED, port-only, no #ifdef PORT." D181,PARTIAL,Game.ScreenShakeIntensity —,"LANDED behind config, default = original." D184,PARTIAL,F10 port-layer options overlay —,"LANDED, port-only, no #ifdef PORT in src/." -D211,PARTIAL,"PC FOV slider — Video.FovScale (M-83 QoL wave, port-only, WIDESCREEN-FOV-PLAN.md Phase 4).","LANDED, port-only, no #ifdef PORT." +D211,MOSTLY-VERIFIED,"PC FOV slider — Video.FovScale (M-83; M-84 refix + front-end gate).","M-84: fast3d matrix hook only scaled pure-perspective loads (watch model/sky), missed the CPU-combined world matrix; moved to game-side fovy scale (fr.c, #ifdef PORT), gated to non-title stages. User-verified: menu unaffected + in-level view widens. Nits: HUD-static / aim-zoom eyeball at ~120. D218 (draw distance) split out + parked." D212,PARTIAL,Anisotropic-filtering config wiring —,"LANDED, port-only. Build clean." D213,PARTIAL,On-screen FPS readout —,"LANDED, port-only. Build clean; bunker1 no-op confirmed." -D214,PARTIAL,"Keyboard rebinding — [Bind] config section (M-83, port-only, PD parity, Phase 4 debt).","LANDED, port-only, no #ifdef PORT." +D214,PARTIAL,"Keyboard rebinding — [Input.Bind] config section (M-83; M-84 doc fix).","LANDED, port-only. M-84 user test: alt/ctrl PASS. Doc bug fixed (no code change): section is [Input.Bind] not [Bind] (config groups by last dot), or use a fully-qualified Input.Bind.Fire= line. Watch-menu Esc-to-back still unwired (pre-existing)." +D217,OPEN,"1P viewmodel + 3rd-person Bond model textures render garbled (green/blue/yellow) — wrong/stale palette on model-DL CI textures (M-84, user QA; surfaced by D215). D161 family.","Observed, not investigated. Next: GE_TEXDUMP on a weapon texture; check gDPLoadTLUT reaches gfx_pc.cpp for model DLs." +D218,LOGGED,"Wide FOV exposes the per-level FarFog draw-distance/fog boundary — blue artifacting down long sightlines (Dam tunnel) at high FovScale (M-84, user QA).","Fix proposed + parked: Video.DrawDistance multiplier on FarFog at bgfog.c:305 (#ifdef PORT) + Video.DrawDistanceAutoFov toggle (default couples draw distance to FovScale)." D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)" D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure,resolved D88.4,RESOLVED,propDefs polymorphic record stream not byteswapped → setupDoor crash,resolved (d88_propdefs.py); layout audit M-20 → D132 diff --git a/docs/dev/findings.md b/docs/dev/findings.md index b61e9e12..4a9bf09a 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -427,10 +427,12 @@ 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). | **LANDED, port-only, no `#ifdef PORT`.** Build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **Owed:** an interactive eyeball at FovScale ≠ 100 (world widens, HUD unmoved, aim-zoom still composes). | +| 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). | **LANDED, port-only, no `#ifdef PORT`.** 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).** Nits: HUD-static / aim-zoom-composes eyeball at ~120. Caveat: `currentPlayerSetCameraScale` frustum-cull / screen↔world math stays at nominal FOV → edge geometry culls a hair early at large scale (cosmetic). Wide FOV also exposes the per-level draw-distance boundary → **D218** (logged, parked). | | 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 — `[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 `[Bind]` block with the defaults visible. | **LANDED, port-only, no `#ifdef PORT`.** Build clean. Default binds byte-equivalent to the prior mapping (scancode test). **Owed:** interactive check that a *non-default* bind (e.g. `Fire = Left Alt`) actually takes, and that ESC still cancels menus (it's in the default `Cancel` bind + a separate `video.c` path). | +| 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. | **LANDED, port-only, no `#ifdef PORT`.** Build clean. **M-84 user test: alt/ctrl rebind PASS.** 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. | +| 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. | **Observed, not investigated (M-84).** Next: `GE_TEXDUMP` / `GE_DTEX` on a weapon texture in BUNKER1 — dump `fmt/siz/palfmt/palidx` + PPM, compare vs a clean world CI tile; check whether `gDPLoadTLUT` reaches `gfx_pc.cpp` for model DLs. | +| 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). | | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | | D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved | diff --git a/port/fast3d/gfx_api.h b/port/fast3d/gfx_api.h index a2db5672..b74963dd 100644 --- a/port/fast3d/gfx_api.h +++ b/port/fast3d/gfx_api.h @@ -48,7 +48,6 @@ void gfx_set_texture_filter(enum FilteringMode mode); void gfx_set_mipmap_filter(enum MipmapFilteringMode mode); void gfx_set_fix_mip_textures(int on); void gfx_set_wrap_fix(int on); -void gfx_set_fov_scale(float scale); /* D211: 1.0 = original vertical FOV */ void gfx_set_anisotropy_level(int level); /* 1 = off; clamped to GL max */ void gfx_texture_cache_clear(void); void gfx_texture_cache_delete(const uint8_t *orig_addr); diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index 2f6913bb..8e905624 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -916,50 +916,6 @@ bool g_fix_mip_textures = true; * off. Video.WrapFix = 1. */ bool g_wrap_fix = false; -/* D211 port-only FOV slider. Multiplies the half-angle of every *perspective* - * projection matrix as it is loaded, so it composes with the game's per-frame - * aim-zoom rewrites for free (see docs/dev/WIDESCREEN-FOV-PLAN.md "FOV - * adjustment design"). 1.0 = byte-identical to the original (guarded skip); - * Video.FovScale is a percent, 100 = 1.0. */ -static float g_fov_scale = 1.0f; - -extern "C" void gfx_set_fov_scale(float s) { - if (!(s > 0.4f) || !(s < 2.01f)) { - s = 1.0f; - } - g_fov_scale = s; -} - -/* Widen/narrow a freshly-loaded projection matrix in place. No-op unless the - * matrix is a perspective projection (row-vector guPerspective layout: only - * P[2][3] == -1, P[3][3] == 0 — an ortho matrix has P[3][3] == 1 and is left - * alone so HUD/menu 2D is untouched). Scaling columns 0 and 1 by the same - * factor scales the effective cotangent, i.e. widens H and V FOV uniformly — - * aspect ratio is preserved, no stretch. */ -static void gfx_apply_fov_scale(float m[4][4]) { - if (g_fov_scale == 1.0f) { - return; - } - if (!(fabsf(m[3][3]) < 0.01f) || !(fabsf(m[2][3] + 1.0f) < 0.05f)) { - return; /* not a perspective projection */ - } - const float cot = m[1][1]; - if (!(cot > 1e-4f)) { - return; - } - const float half = atanf(1.0f / cot); - float nhalf = half * g_fov_scale; - const float LIMIT = 1.48f; /* ~85 deg half-angle; keep cot well away from 0 */ - if (nhalf > LIMIT) { - nhalf = LIMIT; - } - const float f = tanf(half) / tanf(nhalf); - for (int k = 0; k < 4; k++) { - m[k][0] *= f; - m[k][1] *= f; - } -} - /* D183 source-pitch de-stride (see import_texture). Default on; * GE_TEXPITCH=0 restores the old flat read for A/B. */ static bool gfx_tex_pitch_fix(void) { @@ -1256,11 +1212,6 @@ static void gfx_sp_matrix(uint8_t parameters, const int32_t* addr) { if (parameters & G_MTX_PROJECTION) { if (parameters & G_MTX_LOAD) { memcpy(rsp.P_matrix, matrix, sizeof(matrix)); - /* D211: apply the FOV slider on the load path only. GE always - * (re)loads its view projection with G_MTX_LOAD (src/fr.c:725), so - * a relative G_MTX_MUL onto an already-scaled matrix never - * happens and cannot compound. */ - gfx_apply_fov_scale(rsp.P_matrix); } else { gfx_matrix_mul(rsp.P_matrix, matrix, rsp.P_matrix); } diff --git a/port/src/video.c b/port/src/video.c index 6454c4e1..2bf7cdfc 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -82,6 +82,13 @@ static int cfgWinMax = 0; */ f32 portScreenShakeScale = 1.0f; +/* D211: Video.FovScale as a multiplier on the render FOV. Applied game-side + * at the guPerspectiveF chokepoint (src/fr.c) so it lands BEFORE the CPU + * pre-multiplies projection x view into the combined world matrix — the + * fast3d-side matrix hack only caught the handful of pure-perspective loads + * (pause/watch model, sky) and left the world untouched. 1.0f = original. */ +f32 portFovScale = 1.0f; + PD_CONSTRUCTOR static void videoConfigInit(void) { configRegisterFloat("Game.ScreenShakeIntensity", &portScreenShakeScale, 0.0f, 10.0f); @@ -105,11 +112,11 @@ PD_CONSTRUCTOR static void videoConfigInit(void) * scheduler thread in videoStartFrame() where the GL context is bound. */ static volatile int liveCfgDirty = 0; -/* D211/D212: push the port-only image knobs into fast3d. Both are safe to - * call every frame; gfx_set_fov_scale(1.0) is a guarded no-op. */ +/* D211/D212: push the port-only image knobs where they apply. FovScale is a + * plain float the game re-reads each frame; anisotropy goes to fast3d. */ static void videoApplyImageOptions(void) { - gfx_set_fov_scale((float)cfgFovScale / 100.0f); + portFovScale = (f32)cfgFovScale / 100.0f; gfx_set_anisotropy_level(cfgAniso); } diff --git a/src/fr.c b/src/fr.c index a6b19120..3e3e47f5 100644 --- a/src/fr.c +++ b/src/fr.c @@ -715,7 +715,33 @@ Gfx *viSetupCurrentPlayerView(Gfx *gdl) // Create both a floating-point matrix for the CPU side and a fixed-point matrix for the RSP. g_viProjectionMatrix = dynAllocateMatrix(); +#ifdef PORT + /* D211: Video.FovScale — widen the render FOV here, at the one per-frame + * chokepoint that feeds both the world (via the CPU-combined proj x view + * 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. */ + { + extern f32 portFovScale; + 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; } + } + guPerspectiveF(g_viProjectionMatrixF, &g_viPerspNorm, frFovY, g_ViBackData->aspect, g_ViBackData->znear, g_ViBackData->zfar, 1.0f); + } +#else guPerspectiveF(g_viProjectionMatrixF, &g_viPerspNorm, g_ViBackData->fovy, g_ViBackData->aspect, g_ViBackData->znear, g_ViBackData->zfar, 1.0f); +#endif guMtxF2L(g_viProjectionMatrixF, g_viProjectionMatrix); /**