From c2c6fbdd68135f7edfc802318cc48cd16723e1c4 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:18:18 -0400 Subject: [PATCH] =?UTF-8?q?checkpoint:=20F10=20GE-style=20refresh=20+=20di?= =?UTF-8?q?stance=20defaults=20retune,=20D360-D370=20series=20(local=20WIP?= =?UTF-8?q?;=20D370=20pending=20user=20acceptance)=20=E2=80=94=20pre-wave?= =?UTF-8?q?=20baseline=20for=20the=20v0.4.0=20modern-options=20wave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/F10-GE-STYLE-PLAN.md | 129 +++++++ docs/dev/V0.4.0-PLAN.md | 37 +- docs/dev/findings-index.csv | 11 + docs/dev/findings.md | 312 +++++++++++++++ port/include/optionsoverlay.h | 2 + port/src/frontoptions.c | 11 +- port/src/main.c | 2 +- port/src/optionsoverlay.c | 708 +++++++++++++++++++++++----------- port/src/video.c | 43 +-- port/src/watchsettings.c | 71 +++- 10 files changed, 1048 insertions(+), 278 deletions(-) create mode 100644 docs/dev/F10-GE-STYLE-PLAN.md diff --git a/docs/dev/F10-GE-STYLE-PLAN.md b/docs/dev/F10-GE-STYLE-PLAN.md new file mode 100644 index 00000000..ac14238e --- /dev/null +++ b/docs/dev/F10-GE-STYLE-PLAN.md @@ -0,0 +1,129 @@ +# F10 visual refresh — GoldenEye dossier styling (proposal) + +Status: **superseded by D363 after user playtest** — the dossier/paper +concept below was implemented as D362, then replaced with a dark +watch-green/Bank Gothic version. This is the historical proposal for a +port-layer F10 overlay proposal, not a replacement for GE's pause watch or the +front-end PC Options screen. Reference surfaces: `src/game/front.c` +`constructor_menu15_cheat`/`frontSetupMenuBackground`, the existing +`port/src/frontoptions.c` cheat-list-inspired options screen, and GE's in-stage +watch. Current overlay: `port/src/optionsoverlay.c` `optionsOverlayEmit`, +`overlayRowAtY`, `sliderBarSpan`, `maxVisibleRows`. + +## 1. Direction and boundaries + +Make F10 read as an **MI6 dossier laid over the game scene**, not a generic +SDL debug panel: dark translucent scrim, warm paper/ivory content card, +black Zurich-style menu text, red for active options/slider fills and an +occasional muted gold title/accent. The front-end PC Options already uses +GE's actual 3D folder and Zurich text; use it as the visual reference, not +as a display-list dependency. In-stage F10 must remain an inexpensive flat +2D overlay: no loading a 3D wallet model, new ROM assets, front-end init +functions or `src/game` modifications. Preserve the current two-level +category -> settings navigation and the shared settings/commit API. + +The initial port-only implementation lives in `optionsOverlayEmit()` and +uses one shared `OvLayout` for both 320x240 stage and 440x330 front canvases. +Headless captures live under `scratch/d362-test/` (not distributed). Manual +visual review on a bright stage and controller/mouse feel-check remain owed. + +## 2. Concrete visual hierarchy + +- **Backdrop/card:** keep an adjustable translucent dim over the scene; + replace the opaque blue-black rectangle with a near-opaque ivory panel + (subtle border and one offset shadow). Do not mimic the actual folder + texture unless a later, measured prototype proves the simple card + insufficient. Preserve legible contrast against both dark and bright + levels, including safe-area crop and widescreen pillarboxes. +- **Masthead:** small gold/black `MI6 / OPTIONS` or `PC OPTIONS` heading, + fine black rule (a restrained `CLASSIFIED` stamp can be added later if + measured text fits) rather than a blue title/instruction block. Keep the + category name on the **same + page above its first setting**, especially INPUT over Mouse sensitivity. +- **Root:** numbered `1. Input` ... `5. Video`, GE's dossier/menu numbering + and title case as on `frontoptions.c`; a narrow translucent dark selection + box behind the focused item. Category order and labels stay unchanged in + the shared row table; casing is presentation-only. +- **Section:** a compact `PREVIOUS`/Back tab, title and optional + `(Profile N)` annotation, then two columns: label at left, value at right. + Unselected values in black, active/toggled values in GE's dark red + (`0xA00000FF` is the cheat-screen reference). Slider tracks are thin + charcoal with a red fill and an obvious knob; show the numerical value + outside the bar. A mixed-scope `(per profile)` marker must remain legible + without colliding with the value column; measure it and, if necessary, + use a small second line rather than silently removing the distinction. +- **Actions:** Reset retains its two-press/three-second confirmation and + displays `CONFIRM` in red only while armed. Quit remains visually distinct + from resets (and retains its existing activation semantics). Back/Close + affordances should match the actual click targets, not become decoration. +- **Footer:** one quiet, short line for mouse/arrows/controller, with an + explicit `ESC: Back / F10: Close` cue. No newly invented glyphs or fonts + that may be missing from the game's text bank. FPS readout is independent. + +Suggested layout sketch (not pixel positions): + +``` + MI6 / PC OPTIONS CLOSE + ----------------------------------------------- + PREVIOUS GRAPHICS + Anti-aliasing 2x + Texture filter Bilinear + Draw distance [------|----] 50/100 + ... + Reset to defaults [ENTER] + ESC Back / F10 Close +``` + +## 3. Implementation boundaries + +1. Extract a port-only layout helper for fonts, palette, + panel rectangles and spacing; preserve `rows[]`, `rowAdjust`, persistence, + file scoping, reset logic and input bindings. Use GE's loaded Zurich Bold + font for body *only after* validating it exists on both stage and front + contexts; Bank Gothic can remain a fallback/header font. Text clipping + must use the whole viewport as `textRender` expects. +2. Replace only the fill/text passes in `optionsOverlayEmit()` first. Emit + fills before text as currently, reset GBI state explicitly and keep + `OV_BUF_CMDS`/DL size bounds. Avoid mutable texture uploads or new assets. +3. One shared geometry calculation must drive panel/card positions, + `maxVisibleRows`, row Y, slider-bar span, close/back hitboxes, and + `overlayRowAtY`. In particular, don't change visual row spacing without + updating hit-testing (D304/D316), and don't use naive OS-window pixel + mapping instead of `gfx_get_ui_screen_rect` (D316/D335). When a page + scrolls, keep its section title visible; a clipped row cannot be clicked. +4. Use measured text widths to reserve space for values, `Confirm`, + `(Profile N)` and restart markers. At 320x240, allow scrolling/short + contextual hints, not overlapping text or tiny glyphs. At 440x330 and + ultrawide, centre or constrain the card rather than stretching the + columns across the entire viewport. +5. Make any GE-like UI sound effects opt-in to a *separate* follow-up: F10 + runs in controller polling, and D361 prohibits game save/audio calls + from that thread. A cosmetic refresh must not reintroduce that lockup. + +## 4. Acceptance gates + +- Compare screenshots with the existing front Options/cheat dossier and the + in-stage watch; user reviews the live palette/layout before closing the task. +- Native 320x240, front-end 440x330, windowed 4:3, 16:9 and 21:9, + fullscreen, safe-area crop on/off: INPUT title visible; no text clipping, + unreadable values, wrong hitboxes, phantom/duplicated bottom rows or + overflow in the display list. Check graphics's longest labels and AUDIO's + per-profile markers with and without a valid file. +- Mouse click/drag/wheel, arrows and D-pad/stick, ESC back, F10/Start/X + close, focused/armed reset, and quit action all behave **exactly** as + before. F10 Audio on file select still commits via D361's game-thread + queue; test both fresh and existing EEPROM without altering the user's + save (backup/restore for probes). +- With overlay closed, append no panel DL and retain existing golden frame + hashes (unless FPS display is explicitly enabled). Run `./build-pc.sh + ntsc-final`, link/duplicate-symbol checks, front-end + stage smoke, + `GE_WSPROBE_F10FRONT` and targeted live playtest. Log the result in + `docs/dev/findings.md` after implementation, not in advance. + +## 5. Explicit non-goals + +No fullscreen 3D folder in gameplay, changes to the N64 watch, new config +keys, row wording/value changes, save-format changes, controller remapping, +new persistent assets, or `src/game` behavioral edits. The front-end PC +Options screen is a **visual reference**, not part of the first styling +patch; harmonizing its tiny decorative details is a later optional pass. diff --git a/docs/dev/V0.4.0-PLAN.md b/docs/dev/V0.4.0-PLAN.md index 19e75a8f..d2c70557 100644 --- a/docs/dev/V0.4.0-PLAN.md +++ b/docs/dev/V0.4.0-PLAN.md @@ -73,6 +73,12 @@ Sequencing follows M-139's R → Q → F order, split into four phases below. | D314/D315 | F10 flicker (user-deprioritized) + main-vs-in-game menu scaling | D315 deferred from v0.3.0; bigger design question — lands naturally with the overlay work. | | D238 | Mouse sensitivity rework (master sens, RMB aim sens, Y-gain parity) | Partly superseded: aim modes folded (D337, N64 default via PD integration; Centred (PC) opt-in), Aim range PC/N64 (D338). Remaining: sensitivity feel tuning. | | D239 | Click-to-lock parity: delete always-grab mode 0 + config key | Small; closes D192 by deletion. | +| GEPD controls preset | New opt-in key-layout option ("Controls: FPS (default) / GEPD") reproducing the 1964 GEPD scheme: Q=A (accept/next-weapon), E=Use (B), **R=dedicated reload**, **Ctrl=crouch** (hold or toggle mode); M1=fire / M2=aim / wheel=weapon-cycle / arrows=stick all already exist. Implementation = D214 bind-string presets (`Input.Layout` enum writing preset strings into `g_bindStr`, per-user ini override preserved) + two new port-layer actions (IA_RELOAD emits **B** — reload is the B button in GE: `lv.c:796` `bond_pressed_reload_activate()` → `attempt_reload_item_in_hand()`, so a dedicated key needs no ROM hack and zero game-code changes; IA_CROUCH emits B held — crouch is B, same as the pad layout `input.c:1477–1481` — or a latched toggle). **Wrinkle (decided 2026-09-27): the preset leaves LeanLeft unbound** (its default `Q`, `input.c:885`, would otherwise make GEPD's Q=Action emit A+L; 1964 has no lean key, so the faithful layout drops it — rebindable via per-key override; FPS layout keeps Q). Small, `input.c` + overlay row, OFF default. See `GEPORT-REFERENCE-DEEPDIVE.md` §7 and `MODERN-OPTIONS-PLAN.md` (M3). | +| Modern options wave (parity set, 2026-09-27) | One opt-in "Modern" section in the F10/PC options page (`GE.` ini namespace, unknown keys ignored, all rows **default-off / N64-original**): (a) **crosshair on/off + color** (N64 default: always-on white; tint/hide at the HUD draw site); (b) **low-end perf preset** (FpsCap 30 / AA off / render-scale — the D339/#92 follow-up); (c) **GE+ gameplay toggles, clean subset**: disable knockback, disable + hitstun, damage sound, endless death cam (damage flash already shipped + as D232 `Game.NoHitFlash`) — each a small `#ifdef PORT` hook point; + rule-2 flag per feature, port-side default OFF; see `GEPORT-REFERENCE-DEEPDIVE.md` + §2.1 for the full upstream list); (d) **AllUnlocked preservation proof**: verify untoggling keeps progress (D257 read-time patch design should already guarantee it — evidence, not code). (F12 screenshot → PNG dropped from this wave 2026-09-27 — the dev PPM dump stays, the PNG upgrade is parked.) Total ≈ 2 sessions; lands in Phase 3 as one wave. | | D226 | Player-adjustable HUD scale (ammo counter, pickup text, dialogue) | **DONE 2026-09-24 (`30720354`)**: Game.HudScale 75–150% (user-verified), fast3d rect scaling about anchors, dialogue/bottom width-capped. | | Rumble (D224) | Wire `osMotor*` → `SDL_GameControllerRumble` (PD precedent exists) | Deprioritized M-87; pick up if gamepad support is in scope for v0.4.0 — user call. | | Per-pad tuning / hot-plug | PD incremental hot-plug + remembered seats; deadzone/scale/rumble per pad | Medium; real-controller users. | @@ -190,15 +196,25 @@ sweep after each landing). 5. Long-idle soak test (both platforms) as the stability acceptance bar. ### Phase 3 — Improvements / QoL (only after Phases 1–2 are green) -Order by user-visible value: -1. **Native widescreen** (letterbox/center instead of stretch) — the biggest - remaining "feels wrong" item; decide the CenterHUD G-class sign-off first. -2. **F10 overlay wave: D237 (wheel + categories + more settings) + D314/D315.** +Order by user-visible value (2026-09-27 refresh — several rows since DONE, marked): +1. ~~**Native widescreen**~~ — **DONE 2026-09-23** (D334/D335; playtest sign-off owed). +2. ~~**F10 overlay wave: D237 + D314/D315**~~ — D237 **DONE 2026-09-23**; D314/D315 still open (user-deprioritized; lands with further overlay work). 3. **Input feel: D238 sensitivity rework, D239 delete always-grab, window HiDpi/center; rumble + per-pad tuning if gamepad is in scope (user call).** -4. **D226 HUD scale** (F10 slider candidate alongside FovScale). +4. ~~**D226 HUD scale**~~ — **DONE 2026-09-24**. 5. Unlocked FPS: re-measure Phase 0 against the post-D248/D250 baseline and decide whether v0.4.0 or v0.5.0. +6. **Modern options wave** (Bucket C "parity set": crosshair on/off+color, + low-end perf preset, GE+ gameplay toggles subset, AllUnlocked + preservation proof, GEPD key-layout preset — F12 screenshot dropped + 2026-09-27) — one opt-in + section, all rows default-off/N64-original. ≈ 2 sessions total (damage + flash was already D232; F12 screenshot dropped 2026-09-27); the + user-facing answer to "does it have the modern options". Per-item + hook points, effort, and rule-2 sign-off list: local + `MODERN-OPTIONS-PLAN.md` (M1–M7, M6 parked; zero sign-off: M1/M3/M5/M7, + one each: M2 + M4's four toggles — M4.3 is the shipped D232 NoHitFlash — + batched review). ### Phase 4 — Release ritual 1. `./build-pc.sh ntsc-final` clean; `/linkcheck`; full 21-level @@ -457,9 +473,18 @@ Checklist beyond the normal play: you see it, then share the `D303:` log lines from around that moment — each line carries the RDP state of a candidate quad and will identify the spurious draw call's source. +18. **AllUnlocked preservation (M7)**: with the new tree — enable + AllUnlocked, play two missions, toggle it OFF in the Watch options, + continue a third mission: objectives/saves must persist (D257 + read-time patch design; evidence for the findings entry — failure is + its own D3xx). Also the modern-toggles A/Bs when M2/M4 land (see + `MODERN-OPTIONS-PLAN.md` per-item verification). ## Conventions reminder -- One findings entry per investigation; next labels after **D322**. +- One findings entry per investigation; the index tail moves constantly — + it ended at **D370** as of 2026-09-27 (the F10 drag entry), so new + labels start **D371+** (the old "after D322" note is long stale; always + confirm the tail of `findings-index.csv` before filing). - Fidelity fixes: port-layer or documented A1/ABI exception only; genuine behavioral differences need the rule-2 sign-off (`docs/dev-process.md`). - Every subagent reads `docs/porting-notes.md` first and appends to it. diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 71da7749..c815ba9e 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -236,3 +236,14 @@ D355,FIXED,F10/options Music slider inaudible in-stage:,"FIXED (port-only, port/ D356,FIXED,"Settings regroup: Turok-style pages renamed to functional sections (INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO) with PD-style ""(File N)"" scoping annotations, the redu…","FIXED (port-only, port/src/optionsoverlay.{h,c} + port/src/watchsettings.{h,c} + port/src/frontoptions.c; plan docs/dev/D356-SETTINGS-REGROUP-PLAN.md):" D358,FIXED,D356 level-1 overlap:,"FIXED (port-only, port/src/frontoptions.c):" D359,PARTIAL,"Settings UI file terminology settled on the end-user term ""Profile"":","LANDED (port-only, frontoptions.c + optionsoverlay.c + comments):" +D360,OPEN,F10 INPUT heading scrolled away; F10 navigation and distance defaults (2026-09-27).,IMPLEMENTED (port-only): +D361,FIXED,Main-menu F10 Audio freezes the game:,"FIXED (port-only, port/src/watchsettings.c + F10 probe in optionsoverlay.c):" +D362,CLOSED,F10 GoldenEye dossier-style visual refresh (2026-09-27).,CLOSED (superseded by D363 after user playtest): +D363,FIXED,F10 return to a dark overlay with the N64 watch's green/Bank Gothic visual language (2026-09-27).,"FIXED (palette/font user-approved, 2026-09-27); D364 follows up on profile tags, click alignment and slider calibration." +D364,FIXED,Settings UI feedback:,FIXED (user mostly accepted; D365 follows up on annotation size and click targets). +D365,OPEN,F10 annotation hierarchy and visible mouse change targets (2026-09-27).,IMPLEMENTED (optionsoverlay.c): +D366,PARTIAL,Input calibration label:,PARTIAL: label changed in both UIs; numeric parity with external GEPD bundle needs its actual settings before changing input defaults. +D367,?,F10 full-width control boxes misaligned with their contents (2026-09-27).,SUPERSEDED by D368 after user playtest: +D368,?,Simplify F10 click targets:,"INPUT RULE KEPT; tinted strips user-rejected, removed in D369." +D369,FIXED,Remove F10 row boxes/strips entirely (2026-09-27).,FIXED (awaiting live acceptance): +D370,PARTIAL,F10 distance/volume drags hurt frame rate (2026-09-27).,PARTIAL (user live test): diff --git a/docs/dev/findings.md b/docs/dev/findings.md index da36d167..1472de2f 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -644,6 +644,17 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D356 | **Settings regroup: Turok-style pages renamed to functional sections (INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO) with PD-style "(File N)" scoping annotations, the redundant N64-watch rows (`Bond.Look`, `Bond.AimControl`) off the menu surface, and a real per-section Reset-to-defaults (two-step arm->confirm, watch rows to BLANKSAVEDATA via the D352 commit path, ini rows to the port's C initializers; `__Resolution` a documented exclusion) replacing the Turok reset rows the PC build never honored (2026-09-27).** — full `## D356` entry at file tail | FIXED (port-only, `port/src/optionsoverlay.{h,c}` + `port/src/watchsettings.{h,c}` + `port/src/frontoptions.c`; plan `docs/dev/D356-SETTINGS-REGROUP-PLAN.md`): 5 sections all below MAX_PROWS, hidden rows D181/D216-style (keys + watch paths stay live), edge-triggered reset with 3 s arm window + maintain/clear disarm, GE_WSPROBE_RESET front + in-stage probes headless-verified (all sections failures=0, scope isolation OK, saved bytes 0xFF), GE_WSPROBE/GE_WSPROBE_FRONT regressions clean. | | D358 | **D356 level-1 overlap: the front options save-file row (item 0) painted under the first content row, and `s_hl` (item-numbered) was indexed straight into `s_rowIdx` (content-numbered) -- wrong-row adjusts, a dead last row, a one-past read, wrong highlight (2026-09-27).** — full `## D358` entry at file tail | FIXED (port-only, `port/src/frontoptions.c`): content rows draw at `rowY(k+1)`; all `s_hl` -> `s_rowIdx` uses take `s_hl-1`; A-press guards `s_hl >= 1 && s_hl <= s_rowN`; hit-test/cursor untouched (already item-numbered). Companion: "(save)" tag -> "(profile)". | | D359 | **Settings UI file terminology settled on the end-user term "Profile": the screen mixed five terms ("Save file"/"File N"/"(profile)"/"Select file"/"(no file)"), and the manual's "game file" reads as the program's own files in modern PC usage (2026-09-27).** — full `## D359` entry at file tail | LANDED (port-only, `frontoptions.c` + `optionsoverlay.c` + comments): top row `Profile`/`N`, annotation `(Profile N)`/`(none)`, per-file row tag `(per profile)`, F10 value `Select a profile`; identifiers/ini keys unchanged (display-layer only). Manual "game file" -> "profile" sync TODO (port-owned doc, not in repo). | +| D360 | **F10 INPUT heading scrolled away; F10 navigation and distance defaults (2026-09-27).** — full `## D360` entry at file tail | IMPLEMENTED (port-only): F10 root category list + per-section Back; distance sliders normalized to 0–100, default 50/100 (250% authored distance), 2x MSAA default. Compiled and linked to a separate test binary (live game locked main executable); manual playtest owed. | +| D361 | **Main-menu F10 Audio freezes the game: EEPROM save from joyPoll deadlocks waiting for its own poll ack (2026-09-27).** — full `## D361` entry at file tail | FIXED (port-only, `port/src/watchsettings.c` + F10 probe in `optionsoverlay.c`): queue front F10 save edits to game thread; actual poll-thread Music+FX commits tested with existing and fresh EEPROM, orderly quit, user's ini/save restored. | +| D362 | **F10 GoldenEye dossier-style visual refresh (2026-09-27).** — full `## D362` entry at file tail | CLOSED (superseded by D363 after user playtest): paper/Zurich/gold look rejected. The pinned section heading and shared layout/hit geometry remain. | +| D363 | **F10 return to a dark overlay with the N64 watch's green/Bank Gothic visual language (2026-09-27).** — full `## D363` entry at file tail | FIXED (palette/font user-approved, 2026-09-27); D364 follows up on profile tags, click alignment and slider calibration. | +| D364 | **Settings UI feedback: replace P legend; F10 slider click band misses drawn bar; input defaults visually off-centre (2026-09-27).** — full `## D364` entry at file tail | FIXED (user mostly accepted; D365 follows up on annotation size and click targets). | +| D365 | **F10 annotation hierarchy and visible mouse change targets (2026-09-27).** — full `## D365` entry at file tail | IMPLEMENTED (`optionsoverlay.c`): dim Bank Gothic profile annotation (not oversized Zurich); `CONTROLS:` Zurich heading, smaller Bank Gothic instructions; boxed right-hand controls + hover and matching bounded click x. NTSC build/link/stage capture pass; live click assessment owed. | +| D366 | **Input calibration label: call 50 the default, not original (2026-09-27).** — full `## D366` entry at file tail | PARTIAL: label changed in both UIs; numeric parity with external GEPD bundle needs its actual settings before changing input defaults. Built, stage tested in D367; user playtest owed. | +| D367 | **F10 full-width control boxes misaligned with their contents (2026-09-27).** — full `## D367` entry at file tail | SUPERSEDED by D368 after user playtest: per-value boxes remained confusing. | +| D368 | **Simplify F10 click targets: setting name selects, the rest changes (2026-09-27).** — full `## D368` entry at file tail | INPUT RULE KEPT; tinted strips user-rejected, removed in D369. | +| D369 | **Remove F10 row boxes/strips entirely (2026-09-27).** — full `## D369` entry at file tail | FIXED (awaiting live acceptance): no row backgrounds, control rectangles or hover paint; selected setting distinguished by brighter text, slider tracks remain. Input rule from D368 unchanged. | +| D370 | **F10 distance/volume drags hurt frame rate (2026-09-27).** — full `## D370` entry at file tail | PARTIAL (user live test): mostly improved; rapid spam can still lower FPS. Unchanged-detent/audio and Draw/LOD reapply fixed; other image sliders still request full video pass (live log shows frequent FOV passes). No further churn without focused repro. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software @@ -13997,3 +14008,304 @@ display-layer concern only (principle 3). **TODO:** update the instruction manual's "game file" to "profile" (port-owned doc, not in this repo). + +## D360 — F10 category navigation and modern-display distance defaults (2026-09-27) + +**Reported:** F10's INPUT header was offscreen above Mouse sensitivity when +scrolling; the long flat list was hard to navigate. Draw/LOD distance auto +switches obscured the controls and N64-scale pop-in looked like a port bug. +4x default MSAA was too expensive for low-end hardware. + +**Fix (port-only):** F10 opens on a five-category list, and entering a +category shows its header (with Back) and settings together. Keyboard, +controller, mouse and wheel all use the same per-page list. ESC backs out of +a section, then closes from the category list; F10/Start/X still close. +Front-end PC Options already had category pages and shares the row table. +Removed AutoFov controls from both menus (ini keys remain for legacy files), +and exposed both distance sliders at all times. Stored values remain percent +of authored distance; the UI displays 0–100, where 50/100 maps to 250%. +Fresh and SteamOS defaults are 250% draw and LOD distance, 2x MSAA. Reset +uses the same new defaults. Existing ini settings are preserved; old LOD +values below the menu minimum (100%) can still be edited via ini. Note: +these higher distances increase visible geometry/pool demand; test on +open levels and low-end GPUs before claiming performance parity. + +**Verification:** `git diff --check` clean. `build-pc.sh ntsc-final` +initially failed because GCC could not create temporary files in +`C:\Windows\`; configuring C/C++ with `-pipe` built all 248 objects. +The existing executable was locked by a running `ge007.x86_64` process, +so the same linker response file was linked to +`scratch/cc-tmp/ge007-check.exe` successfully. No live game was interrupted. +Manual mouse/pad/ESC, reset, and 50/100 rendering/perf checks are owed. + +## D361 — F10 Audio on the main menu locks up: self-deadlock in EEPROM poll gate (2026-09-27) + +**User repro:** fresh v0.4.0 playtest, change an AUDIO slider from F10 +on the main menu → game locks (not merely an inaudible volume change). + +**Exact chain:** F10 input is processed inside `inputComputePad()` called +by `osContStartReadData()` in `joyPoll()` (`src/joy.c:500`). D354 routed +front-context watch edits through `watchSettingsSet()` → `saveFrontField()` +on that same poll thread. Both `fileGamePakProbe()` and `fileWriteSave()` call +`joyGamePakProbe/LongWrite()` → `joyDisablePoll()` → blocking receive on +`g_ContDisablePollReceiveMessageQueue`. Only `joyPoll()` can send the +acknowledgement (`src/joy.c:434`), but it is itself blocked inside the +F10 edit: deterministic self-deadlock. D354's claim that one settings +surface meant this thread's writes were safe was incorrect; serialization +between editors does not make the controller-poll handshake re-entrant. + +**Fix (port-only):** front F10 writes now coalesce in a small locked +folder/field queue. `watchSettingsGameTick()` drains commits on the game +thread before its non-stage early return and runs `saveFrontField()` there, +with no lock held across EEPROM I/O. The on-screen value is staged before +enqueue for immediate feedback, then cleared only after the matching commit. +The separate stage queue and the direct front options screen path retain +their prior behavior. `GE_WSPROBE_F10FRONT=1` is an opt-in regression probe +called from F10's actual controller-poll input path at file select (once, +after a valid save is available), committing Music+FX to 4096. + +**Verified:** NTSC incremental build/link succeeded. Native Windows test +with `GE_OPTIONSOVERLAY=1`, `Game.SkipIntro=1` and the poll-thread probe +logged the queue plus both Music/FX field persist events and exited via +`GE_QUITFRAME=700` (with existing EEPROM **and** absent EEPROM on boot). +The pre-existing game-thread `GE_WSPROBE_FRONT=120` front-path regression +also persisted Music 255→32 and quit normally. The user's ini and EEPROM +were copied aside and restored byte-identically after each run. Manual +mouse slider/section-reset and stage transition checks remain owed. + +## D362 — F10 GoldenEye dossier-style visual refresh (2026-09-27) + +**Request:** restyle F10 to match GoldenEye, following +`docs/dev/F10-GE-STYLE-PLAN.md`. Existing front Options already uses the +in-game dossier; in-stage F10 needs a lighter port-owned 2D equivalent. + +**Implementation (port-only):** replace the blue/black panel with a dark +scene scrim and warm paper card/shadow/rules, Zurich Bold menu ink (Bank +Gothic fallback during early font loading), MI6 gold masthead, dark-red +active values and slider fills/knobs, numbered GE-style category labels, +faint black selected-row highlights. The active section's Back/title/Profile +band stays pinned while content scrolls, so INPUT never disappears above +Mouse sensitivity. A short `P` gutter marker and footer legend retain +per-profile scoping without the old `(per profile)` label/value collision +at 320px. One `OvLayout` drives render, max rows, slider/drag coordinates, +mouse row-hit bands and close box. No config, input binding, save format, +N64 game-code, or F10 audio queue changes. `GE_OPTIONSOVERLAY=2..6` +optionally auto-opens a particular section for deterministic screenshots +(`=1` remains the category root). + +**Verification:** NTSC compile/link clean; native-Windows `GE_PCDUMP` +front-end root and GRAPHICS capture plus 320x240 stage GRAPHICS capture +(no crash/overflow; orderly `GE_QUITFRAME` exit). Captures: +`scratch/d362-test/` (local, not distributed). User ini and EEPROM restored +byte-identically after each run. A final tiny title/footer polish rebuild +compiled, but the running playtest held `build-pc/ge007.x86_64.exe` open; +linked identical objects to `scratch/d362-test/ge007-d362.exe`, and the +local `scratch/playtest_v040.bat` installed that binary on the next +closed-game launch (guards against a concurrent game process; installed exe +size/timestamp match the staged build). Live human +review of palette, label fit, mouse clicks/drag/wheel, controller focus, +back/close, longest GRAPHICS labels and the F10 Audio regression is owed. +**User follow-up:** dossier/paper styling rejected; see D363 for the dark, +watch-green successor. D362 is retained as the design/iteration record. + +## D363 — F10 returns to a dark overlay, restyled after GE's green watch (2026-09-27) + +**User feedback on D362:** "make it more like how it was but use the watch +style as a reference — green, that other font etc." The watch's actual +options pages (`src/game/options.c:3524+`, `:3825+`) use Bank Gothic and +mint/bright green ink (`0xA0FFA0F0` selected, muted green unselected). +The paper card, Zurich Bold, gold, red, numbered categories and dossier +branding from D362 were not the desired direction. + +**Fix (port-only):** restore the original F10's simple dark translucent +panel and full-screen dim; use the existing Bank Gothic font for all rows, +subdued green for ordinary text and bright watch green for selection, +active values and slider knobs/fill. The `PC OPTIONS` title and close `X` +replace MI6/CLOSE; category names return to their uppercase row labels. +Keep D360's two-level navigation and D362's shared layout/hit geometry, +scrolling and pinned section heading; no setting behavior or save changes. +The mixed-scope profile marker stays short to avoid 320px overlap. + +**Verification:** NTSC build/link clean. `GE_OPTIONSOVERLAY=4`, +`GE_PCDUMP=160-160` with native Windows `GE_QUITFRAME=170` passed in both +front/menu and `-level_09` stage contexts at 640x480: dark panel and green +pixels present (front/stage captures in `scratch/d363-test/`), no errors, +orderly exit. User ini and EEPROM restored byte-identically after both. +Live review of colour, text, click/drag/wheel, controller, ESC/close and +F10 Audio remains owed. +**User follow-up:** palette/font approved, but P shorthand, low-contrast +legend, slider mouse hit position and off-centre input defaults need work; +see D364. + +## D364 — Profile scope, F10 slider hitbox and calibrated input displays (2026-09-27) + +**Reported:** user likes D363's palette but not `P` / `P = PROFILE`; +controls legend reads like setting text; clicking the visible F10 slider +requires aiming above the bar; Input defaults such as deadzone and trigger +show ~23% rather than a visually centred 50/100 or 5/10. + +**Diagnosis:** the F10 bar was at `rowY + 10..12` (knob `+8..14`), but +`overlayRowAtY` assigns the row only within `rowY - 8..+8`: the bar/knob +landed in the NEXT row's hit band. The input values were physically correct +(raw `MouseSensitivity=100` of 1..500; `PadDeadzone=7000` of 0..30000, +~21% full-stick travel; `PadTriggerPct=23` of 1..99), but the UI treated +them as linear full-range sliders rather than calibrated defaults. AUDIO's +`Bond.Music`/`Bond.FX` rows were watch-backed but lacked `.saveScoped`, so +the promised `(Profile N)` title was missing despite every Audio row being +per-profile. + +**Fix (port-only):** move F10 slider bar to `rowY+3..5` and knob to +`rowY+1..7`, both entirely inside the row's `-8..+8` click band. The one +shared slider-bar geometry still drives dragging. Give the control legend +its own dark inset strip with muted-grey Zurich text (Bank Gothic remains +for SETTINGS); likewise use a separate font/colour for scope notes. Remove +`P`/legend. Tag each saved Gameplay toggle `(profile)` beside its label +when measured space permits; Audio is entirely per-profile, so its title +now carries `(Profile N)` (both UIs) and no per-row tags. The front dossier +also suppresses redundant inline Audio tags. Input's three calibrated +sliders now display 0–100, mapping each *unchanged* raw default to 50/100 +via a two-segment monotonic map preserving raw minimum and maximum; bars, +mouse drags and the shared front options screen use that same map. A +`(50 = original)` note explains the scale. Other sliders (audio %, texture +filter x, FOV/HUD %) keep their physical units and authored defaults. +Reset still writes the original raw defaults, config/ini values are unchanged, +and input sensitivity/deadzone/trigger behavior is untouched. + +**Verification:** NTSC build/link clean. Native-Windows `GE_OPTIONSOVERLAY=2` ++ `-level_09` stage screenshot at 640x480 and orderly `GE_QUITFRAME=180` +pass, with the user ini/EEPROM restored byte-identically after the run. +Mouse click/drag on real 4:3/16:9 front/stage, reset and visible tooltip +fit still need an interactive user pass. User accepted most of D364 but +requested smaller profile annotation, a `Controls:` heading with smaller +instructions, and a visible mouse-change target; see D365. + +## D365 — F10 annotations and bounded mouse-change controls (2026-09-27) + +**Reported:** `(profile)` is visibly larger than its setting label; the +Controls legend should read `Controls:` with smaller following instructions; +it isn't visually clear that a label click *selects* but the right column +*changes* a setting. + +**Fix:** Bank Gothic (the smaller watch font already used for row labels) +now renders dim profile tags and section scope notes instead of the larger +Zurich Bold. The footer's `CONTROLS:` heading keeps Zurich Bold, but its +instructions use smaller, dim Bank Gothic, including `BOX: CHANGE` within +sections. Draw subtle green-bordered dark control boxes around every section +row's right-hand interactive area, brightening the hovered box without +moving keyboard selection. Restrict left/right mouse changes to the same +bounded x region; label clicks still select only. Coordinates are mapped +through `gfx_get_ui_screen_rect` for hover as for input, preserving the +safe-area crop behavior (D316). No game logic or saved data touched. + +**Verification:** NTSC compile/link and 640x480 `GE_OPTIONSOVERLAY=2` +`-level_09` frame-160 capture with clean frame-180 quit; ini and EEPROM +restored byte-identically after testing. During compilation the user's +existing game process held the exe, so it was first linked to a staged +scratch path rather than overwritten; final build linked directly after +that process exited. Mouse hover/click in a live window is still owed. + +## D366 — Input slider calibration: “default,” not “original” (2026-09-27) + +**Request:** user prefers the GEPD bundle as the standard for input and +wants the `(50 = original)` note to read `(50 = default)`. + +**Fix:** changed both settings UIs' Input-header text to `(50 = default)`. +`calibratedDefault()` still maps the port's current *real* defaults +(`MouseSensitivity=100`, `PadDeadzone=7000`, `PadTriggerPct=23`) to 50/100, +and `kResetDefaults` agrees with `input.c`. No live config or save edited. +**Caveat:** GEPD's emulator bundle is an external reference; this checkout +has a stub in `reference/mouse-injector/` and its `GEPD-INPUT-PLAN.md` records +its camera aim formulas, but neither has an authoritative bundle controller +config for all three numbers. The port's 38/50 per-mode mouse speeds were +user-calibrated for its own FOV. Don't claim numeric parity with the GEPD +bundle or re-tune default input feel without the user's bundle configuration. + +**Verification:** NTSC compile and staged link succeed. The active user's +playtest held `build-pc/ge007.x86_64.exe`, so the new binary was linked to +`scratch/d366-test/` and the local v040 launcher installs it only after the +running game exits. No stage smoke test while the user's save is active. The user later exited; +D367 includes the subsequent native stage smoke test of both changes. + +## D367 — F10 control frames aligned with their actual clickable contents (2026-09-27) + +**Reported:** D365's full-width rectangles do not align with their text +contents. Root cause: every row used a fixed `[barX0, right-7)` box despite +right-aligned toggles/enum text near `right-12`, slider bars ending much +earlier at `barX1`, and reset actions having their button in the *label* +column (empty value until armed). This advertised empty space as clickable +and left the reset's real target unframed. + +**Fix:** `overlayControlSpan()` now calculates the actual button region per +row: tight around slider tracks, around measured right-aligned values, or +around the reset label; non-interactive/unavailable slider rows get no box. +Both mouse buttons and hover use that same geometry, so moving the mouse +only highlights a genuinely clickable area. The narrower border fits the +Bank Gothic text's vertical footprint. Label clicks on ordinary settings +still only select; clicking the framed reset label invokes its existing +two-step confirmation. No game logic or config/default value changes. + +**Verification:** NTSC compile/link passed, native 640x480 +`GE_OPTIONSOVERLAY=2 -level_09` stage capture and orderly frame-180 exit; +ini/EEPROM restored byte-for-byte. Mouse click/hover and confirmation remain +to be feel-checked live. User's immediate playtest found remaining boxes +wrong; replaced by the simpler D368 rule. + +## D368 — F10: name selects, right-of-name changes (2026-09-27) + +**User decision:** stop trying to frame each differently sized value; all +of the row except the setting name should change it. D367's boxed controls +were still misleading in a live test. + +**Fix (port-only):** `overlayControlSpan` now starts after the measured +Bank Gothic setting name (capped at its label-column clipping edge) and +extends to the panel's right edge. It is used by mouse hover, left/right +click and a single subtly tinted strip with a thin left divider; the +per-value borders are gone. Clicking a name still focuses without changing. +A slider click on its track positions/drags; clicking elsewhere to the right +of its name changes it by one step instead of snapping to the minimum or +maximum. Reset is an action, so its whole row activates the existing +confirmation. The footer now says `RIGHT: CHANGE`. No config/save or game +logic changes. NTSC build/link passes; live mouse check still owed. + +## D369 — Remove F10 per-row paint (2026-09-27) + +User still saw boxes in D368 because the tinted per-row change strips and +selected-row rectangles were still painted. Removed **all** of those from +`optionsOverlayEmit()` (including hover paint). Selected text remains +brighter, sliders retain only their track and knob, and the existing dark +panel/Controls footer remain. Mouse hit behavior from D368 is unchanged. +NTSC compile and staged link pass; a running playtest still holds the old +exe, so the local launcher will install `scratch/d369-test/ge007-d369.exe` +only after the game exits. No save/ini touched. + +## D370 — Avoid redundant work during F10 slider drags (2026-09-27) + +**Reported:** dragging Draw/LOD distance, Music or FX volume drops FPS. +**Diagnosis:** `sliderSetFromX` runs on every controller poll while LMB is +held, including repeated polls over the *same snapped value*. `rowSetCommit` +then re-enqueues watch audio updates and, for *any* `Video.*` key, requests a +`videoStartFrame` live-config pass: `set_swap_interval`, FPS cap, texture +filter, image state and a log line. Draw/LOD multipliers are already read +straight from their config pointers by the render path, so no such pass is +needed. The active playtest log has hundreds of `video: live config applied` +lines and multiple watch-setting persists; log alone does not isolate all +of those calls to one knob. Physically increasing Draw/LOD can independently +increase rendering load, which this fix will not hide. + +**Fix (port-only `optionsoverlay.c`):** early-return before live-apply/queue +when a snapped value has not changed (including queued watch settings); held +mouse polls no longer repeat the same audio setter or GL state work. Retain +slider-release `watchSettingsCommit` so a drag persists exactly once. Exempt +only `Video.DrawDistance` and `Video.LodDistance` from +`videoRequestLiveConfig`; all other Video keys retain their old live-apply +semantics. Audio updates still preview live for genuinely changed detents. +No user config/save or game code edited. NTSC compile + staged link passed; +active Windows game initially held the executable; the local launcher +installed the staged binary on the user's next safe launch (now running). +Live FPS retest (ideally compare dragging +Music/FX while paused vs simply raising Draw/LOD values) owed. +**User playtest:** "it can still drop it down if you spam it but mostly good." +Residual not isolated: the latest playtest log shows repeated full video +config passes while changing FOV (not Draw/LOD) and several watch EEPROM +persists; sustained high Draw/LOD also increases actual GPU work. Parked as +polish until a focused repro identifies which slider/spam pattern dominates. diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h index db393b60..a094f4b1 100644 --- a/port/include/optionsoverlay.h +++ b/port/include/optionsoverlay.h @@ -35,6 +35,8 @@ extern "C" { /* Toggle open/closed. On the closing edge the config is saved. */ void optionsOverlayToggle(void); +/* ESC: back to categories, or close from the category list (host-thread request). */ +void optionsOverlayBack(void); /* 1 while the overlay is on screen. */ int optionsOverlayIsOpen(void); diff --git a/port/src/frontoptions.c b/port/src/frontoptions.c index e233f3a3..3f25d3ec 100644 --- a/port/src/frontoptions.c +++ b/port/src/frontoptions.c @@ -498,6 +498,8 @@ Gfx *frontOptionsMenuDraw(Gfx *DL) if (f < 0) snprintf(note, sizeof(note), "(none)"); else snprintf(note, sizeof(note), "(Profile %d)", f + 1); DL = ink(DL, ROW_X + titleW + 8, TITLE_Y, note, INK_DIM); + } else if (strcmp(optionsRowLabel(s_pageHdr[s_page]), "INPUT") == 0) { + DL = ink(DL, ROW_X + titleW + 8, TITLE_Y, "(50 = default)", INK_DIM); } /* D356: the top profile row (item 0, not in the row table; the label @@ -534,10 +536,11 @@ Gfx *frontOptionsMenuDraw(Gfx *DL) } DL = ink(DL, ROW_X, y, label, INK); - /* D357: per-file rows in the mixed-scope section carry a dim - * "(per profile)" tag -- "stored separately in each profile"; - * the section title says which one is active. */ - if (optionsRowIsSaveScoped(i)) { + /* Only GAMEPLAY mixes ini and profile options. AUDIO is wholly + * profile-scoped and already has (Profile N) in its title; an + * inline tag there would collide with the volume slider. */ + if (optionsRowIsSaveScoped(i) && + strcmp(optionsRowLabel(s_pageHdr[s_page]), "GAMEPLAY") == 0) { DL = ink(DL, ROW_X + measureW(label) + 4, y, "(per profile)\n", INK_DIM); } diff --git a/port/src/main.c b/port/src/main.c index 93c598da..b35e596e 100644 --- a/port/src/main.c +++ b/port/src/main.c @@ -121,7 +121,7 @@ int main(int argc, char **argv) } /* 1. Platform + config + filesystem. Steam Deck / SteamOS: seed the - * first-run preset (native 1280x800 fullscreen, MSAA 4, longer draw/LOD + * first-run preset (native 1280x800 fullscreen, MSAA 2, midpoint draw/LOD * distances) before the load so a missing ini saves these values; an * existing ini always wins. */ if (getenv("STEAMOS")) { diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 5d975f13..c4abc349 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -8,9 +8,9 @@ * * The panel adapts to whatever 2D space it is drawn in (320x240 in-game vs * 440x330 on front-end screens -- viSetXY differs) and scrolls when the row - * list outgrows the viewport (wheel / arrows at the edges). Cyclic rows - * (MSAA, texture filter, resolution, toggles) wrap in both directions; the - * manual % rows (draw/LOD distance) are hidden while their "auto" toggle is on. + * list outgrows the viewport (wheel / arrows at the edges). F10 opens at a + * category list; each category has its own short page and Back row. Cyclic + * rows (MSAA, texture filter, resolution, toggles) wrap in both directions. * * Text + fill helpers are the game's own (textRender / microcode_constructor / * gDPFillRectangle) reached by extern -- same pattern input.c uses to read @@ -66,6 +66,8 @@ struct font; struct fontchar; extern struct font *ptrFontBankGothic; extern struct fontchar *ptrFontBankGothicChars; +extern struct font *ptrFontZurichBold; +extern struct fontchar *ptrFontZurichBoldChars; extern Gfx *microcode_constructor(Gfx *gdl); extern Gfx *textRender(Gfx *gdl, s32 *x, s32 *y, char *text, struct fontchar *chars, struct font *font, u32 colour, s32 width, s32 height, @@ -134,8 +136,8 @@ struct Row { void *ptr; double cfgMin, cfgMax; - /* Hidden while the option named here is nonzero (a manual % row - * disappears while its "auto" toggle is on). Resolved to hidePtr at init. */ + /* Optional conditional row visibility (e.g. Aim range while centred). + * Resolved to hidePtr at init. */ const char *hiddenIfOn; int *hidePtr; @@ -167,7 +169,7 @@ static struct Row rows[] = { * watchSettingsActiveFolder(). All rows use designated initializers * (D351 class). */ { .key="__HdrInput", .label="INPUT", .kind=ROW_HEADER }, - { .key="Input.MouseSensitivity", .label="Mouse sensitivity", .kind=ROW_SLIDER, .step=5 }, + { .key="Input.MouseSensitivity", .label="Mouse sensitivity", .kind=ROW_SLIDER, .step=5 }, /* calibrated UI midpoint = raw 100 */ { .key="Input.MouseInvertY", .label="Invert look (mouse)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, /* Input.PdMouseAim (findings D332): the Perfect Dark port's mouse-aim * model. The port only accumulates the mouse; the game's own crosshair @@ -186,8 +188,8 @@ static struct Row rows[] = { * style is CENTRED (PC), where the crosshair doesn't travel. */ { .key="Input.AimRange", .label="Aim range", .kind=ROW_ENUM, .step=1, .names=kAimRange, .hiddenIfOn="Input.AimMode" }, { .key="Input.PadLookInvertY", .label="Invert look (controller)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="Input.PadDeadzone", .label="Stick deadzone", .kind=ROW_SLIDER, .step=500, .unit="%", .dispDiv=300 }, - { .key="Input.PadTriggerPct", .label="Trigger threshold", .kind=ROW_SLIDER, .step=1, .unit="%" }, + { .key="Input.PadDeadzone", .label="Stick deadzone", .kind=ROW_SLIDER, .step=500 }, + { .key="Input.PadTriggerPct", .label="Trigger threshold", .kind=ROW_SLIDER, .step=1 }, { .key="__ResetInput", .label="Reset to defaults", .kind=ROW_ACTION }, { .key="__HdrGameplay", .label="GAMEPLAY", .kind=ROW_HEADER }, @@ -247,10 +249,10 @@ static struct Row rows[] = { { .key="Video.NativeWidescreen", .label="Native widescreen", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, { .key="Video.WidescreenAuto", .label="Widescreen auto FOV", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, { .key="Video.SafeAreaCrop", .label="Crop overscan", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="Video.DrawDistance", .label="Draw distance", .kind=ROW_SLIDER, .step=25, .hiddenIfOn="Video.DrawDistanceAutoFov", .unit="%" }, - { .key="Video.DrawDistanceAutoFov", .label="Draw dist. follows FOV", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="Video.LodDistance", .label="LOD distance", .kind=ROW_SLIDER, .step=25, .hiddenIfOn="Video.LodDistanceAutoFov", .unit="%" }, - { .key="Video.LodDistanceAutoFov", .label="LOD dist. follows FOV", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + /* 100..400% of authored distance; 250% is the midpoint (50/100). + * Legacy AutoFov ini keys remain supported but no longer hide sliders. */ + { .key="Video.DrawDistance", .label="Draw distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=400 }, + { .key="Video.LodDistance", .label="LOD distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=400 }, /* D304: the per-mode aim/turn-sensitivity sliders and the "Link" toggle * that papered over their decoupling risk were pulled from the menu * (user feedback after the D304 widening); the ONE master @@ -275,9 +277,9 @@ static struct Row rows[] = { * A port-level master volume (M3) will join here as Turok's * Master/Sound/Music trio. */ { .key="Bond.Music", .label="Music volume", .kind=ROW_SLIDER, .step=128, - .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328 }, + .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328, .saveScoped=1 }, { .key="Bond.FX", .label="FX volume", .kind=ROW_SLIDER, .step=128, - .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328 }, + .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328, .saveScoped=1 }, { .key="__ResetAudio", .label="Reset to defaults", .kind=ROW_ACTION }, /* D353's DISPLAY section renamed VIDEO (the presentation knobs), with @@ -300,20 +302,19 @@ static struct Row rows[] = { static int s_inited = 0; static volatile int s_open = 0; static int s_sel = 0; /* selection, index into s_visIdx (visible list) */ +static int s_section = -1; /* -1 = category list; otherwise rows[] header index */ +static SDL_atomic_t s_backPending; /* ESC is received on the host thread */ -/* Visible-row list: rows whose hiddenIfOn option is nonzero are omitted - * (manual % rows hide while their auto toggle is on). Rebuilt every frame in - * overlayUpdateVisible(); s_scroll is the first visible-list entry drawn. */ +/* Visible-row list: category headers on the root page, or the active + * section's Back header and content (including conditional rows). Built + * on the scheduler thread; s_scroll is the first displayed entry. */ static int s_visIdx[NUM_ROWS]; static int s_visN = 0; static int s_scroll = 0; static SDL_atomic_t s_wheelPending; /* D314: host-thread wheel notches */ -/* 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. */ +/* D213: optional on-screen FPS readout (PD parity: Video.DisplayFPS). + * Drawn top-right whenever enabled, independent of the F10 panel. */ static int s_showFps = 0; static char s_fpsText[16] = ""; @@ -347,106 +348,94 @@ static void fpsTick(void) * emit path and the mouse hit-testing in optionsOverlayHandleInput(). * BankGothic caps are ~9 units tall here, so rows need ~16 units of pitch and * values are right-aligned to the panel edge to survive the wide font. */ -#define OV_X0 20 -#define OV_LABEL_X 28 -#define OV_TOP 12 -#define OV_LINE 15 /* row pitch (13 rows must fit ~240) */ -#define OV_HDR 2 /* header rows above row 0 (title+hint) */ -/* i is a VISIBLE-POSITION (already scroll-adjusted by the caller). */ -#define OV_ROW_Y(i) (OV_TOP + ((i) + OV_HDR) * OV_LINE) +#define OV_TOP 8 +#define OV_LINE 16 +/* Dark 2D watch-style overlay; the in-stage watch's 3D model is not loaded + * here. All rendering AND hit-testing take coordinates from this layout; + * 320x240 stages and 440x330 front screens share the same geometry. */ +struct OvLayout { + s32 left, right, top, sectionY, contentY, bottom, footerY; + s32 labelX, valueR, barX0, barX1, maxRows; +}; -/* How many rows fit between the header and the bottom edge of whatever 2D - * space we are in right now (320x240 in-game, 440x330 on front-end screens -- - * viSetXY differs, see src/game/front.c). More rows than this => scroll. */ -static int maxVisibleRows(void) +static struct OvLayout overlayLayout(void) { - int n = (viGetY() - 6 - OV_TOP - OV_HDR * OV_LINE) / OV_LINE; - if (n < 4) { - n = 4; - } - return n; + struct OvLayout o; + s32 w = viGetX(), h = viGetY(); + s32 cardW = w - 16; + if (cardW > 376) cardW = 376; + o.left = (w - cardW) / 2; + o.right = o.left + cardW; + o.top = OV_TOP; + o.sectionY = o.top + 31; + o.contentY = o.top + 49; + o.labelX = o.left + 18; + o.valueR = o.right - 12; + o.barX1 = o.right - 65; + o.barX0 = o.barX1 - 74; + o.maxRows = (h - 6 - o.contentY - 24) / OV_LINE; + if (o.maxRows < 4) o.maxRows = 4; + /* Root has five categories. A section's title/Back sits in its own + * pinned band; only the content rows consume this scrollable area. */ + int contentN = s_visN - (s_section >= 0 ? 1 : 0); + int drawn = contentN < o.maxRows ? contentN : o.maxRows; + o.bottom = o.contentY + drawn * OV_LINE + 24; + if (o.bottom > h - 6) o.bottom = h - 6; + o.footerY = o.bottom - 15; + return o; } -#define OV_RIGHT (viGetX() - OV_X0) /* right edge for right-aligned text */ -#define OV_NUM_W 36 /* reserved width for a slider's number */ -#define OV_BAR_X 150 -/* Slider fill bar span in overlay space (shared by emit + hit-testing). */ +static int maxVisibleRows(void) { return overlayLayout().maxRows; } + +/* Shared with slider dragging and its render path. */ static void sliderBarSpan(s32 *x0, s32 *x1) { - *x0 = OV_BAR_X; - *x1 = OV_RIGHT - OV_NUM_W; - if (*x1 < *x0 + 16) { - *x1 = *x0 + 16; - } + struct OvLayout o = overlayLayout(); + *x0 = o.barX0; + *x1 = o.barX1; } -/* Visible position (0..s_visN-1) the given overlay-space y falls in, or -1. - * - * D304 fix: this used to loop over ALL s_visN entries (0..s_visN-1) - * regardless of which ones are actually scrolled into view and drawn this - * frame -- OV_ROW_Y(p - s_scroll) is a plain linear function of p, so a row - * sitting just past the last visibly-drawn one (pLast, see the emit path) - * still produces a geometrically valid, in-range Y band immediately below - * the panel's real bottom edge. A click near that boundary (real-pixel-to- - * virtual-2D-space rounding in the ox/oy scale-up, or simply a slightly low - * click on the last visible row) could therefore resolve to the NEXT, - * invisible, scrolled-off-the-bottom row instead of the one actually drawn - * there. Concretely: with "All unlocked" sitting in the last visible slot, - * this let a click on it silently hit "Quit to desktop" (the very next row) - * instead -- reported as "I clicked unlock all and I think it crashed" - * (user, 2026-09-18): the game didn't crash, __QuitToDesktop's ROW_ACTION - * fired and closed it via the game's normal exit path. Fix: bound the scan - * to the same [s_scroll, pLast] range the draw loop actually renders. - * - * D316 fix: the band used to be [Y-3, Y+12) -- 3 units above OV_ROW_Y but - * 12 below it, a systematic downward bias baked in independent of the - * mouse-mapping bug above. Centered here on OV_ROW_Y so a borderline click - * no longer favors the row below. */ +/* Visible position or -1. Match the rendered [s_scroll, pLast] window + * exactly (D304: never hit the invisible row below Quit) and centre the + * hit band on the drawn row (D316). The section title has its own pinned + * hit band and is always the selectable Back row, even while scrolling. */ static int overlayRowAtY(double oy) { - int maxV = maxVisibleRows(); - int pLast = (s_visN - s_scroll < maxV) ? s_visN - 1 : s_scroll + maxV - 1; + struct OvLayout o = overlayLayout(); + if (s_section >= 0 && oy >= o.sectionY - OV_LINE / 2 && + oy < o.sectionY + OV_LINE / 2) return 0; /* pinned Back/title */ + int pLast = s_scroll + o.maxRows - 1; + if (pLast >= s_visN) pLast = s_visN - 1; for (int p = s_scroll; p <= pLast; p++) { - double top = OV_ROW_Y(p - s_scroll) - OV_LINE / 2; - if (oy >= top && oy < top + OV_LINE) { - return p; - } + double top = o.contentY + (p - s_scroll) * OV_LINE - OV_LINE / 2; + if (oy >= top && oy < top + OV_LINE) return p; } return -1; } /* Rebuild the visible-row list and keep the selection in range. */ -/* D237: move a visible-list position by dir (+1/-1) to the next row that - * is not a category header; stays put at the ends. */ +/* Move through the current category list or section (including its Back row). */ static int overlayStepSel(int pos, int dir) { - int p = pos; - for (;;) { - int q = p + dir; - if (q < 0 || q >= s_visN) { - /* nothing selectable that way: if we started on a header, try - * the other direction once so we never rest on one */ - if (rows[s_visIdx[p]].kind == ROW_HEADER && dir > 0) { - return overlayStepSel(pos, -1); - } - return (rows[s_visIdx[p]].kind == ROW_HEADER) ? pos : p; - } - p = q; - if (rows[s_visIdx[p]].kind != ROW_HEADER) { - return p; - } - } + int q = pos + dir; + return (q < 0 || q >= s_visN) ? pos : q; } static void overlayUpdateVisible(void) { s_visN = 0; for (int i = 0; i < NUM_ROWS; i++) { - /* The file chooser belongs to the GE front-end screen, not F10. - * F10 in-stage always targets the active Bond file. */ - if (rows[i].kind != ROW_BOND_FILE && - !(rows[i].hidePtr && *rows[i].hidePtr != 0)) { - s_visIdx[s_visN++] = i; + /* Category list: only headers. Section: its header serves as Back, + * followed by its settings. The file chooser belongs to the front. */ + if (s_section < 0) { + if (rows[i].kind == ROW_HEADER) s_visIdx[s_visN++] = i; + } else if (i == s_section) { + s_visIdx[s_visN++] = i; /* Back */ + } else if (i > s_section) { + if (rows[i].kind == ROW_HEADER) break; + if (rows[i].kind != ROW_BOND_FILE && + !(rows[i].hidePtr && *rows[i].hidePtr)) + s_visIdx[s_visN++] = i; } } if (s_visN == 0) { /* cannot happen (toggles have no hide source) */ @@ -458,11 +447,6 @@ static void overlayUpdateVisible(void) if (s_sel >= s_visN) { s_sel = s_visN - 1; } - /* D237: never rest on a category header -- step to the nearest row - * below it (or above, at the end of the list). */ - if (rows[s_visIdx[s_sel]].kind == ROW_HEADER) { - s_sel = overlayStepSel(s_sel, +1); - } } /* Keep the selected row on screen: shift the window ONLY when the selection @@ -482,32 +466,27 @@ static void overlayUpdateVisible(void) static void overlayUpdateScroll(void) { int maxV = maxVisibleRows(); - if (s_visN <= maxV) { - s_scroll = 0; + int first = s_section >= 0 ? 1 : 0; /* section header stays pinned */ + int count = s_visN - first; + if (count <= maxV) { + s_scroll = first; return; } - if (s_sel < s_scroll) { + if (s_scroll < first) s_scroll = first; + if (s_sel >= first && s_sel < s_scroll) { s_scroll = s_sel; } else if (s_sel > s_scroll + maxV - 1) { s_scroll = s_sel - (maxV - 1); } - if (s_scroll < 0) { - s_scroll = 0; - } - if (s_scroll > s_visN - maxV) { - s_scroll = s_visN - maxV; - } + if (s_scroll > s_visN - maxV) s_scroll = s_visN - maxV; } /* The close box brackets the title row at the panel's right edge. */ -#define OV_CB_X0 (OV_RIGHT - 14) -#define OV_CB_X1 (OV_RIGHT + 7) -#define OV_CB_Y0 (OV_TOP - 3) -#define OV_CB_Y1 (OV_TOP + 12) static int overlayInCloseBox(double ox, double oy) { - return ox >= OV_CB_X0 && ox <= OV_CB_X1 && - oy >= OV_CB_Y0 && oy <= OV_CB_Y1; + struct OvLayout o = overlayLayout(); + return ox >= o.right - 40 && ox <= o.right - 5 && + oy >= o.top + 3 && oy <= o.top + 22; } /* ------------------------------------------------------------------------ */ @@ -611,7 +590,22 @@ static void overlayInit(void) const char *e = getenv("GE_OPTIONSOVERLAY"); if (e && atoi(e) != 0) { s_open = 1; - sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY)"); + /* Diagnostic screenshots: 1 = category root, 2..6 = the five + * sections. This is only a boot probe; normal F10 always opens + * at the category root. */ + int page = atoi(e) - 2; + if (page >= 0 && page < 5) { + int found = 0; + for (int i = 0; i < NUM_ROWS; i++) { + if (rows[i].kind == ROW_HEADER && found++ == page) { + s_section = i; + s_sel = 1; + break; + } + } + overlayUpdateVisible(); + } + sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY=%s)", e); } } @@ -639,6 +633,40 @@ static double rowHi(const struct Row *r) return (r->uiMin != r->uiMax) ? r->uiMax : r->cfgMax; } +/* Player-facing calibrated input controls: the original raw defaults are + * deliberate (100% mouse speed, 7000/30000 stick deadzone, 23% trigger + * travel). Show each as 50/100, without changing input.c, ini ranges, + * reset values, or either endpoint. Both UIs and F10 hit/drag use the same + * piecewise mapping, so the shown number always matches the filled bar. */ +static double calibratedDefault(const struct Row *r) +{ + if (!strcmp(r->key, "Input.MouseSensitivity")) return 100.0; + if (!strcmp(r->key, "Input.PadDeadzone")) return 7000.0; + if (!strcmp(r->key, "Input.PadTriggerPct")) return 23.0; + return -1.0; +} +static double rowFractionAt(const struct Row *r, double v) +{ + double lo = rowLo(r), hi = rowHi(r), def = calibratedDefault(r); + if (hi <= lo) return 0.0; + if (v < lo) v = lo; + if (v > hi) v = hi; + if (def > lo && def < hi) + return v <= def ? 0.5 * (v - lo) / (def - lo) + : 0.5 + 0.5 * (v - def) / (hi - def); + return (v - lo) / (hi - lo); +} +static double rowValueAtFraction(const struct Row *r, double f) +{ + double lo = rowLo(r), hi = rowHi(r), def = calibratedDefault(r); + if (f < 0.0) f = 0.0; + if (f > 1.0) f = 1.0; + if (def > lo && def < hi) + return f <= 0.5 ? lo + (def - lo) * f * 2.0 + : def + (hi - def) * (f - 0.5) * 2.0; + return lo + (hi - lo) * f; +} + static struct Row *rowByKey(const char *key) { for (int i = 0; i < NUM_ROWS; i++) { @@ -662,13 +690,26 @@ static void rowSetCommit(struct Row *r, double v, int commit) } int field = watchSettingsFieldForKey(r->key); if (field >= 0) { - watchSettingsSet(field, (int)lround(v), commit); + int want = (int)lround(v); + /* Held drags poll far more often than the mouse moves to a new + * slider detent. Don't enqueue another audio update for the same + * value; release still commits via watchSettingsCommit(). */ + if (watchSettingsRead(field) != want) + watchSettingsSet(field, want, commit); return; } switch (r->type) { - case CONFIG_OPT_INT: *(int *)r->ptr = (int)lround(v); break; - case CONFIG_OPT_UINT: *(unsigned int *)r->ptr = (unsigned int)(v < 0 ? 0 : lround(v)); break; - case CONFIG_OPT_FLOAT: *(float *)r->ptr = (float)v; break; + case CONFIG_OPT_INT: + if (*(int *)r->ptr == (int)lround(v)) return; + *(int *)r->ptr = (int)lround(v); break; + case CONFIG_OPT_UINT: { + unsigned int want = (unsigned int)(v < 0 ? 0 : lround(v)); + if (*(unsigned int *)r->ptr == want) return; + *(unsigned int *)r->ptr = want; break; + } + case CONFIG_OPT_FLOAT: + if (*(float *)r->ptr == (float)v) return; + *(float *)r->ptr = (float)v; break; default: return; } @@ -676,7 +717,12 @@ static void rowSetCommit(struct Row *r, double v, int commit) * is read straight off the pointer by its owner every frame/poll. */ if (strcmp(r->key, "Video.Fullscreen") == 0) { videoRequestFullscreen((int)lround(v)); - } else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) { + } else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart && + strcmp(r->key, "Video.DrawDistance") != 0 && + strcmp(r->key, "Video.LodDistance") != 0) { + /* These two distance knobs are read directly by the render path. + * Reapplying VSync/texture/GL image state every drag detent only + * stalls frames; they do not need videoStartFrame's config pass. */ videoRequestLiveConfig(); } @@ -826,17 +872,15 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Input.PadDeadzone", 7000 },/* = STICK_DEADZONE (7000) */ { "Input.PadTriggerPct", 23 }, /* = 23 */ /* GRAPHICS (port/src/video.c initializers) */ - { "Video.MSAA", 4 }, /* = 4 */ + { "Video.MSAA", 2 }, /* = 2 */ { "Video.TextureFilter", 1 }, /* = 1 (bilinear) */ { "Video.Anisotropy", 4 }, /* = 4 */ { "Video.FovScale", 100 }, /* = 100 */ { "Video.NativeWidescreen", 1 }, /* = 1 */ { "Video.WidescreenAuto", 1 }, /* = 1 */ { "Video.SafeAreaCrop", 1 }, /* = 1 */ - { "Video.DrawDistance", 150 }, /* = 150 (D218) */ - { "Video.DrawDistanceAutoFov", 1 }, /* = 1 (D218) */ - { "Video.LodDistance", 150 }, /* = 150 (D249) */ - { "Video.LodDistanceAutoFov", 0 }, /* = 0 (standalone perf lever) */ + { "Video.DrawDistance", 250 }, /* midpoint: 50/100 */ + { "Video.LodDistance", 250 }, /* midpoint: 50/100 */ /* GAMEPLAY ini rows (port/src/video.c initializers) */ { "Game.SkipIntro", 0 }, /* = 0 */ { "Game.NoHitFlash", 0 }, /* = 0 */ @@ -857,8 +901,8 @@ static double kResetDefault(const char *key) } /* The section's DECLARED row range (its header through the next header) -- - * NOT the visible list, so conditionally hidden rows (the manual draw/LOD % - * sliders while their auto-FOV toggles are on) are reset too; their live + * NOT the visible list, so conditionally hidden rows (e.g. Aim range while + * centred) are reset too; their live * values are written through the same ptr/rowSet path, visibility * irrelevant. Per row, its OWN scope is reset: watch rows -> the selected * file's BLANKSAVEDATA values through the normal commit path (front: direct @@ -950,6 +994,11 @@ void optionsOverlayToggle(void) if (pending > 0) watchSettingsCommit(pending - 1); } s_open = !s_open; + if (s_open) { + s_section = -1; + s_sel = s_scroll = 0; + } + SDL_AtomicSet(&s_backPending, 0); sysLogPrintf(LOG_INFO, "optionsoverlay: %s", s_open ? "opened" : "closed"); if (!s_open) { optionsResetClear(); /* D356: closing the overlay disarms a pending reset */ @@ -957,6 +1006,12 @@ void optionsOverlayToggle(void) } } +void optionsOverlayBack(void) +{ + /* Called from SDL's host event pump; navigation is scheduler-owned. */ + if (s_open) SDL_AtomicSet(&s_backPending, 1); +} + int optionsOverlayIsOpen(void) { if (!s_inited) { @@ -1006,7 +1061,7 @@ static void sliderSetFromX(struct Row *r, double ox) double f = (ox - bx0) / (double)(bx1 - bx0); if (f < 0) f = 0; if (f > 1) f = 1; - double v = lo + f * (hi - lo); + double v = rowValueAtFraction(r, f); double step = (r->step > 0.0) ? r->step : 1.0; v = lround(v / step) * step; rowSetCommit(r, v, 0); /* slider drag: save only on release */ @@ -1021,6 +1076,10 @@ static void sliderSetFromX(struct Row *r, double ox) * see D302: a hot-path getenv() regression cost a real bug before). */ static int s_d314Enabled = -1; /* -1 = not yet resolved */ +/* Shared by paint and mouse input: the area right of a setting's name + * changes it; its name only selects it. Reset actions use the whole row. */ +static int overlayControlSpan(int i, s32 *x0, s32 *x1); + void optionsOverlayHandleInput(void) { static int prevUp, prevDn, prevLf, prevRt, prevLmb, prevRmb; @@ -1049,11 +1108,42 @@ void optionsOverlayHandleInput(void) * visible (a stage poll would otherwise leave it locked/hidden). */ inputSuspendForOverlay(); + /* D361: regression probe on the ACTUAL joyPoll/F10 thread (not the + * game-thread GE_WSPROBE_FRONT hook). A front-end write used to block + * forever in joyDisablePoll waiting for this very poll to acknowledge it. + * Opt-in only, once after the file-select save becomes available. */ + { + static int probe = -1, readyTicks = 0; + if (probe < 0) probe = getenv("GE_WSPROBE_F10FRONT") ? 1 : 0; + if (probe == 1 && current_menu == MENU_FILE_SELECT && watchSettingsAvailable() && + ++readyTicks >= 120) { + probe = 2; + sysLogPrintf(LOG_INFO, "wsf10front: queue Music+FX from controller poll"); + watchSettingsSet(WATCH_SETTING_MUSIC, 4096, 1); + watchSettingsSet(WATCH_SETTING_FX, 4096, 1); + } + } + /* D356: a pending reset arm expires after 3 s or when the selection * leaves the armed row; the overlay's selection is s_visIdx[s_sel]. */ - optionsResetMaintain(s_visIdx[s_sel]); + overlayUpdateVisible(); + if (SDL_AtomicSet(&s_backPending, 0)) { + if (s_section >= 0) { + int hdr = s_section; + s_section = -1; + overlayUpdateVisible(); + s_sel = 0; + while (s_sel + 1 < s_visN && s_visIdx[s_sel] != hdr) s_sel++; + s_scroll = 0; + optionsResetClear(); + } else { + optionsOverlayToggle(); + return; + } + } + optionsResetMaintain(s_section < 0 ? -1 : s_visIdx[s_sel]); - overlayUpdateVisible(); /* % rows may have appeared/vanished (auto toggles) */ + /* % rows may have appeared/vanished after a setting change. */ overlayApplyWheel(); /* D314: wheel notches queued by the host thread */ overlayUpdateScroll(); @@ -1123,14 +1213,31 @@ void optionsOverlayHandleInput(void) if (dir != 0) { adjDir = dir; adjTimer = 18; - rowAdjust(&rows[s_visIdx[s_sel]], dir); + if (s_section < 0) { + s_section = s_visIdx[s_sel]; + s_sel = 1; + s_scroll = 0; + overlayUpdateVisible(); + adjDir = 0; + } else if (s_sel == 0) { + int hdr = s_section; + s_section = -1; + overlayUpdateVisible(); + s_sel = 0; + while (s_sel + 1 < s_visN && s_visIdx[s_sel] != hdr) s_sel++; + s_scroll = 0; + optionsResetClear(); + adjDir = 0; + } else { + rowAdjust(&rows[s_visIdx[s_sel]], dir); + } } else if ((rt || lf) && adjDir != 0) { if (--adjTimer <= 0) { adjTimer = 4; /* D356: reset actions are edge-triggered -- the held-repeat * re-fires adjust rows but must never re-fire a reset (a held * key could arm AND confirm, or commit repeatedly). */ - if (!isResetRow(&rows[s_visIdx[s_sel]])) + if (s_section >= 0 && s_sel != 0 && !isResetRow(&rows[s_visIdx[s_sel]])) rowAdjust(&rows[s_visIdx[s_sel]], adjDir); } } else { @@ -1159,28 +1266,38 @@ void optionsOverlayHandleInput(void) * new row back into the cursor and made the bottom twitch/echo). * Selection moves only by click, wheel, or arrows. */ - /* left press. A click in the label column only focuses the row; a - * click in the value/control column (>= the bar-span start) changes - * it -- so clicking to select a toggle doesn't also flip it. */ - s32 bx0, bx1; - sliderBarSpan(&bx0, &bx1); + /* Clicking the name selects; anywhere to its right changes the + * setting. Reset actions use the whole row. */ if (lmb && !prevLmb) { if (onClose) { optionsOverlayToggle(); /* close + configSave */ return; } - if (hoverVis >= 0 && rows[s_visIdx[hoverVis]].kind != ROW_HEADER) { + if (hoverVis >= 0) { s_sel = hoverVis; /* explicit click -> select */ overlayUpdateScroll(); - if (ox >= bx0) { - struct Row *r = &rows[s_visIdx[hoverVis]]; - if (r->kind == ROW_SLIDER && r->found) { - sliderSetFromX(r, ox); - s_dragRow = s_visIdx[hoverVis]; - int field = watchSettingsFieldForKey(r->key); - SDL_AtomicSet(&s_dragWatchField, field + 1); - } else { - rowAdjust(r, +1); /* toggle / cycle forward (wraps) */ + if (rows[s_visIdx[hoverVis]].kind == ROW_HEADER) { + int hdr = s_visIdx[hoverVis]; + if (s_section < 0) { s_section = hdr; s_sel = 1; } + else { s_section = -1; s_sel = 0; optionsResetClear(); } + s_scroll = 0; + overlayUpdateVisible(); + if (s_section < 0) + while (s_sel + 1 < s_visN && s_visIdx[s_sel] != hdr) s_sel++; + } else { + int i = s_visIdx[hoverVis]; + s32 x0, x1; + if (overlayControlSpan(i, &x0, &x1) && ox >= x0 && ox < x1) { + struct Row *r = &rows[i]; + if (r->kind == ROW_SLIDER && r->found && + ox >= overlayLayout().barX0 && ox <= overlayLayout().barX1) { + sliderSetFromX(r, ox); + s_dragRow = i; + int field = watchSettingsFieldForKey(r->key); + SDL_AtomicSet(&s_dragWatchField, field + 1); + } else { + rowAdjust(r, +1); /* toggle / cycle forward (wraps) */ + } } } } @@ -1197,12 +1314,16 @@ void optionsOverlayHandleInput(void) s_dragRow = -1; SDL_AtomicSet(&s_dragWatchField, 0); } - /* right press in the value column: cycle back / decrement */ - if (rmb && !prevRmb && hoverVis >= 0 && !onClose && ox >= bx0 && - rows[s_visIdx[hoverVis]].kind != ROW_HEADER) { - s_sel = hoverVis; - overlayUpdateScroll(); - rowAdjust(&rows[s_visIdx[hoverVis]], -1); + /* Right press in the same change region: cycle back. */ + if (rmb && !prevRmb && hoverVis >= 0 && !onClose && s_section >= 0) { + int i = s_visIdx[hoverVis]; + s32 x0, x1; + if (!isResetRow(&rows[i]) && overlayControlSpan(i, &x0, &x1) && + ox >= x0 && ox < x1) { + s_sel = hoverVis; + overlayUpdateScroll(); + rowAdjust(&rows[i], -1); + } } } @@ -1284,6 +1405,19 @@ static void valueText(int i, char *out, int n) else snprintf(out, n, "%d FPS", fps); return; } + if (calibratedDefault(r) >= 0.0) { + snprintf(out, n, "%d/100", (int)lround(rowFractionAt(r, v) * 100.0)); + return; + } + /* Distance sliders are normalized to a 0..100 UI while retaining the + * original percent-based ini format (250% = 50/100). */ + if (strcmp(r->key, "Video.DrawDistance") == 0 || + strcmp(r->key, "Video.LodDistance") == 0) { + int normalized = (int)lround((v - 100.0) / 3.0); + if (normalized < 0) normalized = 0; /* legacy LOD ini < 100 */ + snprintf(out, n, "%d/100", normalized); + return; + } /* D346: integer sliders -- optional raw->display divide + unit suffix. */ if (r->dispDiv > 0) { v = (double)(int)lround(v / (double)r->dispDiv); @@ -1320,6 +1454,104 @@ static Gfx *drawTextR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour) return drawText(gdl, xr - measureText(str), y, str, colour); } +/* The N64 watch pages use Bank Gothic with a dim/bright green palette. + * Reuse that same loaded font here, without invoking watch rendering. */ +static struct font *bodyFont(void) { return ptrFontBankGothic; } +static struct fontchar *bodyChars(void) { return ptrFontBankGothicChars; } +static s32 bodyWidth(const char *str) +{ + s32 h = 0, w = 0; + textMeasure(&h, &w, (char *)str, bodyChars(), bodyFont(), 0); + return w; +} +static Gfx *drawBody(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour) +{ + s32 px = x, py = y; + return textRender(gdl, &px, &py, (char *)str, bodyChars(), bodyFont(), + colour, viGetX(), viGetY(), 0, 0); +} +static Gfx *drawBodyR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour) +{ + return drawBody(gdl, xr - bodyWidth(str), y, str, colour); +} + +/* No per-value boxes: the full strip after the rendered setting name is + * active, even if there's space between its name and its bar/value. Its + * boundary is capped at the label's clipping limit for long names. */ +static int overlayControlSpan(int i, s32 *x0, s32 *x1) +{ + struct OvLayout o = overlayLayout(); + const struct Row *r = &rows[i]; + char val[64]; + if (r->kind == ROW_HEADER || (r->kind == ROW_SLIDER && !r->found)) + return 0; + *x1 = o.right - 7; + if (isResetRow(r)) { + *x0 = o.left + 7; /* reset is an action, not a setting name */ + } else { + valueText(i, val, sizeof(val)); + s32 limit = r->kind == ROW_SLIDER ? o.barX0 : + o.valueR - bodyWidth(val) - 7; + if (r->restart) limit = o.barX0 - 6; + *x0 = o.labelX + bodyWidth(r->label) + 4; + if (*x0 > limit) *x0 = limit; + } + return *x1 > *x0; +} +/* In the watch-style panel, instructions/scope annotations are deliberately + * a second font and cool-grey ink, not another setting in green Gothic. */ +static struct font *metaFont(void) +{ + return ptrFontZurichBold && ptrFontZurichBoldChars ? ptrFontZurichBold : bodyFont(); +} +static struct fontchar *metaChars(void) +{ + return ptrFontZurichBold && ptrFontZurichBoldChars ? ptrFontZurichBoldChars : bodyChars(); +} +static s32 metaWidth(const char *str) +{ + s32 h = 0, w = 0; + textMeasure(&h, &w, (char *)str, metaChars(), metaFont(), 0); + return w; +} +static Gfx *drawMeta(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour) +{ + s32 px = x, py = y; + return textRender(gdl, &px, &py, (char *)str, metaChars(), metaFont(), + colour, viGetX(), viGetY(), 0, 0); +} +/* Label column may be narrow at 320x240. Never paint over a slider/value. */ +static int sectionIsMixedScope(int header) +{ + if (header < 0) return 0; + int scoped = 0, global = 0; + for (int i = header + 1; i < NUM_ROWS && rows[i].kind != ROW_HEADER; i++) { + if (rows[i].kind == ROW_ACTION) continue; + if (rows[i].saveScoped) scoped = 1; + else global = 1; + } + return scoped && global; +} + +static Gfx *drawBodyFit(Gfx *gdl, s32 x, s32 y, s32 maxW, const char *str, u32 colour) +{ + char out[80]; + snprintf(out, sizeof(out), "%s", str); + if (maxW < 12) return gdl; + if (bodyWidth(out) > maxW) { + int n = (int)strlen(out); + while (n > 1) { + out[--n] = 0; + if (n + 2 < (int)sizeof(out)) { + out[n] = '.'; out[n + 1] = '.'; out[n + 2] = 0; + } + if (bodyWidth(out) <= maxW) break; + out[n] = 0; + } + } + return drawBody(gdl, x, y, out, colour); +} + Gfx *optionsOverlayEmit(void) { if (!s_inited) { @@ -1352,26 +1584,10 @@ Gfx *optionsOverlayEmit(void) const s32 W = viGetX(); const s32 H = viGetY(); - const s32 right = OV_RIGHT; - const s32 panelTop = OV_TOP - 9; - /* Last visible position actually drawn (window may be shorter than the - * list at small 2D viewports -- the rest is reached by scrolling). - * D304 fix: the "everything fits" branch was `s_visN - s_scroll`, which - * is a COUNT, not the last valid 0-based index -- since that branch only - * runs when s_scroll==0 (overlayUpdateScroll's own invariant), this - * evaluated to `s_visN`, one past the last valid entry, so both draw - * loops below (`for (p = s_scroll; p <= pLast; p++)`) read one row past - * the end of `s_visIdx[]`/`rows[]` on any 2D viewport large enough to fit - * the whole list without scrolling (e.g. the larger front-end 440x330 - * screen once a couple of rows are pulled from the menu, as just - * happened above). Should be `s_visN - 1`, matching overlayRowAtY's - * identical fix. */ - const int maxV = maxVisibleRows(); - const int pLast = (s_visN - s_scroll < maxV) ? s_visN - 1 - : s_scroll + maxV - 1; - const s32 panelBottom = OV_ROW_Y(pLast) + OV_LINE / 2 + 3; - s32 bx0, bx1; - sliderBarSpan(&bx0, &bx1); + struct OvLayout o = overlayLayout(); + int pLast = s_scroll + o.maxRows - 1; + if (pLast >= s_visN) pLast = s_visN - 1; + const s32 bx0 = o.barX0, bx1 = o.barX1; Gfx *gdl = s_buf; gDPPipeSync(gdl++); @@ -1379,84 +1595,107 @@ Gfx *optionsOverlayEmit(void) gDPSetTexturePersp(gdl++, G_TP_NONE); gDPSetScissor(gdl++, G_SC_NON_INTERLACE, 0, 0, W, H); - /* ---- pass 1: all fills (G_CC_PRIMITIVE) ---- */ - gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 150); /* dim */ - gdl = fillRect(gdl, OV_X0 - 8, panelTop, W - (OV_X0 - 8), panelBottom, - 8, 10, 24, 210); /* panel */ - gdl = fillRect(gdl, OV_CB_X0, OV_CB_Y0, OV_CB_X1, OV_CB_Y1, - 150, 40, 40, 235); /* close */ - + /* Dark glass like the previous F10 overlay; green ink and highlights + * follow GE's watch options (options.c's Bank Gothic/0xA0FFA0F0). + * No dossier paper, gold, 3D watch model or new assets. */ + gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 150); + gdl = fillRect(gdl, o.left, o.top, o.right, o.bottom, + 5, 17, 13, 225); + gdl = fillRect(gdl, o.left + 8, o.top + 25, o.right - 8, o.top + 26, + 56, 135, 73, 195); + /* Help is a separate control strip, not another setting row. */ + gdl = fillRect(gdl, o.left + 3, o.footerY - 5, o.right - 3, o.bottom - 2, + 1, 9, 7, 235); + gdl = fillRect(gdl, o.left + 8, o.footerY - 5, o.right - 8, o.footerY - 4, + 55, 102, 68, 170); + /* No row panels or control boxes. Selection is the brighter text in + * pass 2; only a slider's actual track/knob gets a background. */ for (int p = s_scroll; p <= pLast; p++) { const struct Row *r = &rows[s_visIdx[p]]; - s32 rowY = OV_ROW_Y(p - s_scroll); - if (p == s_sel) { - gdl = fillRect(gdl, OV_X0 - 4, rowY - 3, W - (OV_X0 - 4), - rowY + OV_LINE - 4, 40, 46, 96, 220); - } + s32 rowY = o.contentY + (p - s_scroll) * OV_LINE; if (r->kind == ROW_SLIDER && r->found) { - double lo = rowLo(r), hi = rowHi(r); - double f = (hi > lo) ? (rowGet(r) - lo) / (hi - lo) : 0.0; - if (f < 0) f = 0; if (f > 1) f = 1; - s32 by = rowY + 3; - gdl = fillRect(gdl, bx0, by, bx1, by + 5, 60, 60, 70, 220); - gdl = fillRect(gdl, bx0, by, bx0 + (s32)((bx1 - bx0) * f), by + 5, - 210, 200, 90, 255); + double f = rowFractionAt(r, rowGet(r)); + s32 by = rowY + 3; /* INSIDE the row's hit band, not the next row */ + gdl = fillRect(gdl, bx0, by, bx1, by + 2, 31, 68, 44, 205); + gdl = fillRect(gdl, bx0, by, bx0 + (s32)((bx1 - bx0) * f), by + 2, + 59, 200, 87, 255); + s32 knob = bx0 + (s32)((bx1 - bx0) * f); + gdl = fillRect(gdl, knob - 1, by - 2, knob + 2, by + 4, + 160, 255, 160, 255); } } /* ---- pass 2: text ---- */ gdl = microcode_constructor(gdl); - gdl = drawText(gdl, OV_X0, OV_TOP, "PC OPTIONS", 0xffe040ff); - gdl = drawText(gdl, OV_X0, OV_TOP + OV_LINE, - "select: scroll/click, change: mouse/arrows", - 0x8890a0ff); /* hint line */ - gdl = drawText(gdl, (OV_CB_X0 + OV_CB_X1) / 2 - measureText("X") / 2, - OV_TOP, "X", 0xffffffff); /* close glyph */ + gdl = drawBody(gdl, o.left + 10, o.top + 8, "PC OPTIONS", 0xa0ffa0ff); + gdl = drawBodyR(gdl, o.right - 14, o.top + 8, "X", 0xa0ffa0ff); + if (s_section < 0) { + gdl = drawMeta(gdl, o.labelX, o.sectionY, "SELECT A CATEGORY", 0x829e91ff); + } else { + const struct Row *hdr = &rows[s_section]; + gdl = drawBody(gdl, o.left + 12, o.sectionY, "< BACK", 0xa0ffa0ff); + s32 titleX = o.left + 80; + gdl = drawBody(gdl, titleX, o.sectionY, hdr->label, 0xa0ffa0ff); + if (hdr->saveScoped) { + int f = watchSettingsActiveFolder(); + char note[24]; + if (f < 0) snprintf(note, sizeof(note), "(none)"); + else snprintf(note, sizeof(note), "(Profile %d)", f + 1); + gdl = drawBody(gdl, titleX + bodyWidth(hdr->label) + 5, + o.sectionY, note, 0x829e91ff); + } else if (!strcmp(hdr->key, "__HdrInput")) { + gdl = drawBody(gdl, titleX + bodyWidth(hdr->label) + 5, + o.sectionY, "(50 = default)", 0x829e91ff); + } + } for (int p = s_scroll; p <= pLast; p++) { const struct Row *r = &rows[s_visIdx[p]]; - s32 rowY = OV_ROW_Y(p - s_scroll); - u32 col = (p == s_sel) ? 0xffffffff : 0xc0c0c8ff; + s32 rowY = o.contentY + (p - s_scroll) * OV_LINE; + u32 ink = !r->found ? 0x498053ff : + p == s_sel ? 0xa0ffa0ff : 0x66ca77ff; char val[48]; - - if (r->kind == ROW_HEADER) { /* D237 category label, no value */ - gdl = drawText(gdl, OV_X0, rowY, (char *)r->label, 0xffe040ff); - /* D356: sections that contain per-file rows carry the active - * file in the header ("GAMEPLAY (File 1)"), PD-style -- in-stage - * the active file, front end the resolved front target. */ - if (r->saveScoped) { - int f = watchSettingsActiveFolder(); - char note[16]; - if (f < 0) snprintf(note, sizeof(note), "(none)"); - else snprintf(note, sizeof(note), "(Profile %d)", f + 1); - gdl = drawText(gdl, OV_X0 + measureText(r->label) + 6, rowY, note, 0x909090ff); - } + if (s_section < 0) { + gdl = drawBody(gdl, o.labelX, rowY, r->label, ink); continue; } - gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)r->label, - r->found ? col : 0x808080ff); - /* D357: per-file rows in the mixed-scope section carry a dim - * "(per profile)" tag -- "stored separately in each profile"; - * the section title says which one is active. */ - if (r->saveScoped && r->kind != ROW_HEADER) { - gdl = drawText(gdl, OV_LABEL_X + measureText(r->label) + 5, rowY, - "(per profile)", 0x909090ff); + valueText(s_visIdx[p], val, sizeof(val)); + s32 valueW = bodyWidth(val); + s32 labelEnd = r->kind == ROW_SLIDER ? bx0 : o.valueR - valueW - 7; + if (r->restart) labelEnd = bx0 - 6; + gdl = drawBodyFit(gdl, o.labelX, rowY, labelEnd - o.labelX, + r->label, ink); + /* Only GAMEPLAY mixes profile and global values. Its four profile + * toggles have space for a real, subdued word; AUDIO is wholly + * profile-scoped and says so in its section title. */ + if (r->saveScoped && sectionIsMixedScope(s_section)) { + s32 tagX = o.labelX + bodyWidth(r->label) + 4; + if (tagX + bodyWidth("(profile)") < labelEnd - 2) + gdl = drawBody(gdl, tagX, rowY, "(profile)", 0x829e91ff); } if (!r->found) { - gdl = drawTextR(gdl, right, rowY, "(n/a)", 0x808080ff); + gdl = drawBodyR(gdl, o.valueR, rowY, "(n/a)", 0x498053ff); continue; } - - valueText(s_visIdx[p], val, sizeof(val)); if (r->restart) { - /* value left of the bar span, "(restart)" pinned to the edge */ - gdl = drawText(gdl, bx0, rowY, val, col); - gdl = drawTextR(gdl, right, rowY, "(restart)", 0x909090ff); + gdl = drawBody(gdl, bx0, rowY, val, ink); + gdl = drawBodyR(gdl, o.valueR, rowY, "(restart)", 0x498053ff); } else { - gdl = drawTextR(gdl, right, rowY, val, col); + u32 valueInk = (strcmp(val, "On") == 0 || + (isResetRow(r) && strcmp(val, "Confirm") == 0)) + ? 0xa0ffa0ff : ink; + gdl = drawBodyR(gdl, o.valueR, rowY, val, valueInk); } } + gdl = drawMeta(gdl, o.left + 11, o.footerY, "CONTROLS:", 0xa6baaaff); + gdl = drawBody(gdl, o.left + 16 + metaWidth("CONTROLS:"), o.footerY, + s_section < 0 ? "ENTER SELECT F10 CLOSE" : + "RIGHT: CHANGE ESC BACK F10 CLOSE", 0x829e91ff); + if (s_visN - (s_section >= 0 ? 1 : 0) > o.maxRows) + gdl = drawBodyR(gdl, o.right - 10, o.sectionY, + s_scroll > (s_section >= 0 ? 1 : 0) ? "^ v" : "v", + 0x80d58bff); gDPPipeSync(gdl++); gSPEndDisplayList(gdl++); @@ -1541,8 +1780,7 @@ double optionsRowFraction(int i) if (!r || rowHi(r) <= rowLo(r)) { return 0.0; } - double f = (rowGet(r) - rowLo(r)) / (rowHi(r) - rowLo(r)); - return f < 0.0 ? 0.0 : (f > 1.0 ? 1.0 : f); + return rowFractionAt(r, rowGet(r)); } /* D356: raw value (the fraction accessors are bar geometry only); the @@ -1562,7 +1800,7 @@ void optionsRowSetFraction(int i, double f) if (f < 0.0) f = 0.0; if (f > 1.0) f = 1.0; double step = (r->step > 0.0) ? r->step : 1.0; - double v = rowLo(r) + f * (rowHi(r) - rowLo(r)); + double v = rowValueAtFraction(r, f); rowSetCommit(r, lround(v / step) * step, 0); } diff --git a/port/src/video.c b/port/src/video.c index ff9a50c7..f104aa43 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -51,12 +51,12 @@ static struct GfxRenderingAPI *renderingAPI; static int initDone = 0; /* - * [Video] ge007.ini knobs. Every default reproduces the previously-hardcoded - * behaviour, so a fresh config or a missing [Video] section changes nothing. + * [Video] ge007.ini knobs. Draw/LOD and MSAA defaults favour a clean picture + * at modern resolutions without requiring 4x anti-aliasing. */ static int cfgVSync = 1; /* swap interval: 0 = off, 1 = on */ static int cfgFpsCap = 60; /* frame cap in fps; 0 = uncapped (vsync); menu only exposes 30/60 */ -static int cfgMSAA = 4; /* 1/2/4/8 samples; default 4 (modern ports ship AA on; snaps down to the highest supported level) */ +static int cfgMSAA = 2; /* 1/2/4/8 samples; 2x default is lighter on low-end GPUs */ 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 cfgDetailBaseTile = 1; /* D236: TEXTURETYPE_DETAIL -> sample the base image, not the detail tile */ @@ -65,10 +65,10 @@ static int cfgFovScale = 100; /* D211: percent of the original vertical FOV static int cfgWidescreenAuto = 1; /* WIDESCREEN-FOV-PLAN Phase 4: auto-scale vertical FOV by window aspect ratio; on by default, no-op at 4:3 */ static int cfgNativeWidescreen = 1; /* D334 (WIDESCREEN-FOV-PLAN Phase 2): project the world at the real window aspect (Hor+); no-op at 4:3 */ static int cfgHudScale = 100; /* D226: HUD text/ammo scale %, 100 = original (no emission) */ -static int cfgDrawDistance = 150; /* D218: percent of the level's authored far-clip/fog distance. Default raised 100->150 for v0.2.0: at the authored N64 distance, props visibly fade in just before they become visible on modern displays (Dam alarms / wall switches); 150 is the value the Steam Deck preset playtest-validated. 100 = unchanged N64. */ -static int cfgDrawDistanceAutoFov = 1; /* D218: couple draw distance to Video.FovScale unless DrawDistance is set explicitly */ -static int cfgLodDistance = 150; /* D249: percent scale on the geometry/model LOD-swap distance. Default raised 100->150 for v0.2.0 (same pop-in family as DrawDistance: LOD-swapped props like Dam's alarms/wall switches faded in at range); 150 matches the Steam Deck preset. 100 = unchanged N64. */ -static int cfgLodDistanceAutoFov = 0; /* off by default -- unlike DrawDistance, this is meant as a standalone perf lever, not something that should silently get more expensive as FovScale widens */ +static int cfgDrawDistance = 250; /* % of authored far clip; 250% is the UI's 50/100 midpoint */ +static int cfgDrawDistanceAutoFov = 0; /* legacy ini option, no longer exposed in the menu */ +static int cfgLodDistance = 250; /* % of authored geometry LOD distance; 50/100 in the UI */ +static int cfgLodDistanceAutoFov = 0; /* legacy ini option, no longer exposed in the menu */ static int cfgAniso = 4; /* D212: anisotropic filtering samples; 4 = the value fast3d already applied (no visual delta at default) */ static int cfgSafeAreaCrop = 1; /* crop the N64 TV-overscan safe-area margin (visible as black top/bottom bars on PC) instead of showing it; on by default */ static int cfgFullscreen = 0; /* 0 = windowed, 1 = borderless fullscreen */ @@ -200,7 +200,7 @@ f32 portScaleFovY(f32 fovy, s32 isTitleScreen) * both the far clip plane and the fog-saturation distance (levels are tuned * for the stock ~60deg FOV), plus the character/prop fog-visibility-fade * cutoff (src/game/propobj.c chrobjFogVisRangeRelated/sub_GAME_7F054C58). - * Video.DrawDistanceAutoFov (default on) couples the multiplier to + * Video.DrawDistanceAutoFov (legacy ini option, default off) couples the multiplier to * Video.FovScale so a wider FOV doesn't clip its own newly visible far * geometry -- or fade NPCs out early -- against distances tuned for the * narrower original view (D218's "blue artifacting"; the M-121 live @@ -213,7 +213,7 @@ f32 portScaleFovY(f32 fovy, s32 isTitleScreen) * glow" on far Dam tunnel geometry, so auto coupling is now 4x FovScale) * to give headroom (max portFovScale is 1.5 at Video.FovScale's registered * ceiling of 150, so 4x tops out at 6.0x -- ceiling raised to match). - * Identity (1.0f) at both defaults, bit-for-bit no-op. NOTE: end-to-end + * Identity (1.0f) at DrawDistance=100 with auto-FOV off. NOTE: end-to-end * re-check of fogLoadCurrentEnvironment (bgfog.c) found the fog RAMP * itself (not just the far-clip cutoff) already scales correctly with * this multiplier -- g_ScaledFarFogIntensity/scaled_far_fog_dist both @@ -244,14 +244,14 @@ f32 portDrawDistanceMultiplier(void) * "closer", so this function returns the INVERSE of the requested percent: * Video.LodDistance=200 (keep full detail twice as far, more cost) -> * 0.5x on the distance term; =50 (drop to lower detail twice as soon, less - * cost -- the perf lever) -> 2.0x. Video.LodDistanceAutoFov (default OFF, - * unlike DrawDistanceAutoFov) can couple it to Video.FovScale the same + * cost -- the perf lever) -> 2.0x. Video.LodDistanceAutoFov (legacy ini + * option, default OFF) can couple it to Video.FovScale the same * direction as draw distance if ever wanted; off by default so this stays a * standalone dial and doesn't quietly add cost as FovScale widens. Clamped * to a [0.25, 4.0] distance multiplier (== effective LodDistance 25-400%) -- * far outside that band either does nothing visible (LOD never triggers) or - * thrashes every frame. Identity (1.0f) at the Video.LodDistance=100 - * default, bit-for-bit no-op. */ + * thrashes every frame. Identity (1.0f) at Video.LodDistance=100 + * with auto-FOV off. */ f32 portLodDistanceMultiplier(void) { f32 pct; @@ -376,10 +376,9 @@ PD_CONSTRUCTOR static void videoConfigInit(void) * first-run path saves these values to disk and every later launch reads the * file (user changes via F10 win outright); if an ini already exists its * values overwrite everything here. So this is a first-launch preset only. - * 1280x800 is the Deck's native panel resolution; MSAA 4 + VSync is - * comfortable headroom for the A11 GPU; DrawDistance/LodDistance at 150% - * because the authored N64 fade distances read "things pop in just before - * you can see them" on a sharp 7" close-up panel (e.g. the Dam lock). + * 1280x800 is the Deck's native panel resolution; 2x MSAA + VSync keep + * GPU cost modest. Draw/LOD distances use the standard midpoint (250%) + * to avoid obvious N64-era pop-in on modern displays. * Note: the panel is 16:10 and the game renders 4:3, so this stretches * uniformly like any non-4:3 window today (letterboxing is the parked * WIDESCREEN-FOV-PLAN). */ @@ -389,9 +388,9 @@ void videoApplySteamOSDefaults(void) cfgWinW = 1280; cfgWinH = 800; cfgVSync = 1; - cfgMSAA = 4; - cfgDrawDistance = 150; - cfgLodDistance = 150; + cfgMSAA = 2; + cfgDrawDistance = 250; + cfgLodDistance = 250; } /* Set by videoRequestLiveConfig() (F10 overlay, host thread); consumed on the @@ -742,12 +741,12 @@ void videoPumpEvents(void) optionsOverlayToggle(); } } else if (ev.key.keysym.sym == SDLK_ESCAPE && !ev.key.repeat) { - /* Overlay open: ESC closes it (and is swallowed). Otherwise + /* Overlay open: ESC backs out of a category, then closes (swallowed). Otherwise * WI-1: in click-to-lock mode ESC frees the captured cursor * (and is swallowed); else it falls through to input.c where * it feeds the N64 B button (D145). */ if (optionsOverlayIsOpen()) { - optionsOverlayToggle(); + optionsOverlayBack(); } else { inputReleaseCapture(); } diff --git a/port/src/watchsettings.c b/port/src/watchsettings.c index b7bec7e6..68751fa5 100644 --- a/port/src/watchsettings.c +++ b/port/src/watchsettings.c @@ -67,12 +67,18 @@ static int staged[WATCH_SETTING_COUNT]; static int stagedField = -1, stagedFolder = -1; static SDL_SpinLock uiLock; -/* Bounded producer (F10 scheduler) -> consumer (game thread) queue. Snapshot - * is also guarded; no game/watch reads from F10 render or input paths. */ +/* Bounded producer (F10 controller poll) -> consumer (game thread) queues. + * Snapshot is also guarded; F10 input/render never call GE save routines. */ struct Command { int folder, field, value, commit; }; #define CMD_CAP 128 static struct Command cmds[CMD_CAP]; static int head, count; +/* Front-end F10 also runs on the controller poll thread. fileWriteSave() + * calls joyDisablePoll() and waits for that SAME thread to acknowledge it: + * writing here deadlocks. Keep front commits separate from stage commands; + * the game-thread tick drains them without touching the controller thread. */ +static struct Command frontCmds[CMD_CAP]; +static int frontCount; static int snapshot[WATCH_SETTING_COUNT]; static int snapFolder = -1; static SDL_SpinLock lock; @@ -159,8 +165,8 @@ int watchSettingsActiveFolder(void) return validSave(f) ? f : -1; } -/* D356: queued field commands awaiting the game thread (0 in front - * contexts -- nothing is queued there). Drives the in-stage reset probe's +/* D356: stage commands awaiting the game thread (front F10 uses a separate + * queue). Drives the in-stage reset probe's * dispatch check (plan §5.8). */ int watchSettingsQueueCount(void) { @@ -344,14 +350,39 @@ void watchSettingsSet(enum WatchSettingField field, int value, int commit) if (!stageActive()) { int f = frontFolder(); SDL_AtomicLock(&uiLock); - if (commit) { - stagedField = -1; - SDL_AtomicUnlock(&uiLock); - saveFrontField(f, field, value); - } else { - stagedField = field; stagedFolder = f; staged[field] = value; + if (!optionsOverlayIsOpen()) { + /* The front options screen and dev probes run on the game thread. */ + if (commit) stagedField = -1; + else { stagedField = field; stagedFolder = f; staged[field] = value; } SDL_AtomicUnlock(&uiLock); + if (commit) saveFrontField(f, field, value); + return; } + /* F10 is polled from joyPoll() via osContStartReadData. Even a + * fileGamePakProbe here deadlocks: joyDisablePoll waits for joyPoll's + * acknowledgement. Stage the value for UI feedback and enqueue ONLY + * under a short lock; the game thread does the EEPROM I/O. */ + stagedField = field; stagedFolder = f; staged[field] = value; + SDL_AtomicUnlock(&uiLock); + SDL_AtomicLock(&lock); + int i; + for (i = 0; i < frontCount; i++) { + if (frontCmds[i].folder == f && frontCmds[i].field == (int)field) break; + } + if (i == frontCount && frontCount >= CMD_CAP) { + SDL_AtomicUnlock(&lock); + sysLogPrintf(LOG_WARNING, "watchsettings: front command queue full; edit not applied"); + return; + } + if (i == frontCount) { + frontCount++; + frontCmds[i].commit = 0; + } + frontCmds[i].folder = f; + frontCmds[i].field = field; + frontCmds[i].value = value; + frontCmds[i].commit = commit || frontCmds[i].commit; + SDL_AtomicUnlock(&lock); return; } SDL_AtomicLock(&lock); @@ -446,6 +477,26 @@ static void applyValue(enum WatchSettingField field, int value) void watchSettingsGameTick(void) { + /* Drain F10 front-end commits on the game thread. This MUST run before + * the early return for non-stage contexts below. Never hold the queue + * lock through fileWriteSave/joyDisablePoll: joyPoll must stay runnable. */ + struct Command frontBatch[CMD_CAP]; + int nf; + SDL_AtomicLock(&lock); + nf = frontCount; + memcpy(frontBatch, frontCmds, (size_t)nf * sizeof(frontBatch[0])); + frontCount = 0; + SDL_AtomicUnlock(&lock); + for (int i = 0; i < nf; i++) { + struct Command *c = &frontBatch[i]; + if (c->commit) saveFrontField(c->folder, (enum WatchSettingField)c->field, c->value); + SDL_AtomicLock(&uiLock); + if (stagedFolder == c->folder && stagedField == c->field && + staged[c->field] == c->value && c->commit) + stagedField = -1; + SDL_AtomicUnlock(&uiLock); + } + /* D356: the GE_WSPROBE_RESET env gate drives the real reset UI dispatch * (arm -> confirm -> handler, no bypass) through optionsoverlay.c's * probe hooks. Each phase waits for its context to be stable (~120