diff --git a/.github/SECURITY.md b/.github/SECURITY.md index bcfe381d..192f0203 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -35,6 +35,27 @@ run into as a result: actions it uses (by commit SHA), so what ships is reproducible from this repository. +### Verifying a release build (build provenance) + +From **v0.5.0** on, the tag-only CI job attaches an in-toto SLSA +build-provenance attestation to each release artifact (the zip/tarball and +its `.sha256` sidecars). The attestation is signed by GitHub's OIDC key for +*this repository's* workflow running the tagged commit. It does not replace a +code signature — but it proves that the exact file you downloaded was produced +by this repo's CI from the release tag, and nothing else. Verify any +downloaded artifact with: + +```sh +gh attestation verify goldeneye-pc-port-0.5.0-win64.zip \ + --repo jkdansereau/goldeneye-pc-port +``` + +(use the name of the file you downloaded, e.g. +`goldeneye-pc-port-0.5.0-linux-x86_64.tar.gz` on Linux). A passing check confirms both +the file's digest and its provenance; a tampered or foreign file fails. +Releases before v0.5.0 have no attestations — for those, the per-file +`.sha256` sidecars and a from-source build are the available checks. + ## Reporting a vulnerability Please **do not** open a public issue for a security problem. diff --git a/.github/release-notes.md b/.github/release-notes.md index 6cf631ae..90bff607 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -118,7 +118,7 @@ back. Fake unlocks written by older builds are not repaired, so back up uses the level's original distance; you can still see, shoot and use everything that's drawn. - **Ultrawide and high field of view (D468):** the AI keeps the N64's widest - (16:9) view. Turn on *AI sees the wider view* if you prefer the old + (16:9) view. Turn on *Guards see the wider view* if you prefer the old behaviour. - **Sniper rifle (#136):** the scope zooms all the way in again (it was capped at about a third of the original magnification, D484); zoom speed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b567f845..2e92a89e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,7 +198,7 @@ jobs: python3-pip libsdl2-dev zlib1g-dev libgl1-mesa-dev ccache patchelf - name: Restore ccache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.CCACHE_DIR }} key: ccache-linux-${{ hashFiles('CMakeLists.txt') }}-${{ github.sha }} @@ -316,7 +316,7 @@ jobs: run: tools_pc/check-selfhosted-deps.sh - name: Restore ccache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.CCACHE_DIR }} key: ccache-windows-${{ hashFiles('CMakeLists.txt') }}-${{ github.sha }} @@ -388,28 +388,44 @@ jobs: # Tags only. Attach the packaged Windows + Linux bundles to a DRAFT # pre-release so a human can do the ROM smoke-test on each platform before - # publishing. This job — and only this job — gets contents: write. + # publishing. This job — and only this job — gets contents: write, and it + # is also the only job that attests build provenance (id-token: write + + # attestations: write; attest-build-provenance needs both). release: if: github.ref_type == 'tag' needs: [ changes, validate, windows-build, linux-build ] runs-on: ubuntu-22.04 permissions: contents: write + id-token: write + attestations: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: goldeneye-pc-port-win64 path: dist - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: goldeneye-pc-port-linux-x86_64 path: dist + # SLSA build provenance for the release artifacts (in-toto, signed by + # GitHub's OIDC key for this repo's workflow at the tagged commit). + # Anyone can check a downloaded file: + # gh attestation verify --repo jkdansereau/goldeneye-pc-port + - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: | + dist/*.zip + dist/*.zip.sha256 + dist/*.tar.gz + dist/*.tar.gz.sha256 + - name: Create or update draft pre-release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/selfhosted.yml b/.github/workflows/selfhosted.yml index 7e9e823e..374e8b80 100644 --- a/.github/workflows/selfhosted.yml +++ b/.github/workflows/selfhosted.yml @@ -137,7 +137,7 @@ jobs: # Same opt-in ccache CMakeLists.txt wires up (speed-ups plan Step 5); # persisted via actions/cache like ci.yml's windows-build. - name: Restore ccache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.CCACHE_DIR }} key: ccache-selfhosted-${{ hashFiles('CMakeLists.txt') }}-${{ github.sha }} diff --git a/AGENTS.md b/AGENTS.md index 246deaf4..4df5e73e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,20 @@ Needs CMake + SDL2 + zlib + OpenGL, and must run from the MSYS2 MINGW64 shell shell the re-exec itself is legitimately needed (no TMP), so the probe is correct. Verified: MSYS2-PATH shell, forced `TMP=C:\Windows\`, and a no-change incremental run all exit 0 with a fresh exe. + **FIXED 2026-10-04 (`fix/build-arch-tag`, D515):** a FRESH build dir + configured through the re-exec cached `CMAKE_HOST_SYSTEM_PROCESSOR ""` / + `CMAKE_SYSTEM_PROCESSOR ""` (the stripped env also drops + `PROCESSOR_ARCHITECTURE`, which CMake reads for the host processor — same + class as PATHEXT/ComSpec), so `cmake/TargetArch.cmake` produced an empty + arch tag: `Target arch: (64bit=FALSE)`, binary named `ge007..exe` + (a correct 64-bit build; only the name was wrong — found during T7, D513). + The `.ps1` now restores `PROCESSOR_ARCHITECTURE` from the machine + environment (fallback `AMD64`; `SystemRoot`/`windir` likewise) and logs + the four vars to the diag log; `cmake/TargetArch.cmake` falls back to + `${CMAKE_C_COMPILER} -dumpmachine` when `CMAKE_SYSTEM_PROCESSOR` is empty + and FATAL_ERRORs instead of emitting an untagged binary. Existing build + dirs are unaffected (value already cached); a dir configured before the + fix picks up x86_64 on its next in-place reconfigure. 2. **`cannot open output file ge007.x86_64.exe: Permission denied`.** A **running** `ge007.x86_64.exe` locks the output file (Windows rule; you can't relink over a live PE). Check with `Get-Process | Where-Object { diff --git a/README.md b/README.md index 4c36bc82..bcb0c810 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,15 @@ SDL-supported pads); split-screen needs one controller per extra player. click **More info**, then **Run anyway**. 4. The first start takes a few extra seconds: the port reads your ROM and generates its asset files once. After that it starts normally. -5. Play. **F10** (pad: **Select**) opens the options overlay; the +5. Play. **F10** (pad: **Select**) opens the options overlay; on the file-select + screen the *PC Options* entry opens the same overlay. The [Controls](#controls) table lists the default keys. **Steam Deck:** the Linux tarball is the Deck build. Download it on the Deck (or copy it over from your PC), extract it, add your ROM to `data/`, launch it once, and add the executable as a non-Steam game. On first start it applies Deck-friendly display defaults (native 1280×800 fullscreen); if the -resolution is wrong, set it under F10 → *Resolution*. +resolution is wrong, set it under F10 → *Video → Resolution*. --- @@ -130,16 +131,35 @@ resolution is wrong, set it under F10 → *Resolution*. The keyboard defaults are a GEPD-style layout. The controller layout matches Rare's Xbox 1.1 (Jinx) button roles; the other Xbox styles (1.2 Christmas, 1.3 Frost, 1.4 Elektra, which swap the stick roles) are selectable under -*Input → Controller → Layout preset*. PlayStation and Nintendo controllers +*Controller* (pick a player first, then *Layout preset*). PlayStation and Nintendo controllers show their own button names in the menus. -**Rebinding:** *Input → Bindings…* in the options overlay rebinds the -keyboard and mouse (*Movement…* and *Actions…*). Controller buttons are -rebindable per controller under *Input → Controller*: set *Layout preset* to +**Control style:** *Controller → Control style* (named after the watch's own +setting, with Perfect Dark's "Ext" for the port's controls) picks how the +game's controller style (the watch's 1.1–1.4 selector) is treated. **Ext** +(the default) keeps the port's feel and fixes the style itself, so the watch's +selector has no effect. **Original** stops the port from touching the style: +pick 1.1, 1.2, 1.3 or 1.4 on the watch (or in the multiplayer setup) and it +behaves as on the N64, and the Controller page then offers the game's own +styles ("Controller style", 1.1 Honey to 2.4 Goodhead, saved in the Bond file +like the watch's) instead of the layout presets, which apply in Ext only. Buttons are passed +through as the N64's buttons, so 1.3 and 1.4 move fire and aim exactly as the +console does. The 2.x dual-controller styles run faithfully too: the game +reads their second stick from the next controller slot, which the port does not +map yet (a second pad assigned to that slot should provide it, untested), so with +one pad pick a 1.x style. Saves made with earlier builds may hold 1.2 (the port wrote its +forced style back into the save), so when you switch to the Original control style, pick +your style on the watch once. + +**Rebinding:** *Key Bindings* in the options overlay rebinds the +keyboard and mouse (one list: movement, then actions). Controller buttons are +rebindable per controller under *Controller* (pick a player first): set *Layout preset* to *Custom*, select an action and press a pad button (tap B or Esc to cancel, hold B to bind B itself, hold Back to clear; Y clears the selected slot). Each action takes up to two buttons. Menus always keep A/X accept and B/Y -cancel, so you can't lock yourself out. +cancel, so you can't lock yourself out. There is no warning yet when two +actions share a button, and the Xbox release's "left stick aims the +crosshair" and "D-pad copies the left stick" behaviors are not reproduced. **Split-screen:** pick Multiplayer from the main menu. With one controller, keyboard/mouse is player 1 and the controller is player 2; with two or more @@ -160,22 +180,24 @@ Select again) closes. With a keyboard, use `F10` and the arrow keys/Enter. sits at the screen edges, and 4:3 menus are pillarboxed. FOV and draw/LOD distance are adjustable, and AI behaviour stays as on the N64 whatever you set them to. -- **The original look in one click:** the *Original N64 preset* restores the - N64's settings, *Aspect → Original* shows the exact 4:3 frame, and the - *Port defaults preset* goes back. +- **The original look in one click:** the *Display mode* row (F10 → *Video*) switches + between *Modern* and *Original N64* (it reads *Custom* once you edit a setting it covers), + and *Aspect ratio → Original* shows the exact 4:3 frame. - **A steady 60 fps**, with VSync and a frame cap (30 or 60), including on low-end GPUs. -- **Graphics options:** resolution, borderless fullscreen, MSAA (up to 16×), - anisotropic filtering, and nearest, bilinear, trilinear or the N64's own - 3-point texture filter. -- **Mouse aim** in your choice of style (the N64's, or centred FPS-style) with +- **Graphics options:** resolution, borderless or exclusive fullscreen, + a Center window action while windowed, MSAA (up to 16×), anisotropic + filtering, and nearest, bilinear, trilinear or the N64's own 3-point + texture filter. +- **Mouse aim** in your choice of style (the N64's, or centered FPS-style) with per-device sensitivity, smoothing and Y-inversion. - **Modern controller support:** dual-stick layout, deadzones, sensitivity, southpaw, trigger thresholds, rumble-pak vibration, and PlayStation/Nintendo button names. - **Rebindable controls** for keyboard, mouse and each controller. -- **HUD and crosshair options:** HUD scale, an optional customizable - crosshair, and a no-hit-flash option. +- **HUD and crosshair options:** HUD scale (also scales the in-game options + menu), an optional customizable crosshair with adjustable opacity and a + health-based color, and a no-hit-flash option. - **Full audio:** in-level music and sound effects, with music, FX and master volume and a choice of output device. - **An in-game options overlay** (F10 / pad Select) for all of the above, @@ -207,6 +229,10 @@ only in the release packages, and no macOS/ARM builds yet. What a release installs (no networking, no telemetry, no ROM or game assets) and how faithfully the port tracks the original game's logic: [Security & fidelity status](docs/security-and-fidelity-status.md). +The binaries are not code-signed; from v0.5.0 on each release artifact also +carries a GitHub build-provenance attestation you can check with +`gh attestation verify --repo jkdansereau/goldeneye-pc-port` +(see [SECURITY.md](.github/SECURITY.md)). --- @@ -214,9 +240,12 @@ and how faithfully the port tracks the original game's logic: **Options overlay:** press **F10** (pad: **Select**) in game or in the menus. Changes are saved automatically. Most settings live in `ge007.ini`; the -ones that come from the game's own watch menu (auto-aim, look ahead, sight -and ammo on screen, music and FX volume) are part of your save, as on the -N64. +ones that come from the game's own watch menu (look up/down, auto-aim, aim +control, sight, look ahead, ammo on screen, screen size, screen ratio, music +and FX volume) are part of your save, as on the N64. Screen size (Full / Wide / +Cinema) and screen ratio (Normal / 16:9) combine with the PC **Aspect ratio** +setting: Aspect ratio owns the window shape, while the watch's two options +only change what the game renders inside it. **Where files live:** everything stays inside the game's own folder. The port never writes to `%APPDATA%`, the registry or any system location. @@ -281,6 +310,28 @@ and `ge007.ini` from the old `data/` folder into the new one. Don't copy the generated `pcmodels-*` and `pccg-*` folders: the new version makes its own on first start, and stale ones from an older version can cause glitches. +## Using an emulator save + +Saves from N64 emulators (1964, Project64, …) load directly: with the game +closed, just copy your `.eep` to `data/ge007.eep`. On first launch the game +detects the emulator byte order and converts it in place, keeping a backup of +the original file next to it as `ge007.eep.emulator.bak` (never overwriting an +existing one). Any slot that is corrupt in both formats is left alone and gets +reset by the game, exactly as before. An existing port save is untouched. + +To take a save back into an emulator, use the small converter that ships in +the release folder as `tools/eep_convert.py` +(`tools_pc/eep_convert.py` in a source checkout; plain Python 3, no +dependencies): + +```sh +python tools/eep_convert.py pc-to-n64 data/ge007.eep back-to-emulator.eep # port -> emulator +python tools/eep_convert.py verify some-file.eep # which format is this? +``` + +`verify` checks every save checksum and, without `--as n64|pc`, tells you +which convention the file uses. + ## Uninstalling Delete the folder. The port writes nothing anywhere else, so that removes the @@ -297,7 +348,7 @@ want to keep your progress. **More info**, then **Run anyway**. - **Black screen or no window:** update your graphics driver; the port needs OpenGL 3.0. -- **Wrong resolution on the Steam Deck:** F10 → *Resolution*. +- **Wrong resolution on the Steam Deck:** F10 → *Video → Resolution*. - **Where are my saves?** `data/ge007.eep`. Copy it somewhere safe while the game is not running to back it up. - **The game crashes:** open an @@ -322,9 +373,6 @@ place: [`docs/ROADMAP.md`](docs/ROADMAP.md). In brief: - **macOS and ARM builds**. - The remaining small accuracy differences, each checked against the N64 game in an emulator. -- **Emulator saves**: a converter so saves from 1964, Project64 and other N64 - emulators can be moved to and from the port (they need converting today; a - straight copy is wiped). - **1.0 sign-off**: a full campaign playthrough at more than one difficulty plus a split-screen session, on every platform that ships. diff --git a/build-pc.sh b/build-pc.sh index 9e1a7c77..b5c149cc 100644 --- a/build-pc.sh +++ b/build-pc.sh @@ -151,6 +151,23 @@ $env:PATH = "$UsrBin;$Bin;C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbe # stays $null (the rc=2 "did not execute" regression). Restore them. $env:PATHEXT = '.COM;.EXE;.BAT;.CMD' if (-not $env:ComSpec) { $env:ComSpec = 'C:\Windows\System32\cmd.exe' } +# D515: same class of bug as PATHEXT/ComSpec above — a stripped re-exec env +# can also drop PROCESSOR_ARCHITECTURE, which CMake reads to fill +# CMAKE_HOST_SYSTEM_PROCESSOR; empty value -> empty TARGET_ARCH -> "ge007..". +# Restore it from the machine environment (fallback 'AMD64'), and restore +# SystemRoot/windir the same way (standard Windows vars native tools expect). +if (-not $env:PROCESSOR_ARCHITECTURE) { + $env:PROCESSOR_ARCHITECTURE = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE', 'Machine') + if (-not $env:PROCESSOR_ARCHITECTURE) { $env:PROCESSOR_ARCHITECTURE = 'AMD64' } +} +if (-not $env:SystemRoot) { + $env:SystemRoot = [Environment]::GetEnvironmentVariable('SystemRoot', 'Machine') + if (-not $env:SystemRoot) { $env:SystemRoot = 'C:\Windows' } +} +if (-not $env:windir) { + $env:windir = [Environment]::GetEnvironmentVariable('windir', 'Machine') + if (-not $env:windir) { $env:windir = 'C:\Windows' } +} # Writable temp for the native toolchain. [System.IO.Path]::GetTempPath() # CANNOT be used here: it honours the inherited (broken) TMP/TEMP env vars # and returned C:\Windows\. Pick the first writable candidate instead. @@ -169,6 +186,10 @@ if ($null -eq $tmpdir) { "no writable temp dir found" | Out-File $diag -Encoding $env:TMP = $tmpdir $env:TEMP = $tmpdir "start UsrBin=[$UsrBin] Bin=[$Bin] MsysRoot=[$MsysRoot] Here=[$Here] BuildDir=[$BuildDir] RomId=[$RomId] tmp=[$tmpdir]" | Out-File $diag -Encoding ascii +# D515 diag: the stripped re-exec env can also drop PROCESSOR_ARCHITECTURE, +# which CMake reads to fill CMAKE_HOST_SYSTEM_PROCESSOR (empty value -> +# empty TARGET_ARCH -> "ge007.."). One line, cheap, useful next time. +"env PROCESSOR_ARCHITECTURE=[$env:PROCESSOR_ARCHITECTURE] PROCESSOR_ARCHITEW6432=[$env:PROCESSOR_ARCHITEW6432] SystemRoot=[$env:SystemRoot] windir=[$env:windir]" | Out-File $diag -Append -Encoding ascii $exe = "$Bin\cmake.exe" "exe=[$exe] exists=[$(Test-Path -LiteralPath $exe)]" | Out-File $diag -Append -Encoding ascii # A missing exe makes `& $exe` throw (caught below) and leaves LASTEXITCODE diff --git a/cmake/TargetArch.cmake b/cmake/TargetArch.cmake index b2558475..947cc814 100644 --- a/cmake/TargetArch.cmake +++ b/cmake/TargetArch.cmake @@ -6,6 +6,14 @@ function(target_architecture OUT_VAR) # Derive from the cross-compiler target, e.g. x86_64-w64-mingw32-gcc string(REGEX MATCH "^(i[3-6]86|x86_64|aarch64|arm|powerpc|wasm32)" _arch "${CMAKE_C_COMPILER_TARGET}") + elseif(CMAKE_SYSTEM_PROCESSOR STREQUAL "") + # D515: a stripped native env (e.g. the build-pc.sh PowerShell re-exec + # before it restored PROCESSOR_ARCHITECTURE) leaves + # CMAKE_HOST_SYSTEM_PROCESSOR / CMAKE_SYSTEM_PROCESSOR empty. Ask the + # compiler instead of silently producing an untagged binary ("ge007.."). + execute_process(COMMAND "${CMAKE_C_COMPILER}" -dumpmachine + OUTPUT_VARIABLE _dm OUTPUT_STRIP_TRAILING_WHITESPACE) + string(REGEX MATCH "^(i[3-6]86|x86_64|aarch64|arm|powerpc|wasm32)" _arch "${_dm}") else() if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$") set(_arch "x86_64") @@ -24,5 +32,9 @@ function(target_architecture OUT_VAR) set(_arch "i686") endif() + if(_arch STREQUAL "") + message(FATAL_ERROR "TargetArch: could not determine the target architecture (CMAKE_SYSTEM_PROCESSOR empty and compiler -dumpmachine did not match); refusing to build an untagged binary -- the 'ge007..' bug, see D515.") + endif() + set(${OUT_VAR} "${_arch}" PARENT_SCOPE) endfunction() diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e1789b61..d53035ae 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,17 +61,15 @@ difficulty, plus a split-screen session, on a build with the §3 verification debt cleared. Where things stand (2026-10-01): 1–7 hold for NTSC-U, with the small -accuracy gaps listed in §2. 8 is merged on `release/v0.4.1` (2P verified on -every MP map; 3P/4P with real pads owed). 9 is open (Stage B). - -**Parity rule (decided 2026-09-30):** every platform we release for -carries the same sign-off gate: a full 21-level sweep plus a campaign -pass. Windows and Linux now; macOS when it joins. +accuracy gaps in §2. 8 is merged on `release/v0.4.1` (3P/4P with real pads +owed). 9 is open (Stage B). **Parity rule (2026-09-30):** every released +platform carries the same gate: a full 21-level sweep plus a campaign pass. ## Order of work (decided 2026-09-30) -Work continues on `release/v0.4.1`. When to cut it, and what to call it -(v0.5.0 or later), is decided as the work lands. There are two stages: +Work continues on `release/v0.4.1`, **to ship as v0.5.0 (named 2026-10-03, +maintainer)** — cut after the playtest session and the easy folds land +(push checklist: `dev/notes/PUSH-TASKS-v041.md`). There are two stages: **Stage A: now → the pre-1.0 build** 1. Clear the §3 verification debt. @@ -93,18 +91,14 @@ Work continues on `release/v0.4.1`. When to cut it, and what to call it 3. The exit gate on every shipped platform. 4. Remove the temporary scaffolding (§7). -**Next (decided 2026-10-03, after the Birdman64 comparison;** findings and -lessons in the maintainer's local note `BIRDMAN64-LESSONS.md`**):** -1. D441: finish the census as one complete, lead-verified list, compiler - pass in both directions plus the compiler-invisible classes (§2). - **Done 2026-10-03.** +**Next (decided 2026-10-03, after the Birdman64 comparison; local note `BIRDMAN64-LESSONS.md`):** +1. D441 census: **done 2026-10-03** (§2). 2. Fix the listed sites as mechanical, build-checked batches on a cheap - worker in its own worktree; the lead reviews every diff (§7). - **Batches 1-5 done 2026-10-03** (35 sites, driven by the tier-1 - high-arena build's crash order). Next: the tier-1 sweep over every - level with a seeded frame compare, then further batches from it. -3. Scripted-playthrough harness: a feasibility spike first. Aiming, - shooting, guards and timed events may block open-loop scripts (§7). + worker; the lead reviews every diff (§7). **Batches 1-5 done + 2026-10-03.** Next: the tier-1 sweep over every level with a seeded + frame compare, then further batches from it. +3. Scripted-playthrough harness: moved to post-1.0 (maintainer + 2026-10-04; revisit with newer models) (§7). ## Known issues @@ -117,11 +111,12 @@ below). | Issue | Impact | Workaround | Tracking | |---|---|---|---| | PAL and JP ROMs aren't supported in release packages | NTSC-U only | Use an NTSC-U ROM. Both regions convert, build and boot from source (D258, D487, D488; see `docs/building.md`); packaging (per-region exe, CI matrix, converter region check) is still open | D258, #85, §1 | +| Changing Aspect ratio in a level (mostly Fill window to Original or a forced ratio) can briefly glitch the gun / hand model, rarely | Cosmetic, one-off | Change the ratio from the front-end PC Options, or accept it | D509 (frame hold fixed the common case; the rare remainder is logged, not investigated) | | No macOS or ARM builds | Platform | — | #88, #95, #101, §4 | | Saves from builds before v0.4.1 can hold fake unlocks from `All unlocked` | Save data | Not repaired automatically. Since v0.4.1 the option never writes the save; keep a backup of `data/ge007.eep` from before you used it | D442, D387 | | `Skip intro` and `All unlocked` are experimental | Opt-in options | Leave them off for a normal playthrough | D408, D442 | | The first frame of a level takes a little longer while its textures upload | Brief FPS-counter dip | None needed | D475, D480 | -| Far objects almost fully hidden in fog can show as a faint silhouette with straight vertical edges (e.g. Surface's satellite dish from the start area). The N64's 16-bit colour hid the 2-5 level difference; the port's 32-bit output shows it | Cosmetic, any settings | None; raising Draw/LOD distance makes more such objects visible | D503, §5c | +| Far objects almost fully in fog are now hidden as on the N64, except in widescreen, where the wider view can still show a faint distant building edge (e.g. Surface's dish from the start area). 4:3 matches the N64 | Cosmetic | None; higher Draw/LOD distance shows more | D503, §5c | --- @@ -129,8 +124,8 @@ below). | Item | Status | Refs | Notes | |---|---|---|---| -| **2–4 player split-screen multiplayer** | verify | #99, D416–D423, D496 | Merged 2026-10-01. D417 Rule-2 approved. **Maintainer playtest 2026-10-03:** 2P and 3P with real Xbox and PS5 pads, handicaps, teams and game modes all good; the short 3P/4P pickup text is the game's own (D496 fixed an uninitialised buffer behind the weapon text). Earlier: every MP map in 2P, 4P Temple. **Still owed:** 4P with real pads, pads-only mode, MP audio, D422 centred aim live on P2+ (`AimMode = 1`), D428 sway on P2+. | -| **PAL and JP ROM support** | partial | D258, D487, #85 | **2026-10-02: both regions convert, build and boot** (D258 fixed and byte-verified against real PAL and JP ROMs; region-aware emitters and ROM detection; PAL link fix). Both play Dam with no crash; NTSC-U unchanged (`verify.sh dam` PASS). Build: `BUILD_DIR=build-pal ./build-pc.sh pal-final` / `build-jpn ... jpn-final`, with `data/ge007..z64` and the emitters run for that region. **D487 fixed 2026-10-02** (PAL frame height decoded with the NTSC rule cut the bottom ~12% of every PAL frame). **D488 fixed 2026-10-02** (PAL build now reports the PAL TV type, not Brazil's MPAL). **Open:** full runtime passes per region (all levels, menus, audio, saves; PAL 50 Hz feel incl. the D427/D451/D486 2-ticks/frame gates; EU-only text paths); packaging (per-region exe names, CI matrix, drop-in converter region gate, #6). ROMs: `ge-port-reference/1964_GEPD_Edition/ROMs` (JP .n64 = v64, PAL `.z64`, both hash-verified). #85 needs reopening or a fresh tracker when this ships. | +| **2–4 player split-screen multiplayer** | verify | #99, D416–D423, D496 | Merged 2026-10-01. D417 Rule-2 approved. **Maintainer playtest 2026-10-03:** 2P and 3P with real Xbox and PS5 pads, handicaps, teams and game modes all good (D496 fixed an uninitialised weapon-text buffer). **Still owed:** 4P with real pads, pads-only mode, MP audio, D422 centred aim live on P2+ (`AimMode = 1`), D428 sway on P2+. | +| **PAL and JP ROM support** | partial | D258, D487, #85 | **2026-10-02: both regions convert, build and boot** (D258 byte-verified against real PAL and JP ROMs); both play Dam with no crash; NTSC-U unchanged. Build: `BUILD_DIR=build-pal ./build-pc.sh pal-final` / `build-jpn ... jpn-final` with `data/ge007..z64`. D487, D488 fixed (PAL frame height, TV type). **Open:** full runtime passes per region (levels, menus, audio, saves; PAL 50 Hz feel incl. the D427/D451/D486 gates; EU-only text); packaging (per-region exe names, CI matrix, converter region gate, #6). ROMs: `ge-port-reference/1964_GEPD_Edition/ROMs`. #85 needs reopening or a fresh tracker when this ships. | | Drop-in ROM converter, remaining parts | decision | #6, [`dev/release-dropin-rom-plan.md`](dev/release-dropin-rom-plan.md) | NTSC-U drop-in shipped. Left: per-region output names and re-exec guard (with PAL/JP), native C emitters to replace the PyInstaller converter. | | Cut content (TCRF weapons/items) | decision | [`dev/CUT-CONTENT-BACKLOG.md`](dev/CUT-CONTENT-BACKLOG.md) | Not part of the shipped game. Tier 0 already works via the debug/cheat menus. Tier 1 (config-toggle grants) is small and port-only. Tier 2/3 (placing props, re-enabling unused MP maps) need game data changes and are out of scope for this repo. | @@ -140,13 +135,14 @@ below). |---|---|---|---| | Third-person Bond offset residual | partial; intro puppet explained (2026-10-02) | D173, D292 | Mostly fixed. On the level-start puppet the variance is tick/PRNG-dependent idle timing (D13 class, faithful, no sign-off requested); one 1-frame first-tick Y transient is untraced. If the residual is seen elsewhere (cutscene body model), the maintainer should note where. | | Dam ending cutscene: Bond placement; railing transparency | open | D173, D292; maintainer 2026-10-02 | **Maintainer to re-check the end of Dam** (the last place a Bond offset was seen; the D173 level-start probe found no defect). Also seen in the Dam cutscene: an edge railing texture (a 2D element, maintainer believes) with odd transparency; possibly already fixed by the alpha pass. Capture a screenshot of each if still present. | +| Title-screen attract demo runs too fast; F10 box rescales while it plays | backlogged (after v0.5.0, maintainer 2026-10-04) | D87 (ramrom file struct); timing: D13, D248, D427, D451, D486 | **Reported 2026-10-04 (maintainer live pass):** the attract segment plays visibly too fast; not investigated. Lead: it is a ramrom demo replay whose recordings carry a per-frame tick count, which the port's 60 fps / 1-tick pacing may override. Rule out PC causes, then compare with 1964. Same pass: the F10 box briefly rescaled during the demo (not reproduced; candidate fix = overlay reads fast3d's native viewport, parked locally). | | Dam: first-tower guard walks a small circle (glass collision polygon) | done | D490 | **Fixed 2026-10-02, merged into `release/v0.4.1` 2026-10-03:** a stack overflow in `sub_GAME_7F03ECC0` (flat-bbox objects, i.e. glass) corrupted the collision polygon; `rem[8]` under `AVOID_UB`. Headless probe: the guard now walks his whole patrol loop. Maintainer by-eye check in Dam passed 2026-10-02. The porting-notes D24 audit ran 2026-10-03 and found D491 (below); no other overflow of this kind. | | Struct reads across adjacent globals: portal bbox seeds put props in extra rooms; casing spin seeded from trigger state | done | D491 | **Fixed 2026-10-03, merged into `release/v0.4.1`:** `bg.c` / `gunfire.c` read three separate scalar globals as one coord3d; mingw reverses their order. `#ifdef PORT` reads the named globals. Builds clean, 900-frame `-level_09` smoke clean. Nothing by eye is expected. Delete this row once released. | | Sniper rifle: zoom level and speed vs 1964 | verify | #136, D484, D485, D494 | Crouch: **D494** (2026-10-03) makes the PC crouch key crouch/stand freely with the sniper, as GEPD does (superseding D483's freeze); maintainer-verified. Zoom level (D484, 20 -> 7 degrees) and speed (D485, tick-scaled, Rule-2) fixed 2026-10-02. **Owed:** 1964 A/B of zoom level and speed; close #136 after it. | | Crash on save slots 1/3/4, plus a host BSOD (fltmgr.sys) | decided | #94 | Needs a repro. Two maintainer retest requests (v0.3.0, v0.4.0) got no reply. Likely D299/D301 (save-slot crash, fixed) and D344 (BSOD on exit, fixed). **Decided 2026-10-01: close as stale when the release ships**, pointing at D299/D301/D344; no GitHub activity before then. | | Authored mip chains not sampled for explicit-LOD textures | post-1.0 | D323 | Distant textures use driver mips instead of Rare's hand-authored chain (heaviest on Statue/Aztec). Parked as a fidelity enhancement. **Decided 2026-10-01:** after 1.0, together with the LOD-fraction approximation as one "real RDP LOD" project. | | Approximations in the software RSP | post-1.0 | `gfx_pc.cpp` `G_CCMUX_LOD_FRACTION`; D323 | The LOD-fraction CC input is an eyeballed approximation, bundled with D323 as one "real RDP LOD" project after 1.0 (2026-09-30 triage). **WrapFix settled 2026-10-02:** the maintainer checked the Depot building ceilings against 1964 with the default `Video.WrapFix = 0` and they look right, so the default stays off (the opt-in knob remains; RC3/D167). | -| Latent 64-bit ABI hazards | partial | D441, #107, #108; ~396 `-Wpointer-to-int-cast` sites at the start of the sweep (185 remain in the 17 touched files; many are intentional `(s32)&ANIM_DATA_*` offsets, D34); AUDIT-M6 `struct player`/`struct hand` raw offsets (porting-notes §A1) | **Next (2026-10-03): one complete census list** replacing parts 1/2 (part 3 was fabricated): re-run `scratch/d441p2/run.py` with `-Wpointer-to-int-cast -Wint-to-pointer-cast -Wint-conversion` (mingw is LLP64, so this already matches Birdman64's "long forced to 32 bits" pass), add the classes no compiler flags (raw `sizeof` copies of structs with pointer slots, union/raw-offset aliases, u32 addresses in display lists, bit-31/K0 tests, `&p[-u32]`, adjacent-global reads (D491), N64-stride allocations, s32 sentinels, endianness of serialized records), one row per site with provenance, store-to-u32 vs arithmetic-only, status and verifier. Part 1 landed (D441): 17 files widened to `uintptr_t` under `#ifdef PORT`, build and Bunker/Silo runs verified. **Still owed:** the full census with per-site provenance (H/R/I/?, table not yet written) plus a **store-to-u32 vs arithmetic-only** tag per site (a `uintptr_t` widening is enough only for arithmetic; sites that store back into a u32 field need `portHostToN64` under the macOS port_addr model), an env-gated high-arena test mode, and a check under an arena above 4 GB. Harmless today because the arena sits low. They would bite on macOS/ARM/ASLR. **#108 concern:** widening the `sndPlaySfx` bound disables the guard on every platform; prefer a range check. **#107 will now conflict** with D441 in `bg.c` (`bgRoomCalcBB`), so rebase or close it during the outside-PR review. A read-only provenance pass over the remaining sites is a good local-Qwen delegate task. **2026-10-01:** D457(c)(d) merged (player matrix pointers, Model/PROMOTE/vtxstore pointer width); #107's two sites fixed (D441); read-side census part 2 (403 `-Wint-conversion`/`-Wint-to-pointer-cast` warnings, triaged by file) in the local note `ABI-CENSUS-D441-PART2.md`; model.c rows need a re-run after D457(d). **Stage B plan (2026-10-01, maintainer's local design note):** (1) centralise the hard-coded arena address windows: **done 2026-10-02** (`port/include/portaddr.h`: `PORT_DRAM_V1_BASE` / `PORT_DRAM_K0_BASE` / `PORT_DRAM_SIZE`, used by `OS_K0_TO_PHYSICAL`, `dram.c`, `libultra.c` bases, `n64stubs.c`, the fast3d window checks; compile-time constants, no codegen change; menu / Bunker / Statue smoke clean). Left: the three link-time symbols in `port/src/dram_syms.s` (needs a preprocessed `.S` or `--defsym`), and a runtime-derived base for macOS; (2) widen `mema.c` (`memaspace.addr` s32 with `0xffffffff` sentinels, `memaRealloc`/`_memaFree` s32 params; the deferred D453 item); (3) build the high-arena test flavour (`GE_HIGHARENA_BASE`, tier 1 at 0x90000000, tier 2 above 4 GiB) and iterate on the first failures; (4) the heap-backed animation-table slots (`initanitable.c`); converges with #95's port_addr chokepoint. **2026-10-02 (D478):** read-side census re-run (319; 129 outside `chr.c` D34) triaged site by site: nothing live on the current layout; the remaining items are arena pointers in 32-bit slots and stack buffers through `s32` (safe only while stacks are low: `MAP_32BIT`, no ASLR on Windows), which is the Stage B / macOS list in the D478 entry. **2026-10-03 (D441):** complete census (424 compiler sites + 460 compiler-invisible grep hits, local `scratch/d441/`); Stage B item (3) tier 1 built (`GE_HIGHARENA_BASE=0x90000000`, `dram_syms.S`) and item (1)'s leftover link-time symbols done; 35 sign-extension sites fixed in 5 batches plus a fast3d `0x90000000` range literal; CI ratchet `tools_pc/abi_ratchet.py` (baseline 389). Tier 1 runs the menu and `-level_09` pixel-identical to the default build. **Still owed:** the tier-1 all-level sweep, tier 2 (above 4 GiB, needs the u32 slots widened, not zero-extension), the store-to-u32 tagging for macOS. | +| Latent 64-bit ABI hazards | partial | D441, #107, #108; AUDIT-M6 `struct player`/`struct hand` raw offsets (porting-notes §A1) | **State (2026-10-03, D441):** complete census (local `scratch/d441/`; many compiler hits are intentional `(s32)&ANIM_DATA_*` offsets, D34); 17 files widened to `uintptr_t` under `#ifdef PORT`; 35 sign-extension sites fixed; CI ratchet `tools_pc/abi_ratchet.py` (baseline 389); arena windows centralised (`port/include/portaddr.h`); tier-1 high-arena build (`GE_HIGHARENA_BASE=0x90000000`) runs the menu and `-level_09` pixel-identical to default. D478: nothing live on the current layout; the rest is harmless today but would bite on macOS/ARM/ASLR. **Sweep (2026-10-05):** tier-1 all-level sweep done — 21/21 PASS, 0 crashes, worst over-tol 2.38 % Jungle / 1.38 % Surface 2 (both under their 3 % limit), all 1 %-limit levels <= 0.24 %; no fix batches, ratchet unchanged (389); recipe + caveats in the D441 findings entry. **Owed:** tier 2 (above 4 GiB; u32 slots widened); store-to-u32 vs arithmetic-only tag per site (`portHostToN64` under macOS port_addr); `mema.c` widening (D453); `initanitable.c` heap slots; GE_DETERM wall-clock timers (D117 follow-up, blocks using determinism mode for frame gates). **#108:** widening the `sndPlaySfx` bound disables the guard everywhere; prefer a range check. Detail: findings D441, D457, D478. | ## 3. Verification debt (fixed; a live check is owed) @@ -156,12 +152,13 @@ A single campaign pass with these on the checklist would clear most of them. |---|---|---| | Presets: Original N64 / Port defaults | D440 | Press both rows in F10 and in the front-end PC Options with a mouse and a pad; draw distance applies from the next level load. `GE_OPTIONTREEPROBE` already fails on the base commit (separate look). | | Probe strip (2026-09-30) | D245, D302, D318 | Frigate or Dam water/sky A/B against the previous build (the s16 sky path was removed); one Facility playthrough to confirm the D318 watchdog stays silent. | -| Infra fixes | D179, build-pc.sh, crash.c, CI | The 2026-09-30 re-exec no-op (PATHEXT=.CPL in the inherited env) is fixed and verified from the agent shell (see AGENTS.md). `./build-pc.sh` from a genuine MSYS2 login shell; the next CI run (pinned actions, workflow input check). `GE_CRASHTEST=6` shows resolved frames. The romdata `VirtualAlloc` failure path now fails boot instead of falling back to the heap copy: confirm you want that. | +| Infra fixes | D179, D515, build-pc.sh, crash.c, CI | 2026-09-30 re-exec no-op (PATHEXT) fixed; 2026-10-04 empty-arch-tag bug (D515) fixed headless (details in AGENTS.md) — confirm on your next fresh-dir build. **Owed:** `./build-pc.sh` from a genuine MSYS2 login shell; the next CI run (pinned actions, workflow input check). `GE_CRASHTEST=6` shows resolved frames. The romdata `VirtualAlloc` failure path now fails boot instead of falling back to the heap copy: confirm you want that. | | Light-fixture hit-type reads (impact sound + sparks) | D434, #119 | Shoot fixtures on Bunker and Caverns. | | Dam/Caverns water animation | D465, #127 | Fixed 2026-10-01 (16-byte `Gfx` raw-index write). Maintainer: still "slightly faster" than 1964 on Dam; measured rate equals N64 within 0.1%. Optional objective check: time ten shimmer cycles on both (expect ~26 s). | | End credits after Cradle | #129 | Did not reproduce on `release/v0.4.1` 2026-10-01 (maintainer finished Cradle on Agent, credits played, clean exit). Close #129 as not reproducing when the release ships. | | v0.4.1 batch (unreleased; fixes live on `release/v0.4.1`) | D424–D432; #114 fire rate, #115 tank-crush audio loop, #116 tank movement, #117 sway, #118 GL recoil, #119 light fixtures | All user-verified against GEPD/1964 on 2026-09-29. Close the issues when it ships. | -| FOV-scale edge culling | D222 | Max FOV, pan across NPCs at the screen edge. | +| FOV-scale edge culling | D222 | Max FOV, pan across NPCs at the screen edge. Also audit the FOV/aspect terms in `camIsPosInScreen` and `c_lodscalez` for the Surface dish (D503 residual). Maintainer decision owed: expected widescreen behaviour vs fix. | +| Draw/LOD 800% | D503 | Draw/LOD distance ceiling raised 400% → 800% (2026-10-04). Owed: at 600-800, check far-field z-fighting on Dam (tunnel), Surface and Jungle; on Aztec or Streets, grep the log for the D500 vtx-pool budget line. If z-fighting shows, lower the ceiling to 600 rather than touching depth precision. | | Water pulse, Surface 2 | D229 | Frigate is verified. Only the Surface 2 by-eye check is owed. | | Linux save/config path when launched outside the game dir | D256 | Save-flow check on Linux. | | Settings/F10 batch | D356, D358, D361, D363, D374, D406 | Live accept of each item in the F10 overlay. (D377–D379 residuals were waived.) | @@ -173,12 +170,12 @@ A single campaign pass with these on the checklist would clear most of them. | Item | Status | Refs | Notes | |---|---|---|---| -| macOS (Intel + Apple Silicon) / ARM64 Linux | open (target 0.9.0/1.0, maintainer 2026-10-02) | PRs #88, #95; #101 | Outside drafts, parked for `main` until after 1.0. **Address model decided (2026-09-30): port_addr (#95)**, i.e. a single `portN64ToHost`/`PORT_N64PTR` chokepoint with a `PORT_ADDR_STRICT` validator, over the dram-scoped alternative. The `macos` integration branch **exists on origin** (2026-09-30). It was cut from `origin/main` `0e8c2ce2`, and it takes #88 (merged as `c4153157`, using #95's conflict-resolved copy `ae1884cc`), #132 (Linux build fix, `39d4f8d0`: #88's `romdata.c` included `` before `ultra64.h`, and glibc's `errno` macro broke the `errno` fields in `PR/os.h`; #95 needs it on rebase) and #95 (retargeted to it, still open). italoarruda's work also goes there. Nothing merges from it to `main` before the post-1.0 pass. **Open against `macos`:** #131, the fork-friendly self-hosted sweep. It adds `platform` and `runner_labels` inputs, a macOS job running `level_sweep_mac.sh` with a JSON verdict, seed-dir-only ROM staging, and `docs/selfhosted-sweep.md`. It is a scaffold that has not been run on a Mac, and it needs #95 in `macos` first. **When `macos` is next rebased onto `main`** (after the v0.4.1 push), `selfhosted.yml` will conflict with the v0.4.1 ROM-cache fix. Take #131's version, which already includes that fix. The crouched-rifle table fix (`chr.c`), found three times (#95, italoarruda, #120), landed on `release/v0.4.1` via #120 (D445) on 2026-09-30. #120's shape is the correct one (it restores the N64 table length of 2; #95's terminator-only version leaves length 1), so **drop #95's `crouched_rifle` hunk** when `macos` is rebased. #95's finding labels (D29x, then D405–D421) clash with ours, so relabel on merge. The maintainer tests firsthand before merging. Needs the ABI-hazard sweep in §2 (store-to-u32 tagging). | +| macOS (Intel + Apple Silicon) / ARM64 Linux | open (target 0.9.0/1.0, maintainer 2026-10-02) | PRs #88, #95; #101 | Outside drafts, parked for `main` until after 1.0. **Address model decided (2026-09-30): port_addr (#95)**, a `portN64ToHost`/`PORT_N64PTR` chokepoint with a `PORT_ADDR_STRICT` validator. The `macos` integration branch is on origin with #88 (merged), #132 and #95 (retargeted, open); nothing merges to `main` before the post-1.0 pass. **Open against `macos`:** #131 (self-hosted sweep scaffold, never run on a Mac; needs #95 first). **On the next rebase onto `main`:** take #131's `selfhosted.yml`; drop #95's `crouched_rifle` hunk (our #120/D445 is correct); relabel #95's D29x/D405-D421 labels. Needs the §2 ABI-hazard sweep; the maintainer tests firsthand before merging. | | Platform parity gate (Linux co-equal with Windows) | open | — | **Decided 2026-09-30:** yes. Every released platform carries the same gate: a 21-level sweep plus a campaign pass. Needs the golden baselines on both platforms first (§7). The last known Linux crash (D190) was closed as not-a-bug. | -| Low-end GPUs (~30 fps on Celeron/HD 400) | fixed, verify on N3060 class | D339, D481, D482, #92 | **D482 (2026-10-02):** measured against the PD port on the X220, fast3d is as cheap per batch as PD's; the remaining gap is our heavier defaults. Atom/Celeron-class GPUs (HD 400-605, UHD 600/605, Bay Trail class, software renderers) now get DrawDistance/LodDistance 100 and MSAA 1 once, where still at the defaults (Cradle: 482 -> 146 batches, render CPU 8.2 -> 5.2 ms). **D481 (2026-10-02):** the root cause was architectural, not render cost: fast3d ran inline on the scheduler thread, delaying retrace delivery, so the game's tick gate skipped frames. With the render worker a Lenovo X220 (HD 3000) went from 44-46 to 60.0 fps at every setting; the PD port measured 60 on the same box. **Next only if N3060-class hardware still drops:** decide CPU vs GPU from `GE_PERFSTAT` there (`gpu=` vs `run=`), then either a renderer-keyed lighter default (GPU) or fast3d per-batch cuts diffed against PD's hot loop (CPU). **Earlier direction (maintainer, 2026-10-01): real optimizations, not a preset.** **2026-10-01 profile** (all-thread sampler, local note `PERF-HOTSPOTS.md`, probes on branch `perf/profile-hotspots`): the top render-thread hotspot was an uncached getenv per vertex batch, fixed as **D473** (Statue display-list CPU about -55%). After it, whole-process CPU is ~1.5-2 ms/frame on a fast machine (~8-10 ms scaled 5x), so the N3060 / HD 400 is probably GPU- or driver-bound (per-draw cost, MSAA, fill). **Next:** one `GE_PERFSTAT=1` run on that class of hardware (compare `gpu=` vs `run=` vs `interval=`), then target what dominates: draw-call/state-change reduction (needs a flush-reason histogram on the target), texture-bind lookups, per-triangle debug hooks, combiner lookup (ranked in the note). The D372 preset gets no further effort. | -| Statue/Cradle hitching (maintainer, 2026-09-30) | parked (not player-visible, 2026-10-02) | `GE_PERFSTAT` spike counters (2026-10-01) | **Diagnosed 2026-10-01 (D475):** no sustained load. The low-CPU ~33 ms frames are the game's own frame limiter skipping a retrace after a slightly late gfx task (`boss.c` tick-interval check; a D13-class timing interplay, needs a design look and possibly Rule-2). The CPU-side bursts are CI-palette re-imports (one texture re-imported with 51 palettes in ~8 frames), not first-sight loads; room-texture pre-warm was built and measured, with ~no gain (unmerged branch `perf/prewarm`). **CI8 palette re-imports ruled out as a CPU cost (D477, 2026-10-02):** decode+upload ~0.008 ms/frame, worst frame 0.44 ms; the cache already holds the animated palettes (10 re-misses in 1314); the same texture is drawn with several palettes per frame, so an in-place update path would thrash. GPU palette lookup is not justified by this data. CI4 part done (D476). Frame-limiter design look done 2026-10-02: no independent pacing bug; the skips follow long gfx tasks. **Next (if the hitches still matter):** a per-frame timeline probe (game-thread build + limiter decision, render-thread misses/new textures/shader compiles, per-frame GPU timestamps, swap time) over >=5 runs, PresentMon during real play at a spot that hitches, then a disable-falsifier per suspect (driver-deferred texture allocation, `glGenerateMipmap` under three-point filtering, shader compiles). Needs the maintainer's description of where the hitch shows. **Parked 2026-10-02:** the timeline probe was built (`probe/hitch-timeline`, `GE_HITCHTL`, never merge) and run during a ~2 min maintainer play session. The maintainer felt no hitching at their settings, only the FPS counter dipping to 58/59 as a level starts. The capture agrees: the long frames sit at level load and transitions (one 59 ms frame with 316 new textures + 4 shader compiles) plus one ~20 ms in-play cluster with 3-7 shader compiles and new GL textures (first-draw cost). Reopen only if a visible hitch is reported (e.g. on low-end hardware); the cheapest lever then is shader/texture pre-warm at level load. **D480 (2026-10-02, `perf/shader-prewarm`):** the in-play cluster was shader compiles; created shaders are now remembered in `ge007.shaders` and compiled at the first frame of the next session (0 in-play compiles on a warm run, output-identical). The level-start texture frame is left as is (see D480). | -| Steam Deck preset skipped when first launched from Desktop Mode | verify | D283 | Fixed on `release/v0.4.1`: the Deck is detected by DMI hardware id (Valve Jupiter/Galileo; `STEAMOS` kept as a second signal) and the preset applies once per ini, only while Fullscreen/Maximized are untouched. Only the Windows build and a Linux `-fsyntax-only` check ran. **Owed: one run on a real Deck** (Desktop-Mode-created ini, then Game Mode; expect fullscreen 1280x800 and the log line `video: Steam Deck (DMI Valve ...)`). The Known-issues row above goes when this ships. | -| Windows 7 | open | #98 | **Re-checked 2026-10-02: likely feasible around 1.0.** The exe imports no Windows 8+ functions; the bundled SDL2 2.32.10 (still supports Windows 7) and winpthreads only load newer APIs dynamically with fallbacks; msvcrt runtime; OpenGL 3.0 drivers exist on Windows 7. The real blocker is the first-run asset converter `ge007-convert.exe` (PyInstaller on Python 3.9+, which does not run on Windows 7): the native C converter (#6 Part B) removes it, or a separate Python 3.8 build. Work: one Windows 7 VM test, then either converter route; CI cannot test Windows 7, so it is a manual per-release check. Supersedes the earlier 'won't-fix (toolchain)' recommendation. | +| Low-end GPUs (~30 fps on Celeron/HD 400) | fixed, verify on N3060 class | D339, D481, D482, #92 | **D481 (2026-10-02):** fast3d ran inline on the scheduler thread, delaying retrace delivery so the tick gate skipped frames; with the render worker a Lenovo X220 (HD 3000) went 44-46 → 60.0 fps. **D482:** Atom/Celeron-class GPUs (HD 400-605, UHD 600/605, software renderers) get DrawDistance/LodDistance 100 and MSAA 1 once, where still at defaults. **D473:** uncached per-batch getenv fixed. Direction: real optimizations, not a preset. **Next only if N3060-class hardware still drops:** one `GE_PERFSTAT=1` run there (`gpu=` vs `run=`), then a lighter renderer-keyed default (GPU) or fast3d per-batch cuts (CPU; local `PERF-HOTSPOTS.md`). | +| Statue/Cradle hitching (maintainer, 2026-09-30) | parked (not player-visible, 2026-10-02) | `GE_PERFSTAT` spike counters (2026-10-01) | **Diagnosed (D475, D477, D480):** no sustained load. The ~33 ms frames are the game's frame limiter skipping a retrace after a slightly late gfx task (`boss.c`; D13-class timing). CPU bursts were CI-palette re-imports (ruled out, D477; CI4 D476) and in-play shader compiles (fixed by D480). **Parked 2026-10-02:** a maintainer play session with the timeline probe (`probe/hitch-timeline`, never merge) felt no hitching. Reopen only if a visible hitch is reported (lever: pre-warm at level load). | +| Steam Deck preset skipped when first launched from Desktop Mode | verify | D283 | Fixed on `release/v0.4.1`: the Deck is detected by DMI hardware id (Valve Jupiter/Galileo; `STEAMOS` as a second signal) and the preset applies once per ini, only while Fullscreen/Maximized are untouched. Only a Windows build and a Linux `-fsyntax-only` check ran. **Owed: one run on a real Deck** (Desktop-Mode-created ini, then Game Mode; expect fullscreen 1280x800 and the log line `video: Steam Deck (DMI Valve ...)`). The Known-issues row goes when this ships. | +| Windows 7 | open | #98 | **Re-checked 2026-10-02: likely feasible around 1.0.** The exe imports no Windows 8+ functions and the bundled SDL2 2.32.10 still supports Windows 7. The real blocker is the first-run converter `ge007-convert.exe` (PyInstaller, Python 3.9+): the native C converter (#6 Part B) removes it, or a separate Python 3.8 build. Work: one Windows 7 VM test, then manual per-release checks (CI cannot test it). Supersedes the earlier 'won't-fix' call. | ## 5. Modern-remaster layer (port features beyond the N64) @@ -203,16 +200,25 @@ Background research lives in the maintainer's local notes (gitignored): | Item | Status | Refs | Notes | |---|---|---|---| -| `All unlocked` rework: RAM override instead of save patching | done | D442, D387, D259 | **Landed 2026-09-30 (D442)** on `release/v0.4.1`: query-time hooks in `file2.c` (`fileGetIsCheatUnlocked`, `fileIsStageUnlockedAtDifficulty`), RULE-2-SIGNOFF recorded; the EEPROM patch and the D387 merge are removed. **Verified 2026-10-03 (headless, frame dumps):** ON = all 20 levels in mission select, every difficulty ticked (real menu path), cheat menu fully populated (all 23 incl. Magnum/Laser/Golden Gun); OFF = only the save's real progress (Dam); `ge007.eep` byte-identical across all runs. MP character list not captured (same COMPLETED query path). Delete this row once released. | -| Settings units and values cleanup | done | D357, D443 | **Landed 2026-09-30 (D443):** mouse ×, whole-% deadzone and volume, FOV in horizontal degrees, MSAA 16×. FPS presets above 60 are deferred to the frame-rate-above-60 work (the sim ticks at 60 Hz). **Verified 2026-10-03 (headless frame dumps of Input/Graphics/Audio in F10 and front PC Options):** both UIs show the same values in the new units (mouse 1.0x, deadzones 23%, volumes %, FOV 75 deg, MSAA list up to 16x). Vibration now shows whole % too (was a raw 0.50). The `N/100` sliders (mouse vertical, look sensitivity, distances, trigger) are the calibrated-default scheme (50 = default), kept. Delete this row once released. | +| `All unlocked` rework: RAM override instead of save patching | done | D442, D387, D259 | **Landed 2026-09-30 (D442):** query-time hooks in `file2.c`, RULE-2-SIGNOFF recorded; the EEPROM patch and D387 merge are removed. **Verified 2026-10-03 (headless):** ON = all 20 levels and cheats unlocked; OFF = only real progress; `ge007.eep` byte-identical. Delete this row once released. | +| Settings units and values cleanup | done | D357, D443 | **Landed 2026-09-30 (D443):** mouse ×, whole-% deadzone/volume/vibration, FOV in horizontal degrees, MSAA 16×; verified 2026-10-03 (both UIs show the same values). FPS presets above 60 wait for the frame-rate-above-60 work. Delete this row once released. | | Restart button in the settings menus | done | D443 | **Landed 2026-09-30 (D443):** a "Restart game" row (orderly D344 quit, then relaunch). **Owed:** click it once on Windows (no BSOD); the Linux relaunch path has never been compiled. | -| Outside PRs #107–#109, #122–#124 | decided | #122 hot-plugged pads never reopen, #123 persistent crosshair (`gunfire.c`), #124 compact health/armour bars (`bondview2.c`); #107/#108/#109 (italoarruda) | **Decided 2026-10-01 after design reviews (notes: `docs/dev/notes/PR-REVIEW-109.md`, `PR-REVIEW-123-124.md`, local):** **#120/#121/#122** merged locally (#122 = D450, with split-screen); close all three when the branch is pushed. **#123** (persistent crosshair): accept-with-changes, **deferred**; later, either amend their PR or do the work ourselves (add to the Original N64 preset, relabel D436, our Rule-2 line, 2P + sniper-zoom check). **#124** (compact health/armour bars): **deferred** (pre- or post-1.0); too many issues to merge now: ignores HudScale, overlaps the bottom-left status text, 4:3-edge anchoring on ultrawide, draws extra commands when off, mixed apparent/real values, a test knob in shipping code, not in the Original N64 preset, split-screen untested. Either we take it on or ask the author to address the list when GitHub activity resumes. **#133** (F10 ultrawide aspect, JosephAHK, 2026-10-01): duplicates D472 (overlay card pillarboxed + mouse mapping, already on `release/v0.4.1`); close as superseded with thanks when the branch is pushed. **#109** (pad rebinding): **decline as-is**. It conflicts in all 4 files, would revert the D394 Jinx defaults, uses global (not per-seat) bindings, and its cancel is keyboard-only. Supersede with our own pad presets + rebinding (§5a row below), crediting the author's two-names format, menu lockout guard and release-before-capture arming (Co-authored-by); close with thanks and the change list when GitHub activity resumes. **#107**: folded into D441 on 2026-10-01 (both sites fixed with the author's `uintptr_t` arithmetic, Co-authored-by); close it on push. **#108**: belongs with `macos`; ask for a range check instead of widening the bound. Relabel any outside D-labels on merge. | -| Gamepad presets: per-seat preset, duplicate-binding warning | open | D469, D498, D394 | D469 (pad action table, Jinx 1.1 default, Custom per-seat bindings) and **D498** (Xbox 1.2 Christmas / 1.3 Frost / 1.4 Elektra) are done and maintainer-verified with pads (2026-10-01, 2026-10-03). Not done: a per-seat preset; a warning when two actions share a button; the Xbox "left stick aims the crosshair" and "D-pad copies the left stick" behaviours (D394). | +| F10 / PC Options menu: PD-style dialog refactor + watch-consolidation review | verify | D504-D512, `MENU-PD-ALIGNMENT-PLAN.md` (maintainer's local notes) | **Built 2026-10-04 (D504); follow-ups merged into `release/v0.4.1`:** D505/D506/D507 (wording, real-unit sliders, descriptions); D508 forced aspect ratios (16:9 / 21:9); D509 2-frame hold after an aspect change (rare gun glitch = known issue); D510 overlay + FPS counter same size front end vs level; D512 `Game.HudScale` scales the F10 overlay. **Owed (live pass, `docs/dev/notes/MENU-LIVE-CHECKLIST.md`, launches A/B/C):** look-and-feel vs PD; mouse, keyboard and pad in a level and on the front end; HudScale 75/100/150; 21:9; display-mode round trip across a restart; split-screen 2P with the overlay open. **Gaps:** background blur (post-1.0, §5c); per-seat control scheme is #139 / D513. Watch consolidation: not cheaply feasible (parked as T5). | +| Defaults/presets consolidation: one `Display mode` switch | verify | D504, D440, `MENU-PD-ALIGNMENT-PLAN.md` §5 (local notes) | **Landed 2026-10-04 (D504):** the derived `__DisplayMode` row shows Modern / Original N64 / Custom (no ini key); F10 dropdown + left/right stepping, front end steps too; one reset row per dialog covering exactly its keys; editing a non-bundled key does not flip Display mode. **Decided 2026-10-03: tri-state**, flipping to Custom when the user edits a bundled key. **Owed (live):** display-mode round trip across a restart; read of the regrouped pages and wording (MENU-LIVE-CHECKLIST.md A1/A2/A4). | +| Control style Original shows the game's own N64 styles | verify | #139 follow-up, D516, D517 | **Landed 2026-10-04 (D516, merged @ 91595597):** "Controller style" row (Original only; the game's own 1.1-2.4 per seat, saved to the Bond file; front end reads "(in a mission)"); D498 presets hidden in Original. **D517:** hold-repeat on any Bond row dropped fps (EEPROM rewrite per detent); now staged like sliders. Live passes 2026-10-04 (maintainer): single player + split-screen 2P with different styles per seat. **Owed:** the 2.x second-pad pass (real second controller). | +| Pad layout preset per player (setting P2's no longer moves P1) | verify | D518, D498 follow-up | **Landed 2026-10-04 (D518, merged @ 91595597):** `Input.PadPreset` kept as seat 1's key + new `Input.PadPreset2/3/4`; the F10/front-end preset row follows the Select-player seat. Headless: GE_PADMAPTEST PASS; **live passes 2026-10-04** (P2 change leaves P1 alone; split-screen: each seat ran its own preset). | +| Control scheme selectable: Ext / Original control style | verify | #139, D513, D498, D166 | **Landed 2026-10-04 (D513, merged into `release/v0.4.1`).** `Input.ControlScheme` (row "Control style", Ext default / Original) on the Controller page, both UIs. Original leaves the game's own style alone; D498 presets set layout and stick swap only in Original; 2.x runs faithfully; global in v1, per seat with the Select Player work. **Owed:** pad pass in both modes, watch-selected 1.1-1.4, split-screen 2P with different styles, 2.x with a second pad. | +| Outside PRs #107–#109, #122–#124 | decided | #122 hot-plugged pads never reopen, #123 persistent crosshair (`gunfire.c`), #124 compact health/armour bars (`bondview2.c`); #107/#108/#109 (italoarruda) | **Decided 2026-10-01 (local `PR-REVIEW-*.md`):** **#120/#121/#122** merged locally (#122 = D450); close on push. **#123**: accept-with-changes, deferred (Original N64 preset, relabel D436, Rule-2 line, 2P + sniper-zoom check). **#124**: deferred (ignores HudScale, overlaps status text, ultrawide anchoring, split-screen untested). **#133**: duplicates D472; close as superseded on push. **#109** (pad rebinding): decline as-is; supersede with our pad presets (§5a), crediting the author (Co-authored-by). **#107**: folded into D441; close on push. **#108**: belongs with `macos`; ask for a range check. | +| Gamepad presets: duplicate-binding warning, Xbox stick behaviours | open (after v0.5.0; documented in the README) | D469, D498, D518, D394 | D469 (pad action table, Jinx 1.1 default, Custom per-seat bindings), **D498** (Xbox 1.2 Christmas / 1.3 Frost / 1.4 Elektra) and the per-seat preset (**D518**) are done and maintainer-verified with pads. Not done (maintainer 2026-10-04: skip in game for v0.5.0, document only): a warning when two actions share a button; the Xbox "left stick aims the crosshair" and "D-pad copies the left stick" behaviours (D394). | | Pad button names on a Nintendo pad | verify | D471; local note `PAD-MENU-AUDIT.md` | Audit done 2026-10-01 (no controller-only trap; D472 fixed G1-G3). D471 family button names + player LEDs: **PS5 DualSense verified 2026-10-03.** **Owed:** a Nintendo (Switch) pad check; the maintainer has no such pad, so this waits for one or for a user report. | | Mute and auto-pause on focus loss; PNG screenshots to `screenshots/` | parked | D231, PR #56 (closed unmerged), `docs/dev/notes/parked/0001-QoL-*.patch` (local) | Deprioritised by the maintainer on 2026-09-25. F12 today is the dev PPM dump. | | FPS tanks while adjusting F10 music volume with a controller | done | D489 | Maintainer, 2026-10-02 (seen on PAL; general, also on v0.4.0). Cause: every pad/arrow slider step saved to the EEPROM (3 joy-poll handshakes + file rewrite on the game thread). Fixed: steps stage, save once on release; merged into `release/v0.4.1` 2026-10-03. Maintainer pad check passed 2026-10-03. Delete this row once released. | | Frame rate above 60 (120/144/uncapped with interpolation) | decision | [`dev/UNLOCKED-FPS-PLAN.md`](dev/UNLOCKED-FPS-PLAN.md) | The menu cap is {30, 60} (the ini accepts 0 = uncapped, but the sim ticks at the VI rate). The plan's Phase 0 is resolved by D248 (the default was a port bug; it is a real 60 fps) and its Phase 1 audit and Phase 2 slice scope are written (landed 2026-09-30), but the header still reads parked. Next step is a standing sim/render-rate measurement, not the old 30 fps investigation. Must stay compatible with netplay determinism. | -| Swapping saves with emulators | decided (after the D441 census) | D492 | **Investigated 2026-10-03 (D492):** the port stores the save checksum words and `options` little-endian, so an unconverted 1964 save fails validation and all five slots are wiped on first boot. Converting (swap `options`, recompute the checksum pair) was proven in a headless A/B: all slots OK, all 20 levels unlocked. **Decided:** `tools_pc/eep_convert.py` (both directions, `--verify`) plus a README "Using an emulator save" section; the on-disk format stays as is. Seed: `scratch/eepinv/convert.py`. | +| Swapping saves with emulators | done (D492) | D492; `tools_pc/eep_convert.py`; README "Using an emulator save" | **Implemented 2026-10-04:** `tools_pc/eep_convert.py` (`n64-to-pc`, `pc-to-n64`, `verify [--as n64|pc]`, auto-detect), bundled as `tools/eep_convert.py` in both release packages; README section added. **Why (D492):** the port stores checksum words and `options` little-endian, so an unconverted 1964 save fails validation and all five slots are wiped; converting (swap `options`, recompute the checksum pair) was proven in a headless A/B. On-disk format unchanged. Verified byte-identical and round-trip. | +| PD Extended Options parity: settings that need new dev work | done (reviewed 2026-10-04; two rows deferred) | `MENU-PARITY-SONNET-PLAN.md` §3 (local notes); pd_port `port/src/optionsmenu.c` @ 32a1cb9 | **Reviewed 2026-10-04 (maintainer decisions; local notes `PD-PARITY-DESIGN-REVIEW.md`, `WATCH-SCREEN-RATIO-DESIGN.md`).** **Done:** D511 fullscreen mode, Center window, crosshair opacity and color by health (live check owed, MENU-LIVE-CHECKLIST.md A4/A5/B3). **Watch Screen size/Ratio:** stay on the watch, no PC rows; the game saves watch settings on watch close (D349 correction) and they don't fight Aspect ratio (README). **Deferred past v0.5.0:** mouse lock mode (Auto/Always/Off; aim lock itself = `Input.AimMode`, D337); per-seat controller device select (needs a stable pad id; MP input layer). **Closed:** edge deadzone = `Input.AimRange` (D338); crosshair sway N/A (GE has none; gun sway is D428); use key reloads, no toggle (Ext keeps D378's GEPD split, Control style Original gives the N64 behaviour); per-seat options/binds (pad binds already per seat, D469/D518; keyboard/mouse is one device); no Detail textures toggle (`Video.DetailBaseTile` is the D236 fix); N/A confirmed: HUD centering (automatic, D335b), glare, overexposure, GE64 muzzle flashes, MP death music, radial menu speed, language filter, crosshair speed. **Other rows:** analog movement/stick scale/crouch → #139, T7; tickrate/framerate limit → frame rate above 60. | +| One PC options UI: front-end "PC Options" opens the F10 overlay over the folder graphic | verify | D519, D343, D504, D510, D512 | **Landed 2026-10-04 (D519, branch `feat/one-options-ui`):** the file-select "PC Options" entry opens the F10 overlay over file select; the `MENU_PC_OPTIONS` screen and four old probes are gone; Bond-file rows and the watch chooser are keyed to file select. Headless checks pass (D519). **Owed:** by-eye mouse, keyboard and pad pass on file select; profile chooser + Controller style with a real profile; footer text overlaps the Copy/Erase bar. | +| Emulator saves load directly in the game (no tool for import) | verify | D514, D492; `port/src/eepimport.c` called from `geEepromLoad`; D297 `legacycrc.c` same pattern | **Landed 2026-10-04 (D514, merged into `release/v0.4.1`).** Copying a 1964/Project64 `.eep` in as `data/ge007.eep` works: on first launch each region that validates in emulator format and not port format is converted in place (same rules as `eep_convert.py`); regions invalid in both are left to the game's wipe; port saves untouched; the original is backed up to `ge007.eep.emulator.bak`. Export stays with `eep_convert.py`. Headless verify 6/6 byte-identical to the in-game-proven reference. **Owed:** by-eye mission select after importing a real 1964 save. | +| DexDrive / GameFAQs `.n64` saves | idea (easy, post-1.0) | D514; sample `scratch/eepinv/gamefaqs_dexdrive_2001.eep` (local) | **Noted 2026-10-04.** Old GameFAQs GoldenEye saves are DexDrive Controller Pak dumps (`123-456-STD` header, then a 32 KB pak) whose note (game code `3BADD1E5`, 2 pages) holds the 512-byte cartridge EEPROM. Extracting it and zero-padding to 2048 bytes gives a valid emulator-format save (TenebrusoM sample: all 6 CRCs OK). Support = walk the note table, pull the note, pad, hand to the D514 importer (or an `eep_convert.py dexdrive-to-pc` subcommand). Not tested in game. | ### 5b. GE+ / Dab's-mod gameplay options (opt-in; touch game code) @@ -232,12 +238,13 @@ needs Rule-2 review. | Item | Status | Notes | |---|---|---| | Post-process chain: FXAA/SMAA, bloom, depth of field, colour grade/LUT, CRT filter, brightness/gamma, colourblind filters, SSAO | post-1.0 | Build the framebuffer pass once. Every effect defaults OFF. **Decided 2026-10-01:** after 1.0; our own opt-in implementation (default OFF; the Original N64 preset restores the N64 look); thepont's fork is reference only. | -| Near-full fog reveal (D503): far objects about 97% fogged show as faint silhouettes with straight room-clip edges on 32-bit output (e.g. Surface's dish) | triage | Two fix options, both opt-in or by-eye gated: (a) snap fog at or above ~97% to full fog (port-only, fast3d vertex fog; small, but changes fog everywhere slightly, needs a by-eye pass across levels); (b) an N64 16-bit colour/dither output mode (5 bits per channel, as the console framebuffer), a natural fit for the Original N64 preset and the "authentic 320x240" item below. Draw/LOD distance cannot help: 100 is already the N64 value. Measurements: HANDOFF 2026-10-03 / findings D503 (owed). | +| Near-full fog reveal (D503): far objects about 97% fogged show as faint silhouettes with straight room-clip edges on 32-bit output (e.g. Surface's dish) | verify | **Snap landed 2026-10-04** (cutoff 247, not derived from measured vertex fog); dish residual = (C) widescreen frustum, moved to the §3 D222 row; by-eye pass across levels owed. **Decided 2026-10-03 (maintainer): option (a)**, snap fog at or above ~97% to full fog (port-only, fast3d vertex fog). Option (b) (N64 16-bit colour/dither output) stays a candidate for the Original N64 bundle. Measurements: findings D503. | | Render scale / supersampling, integer scaling, "authentic 320×240" mode | post-1.0 | **Decided 2026-10-01:** after 1.0; our own opt-in implementation (default OFF; the Original N64 preset restores the N64 look); thepont's fork is reference only. | | Dynamic/coloured lighting, dynamic shadows, water reflections | post-1.0 | Large. Consult the PD port first. #105 is about the fork's version of reflections, not ours. **Decided 2026-10-01:** after 1.0; our own opt-in implementation (default OFF; the Original N64 preset restores the N64 look); thepont's fork is reference only. | | Path-traced render mode (opt-in, RTGL1) | post-1.0 | Experimental, for fun (maintainer, 2026-10-01): **deprioritised to after 1.0**. Plan in the maintainer's local notes: vendor MIT RTGL1 behind `port/rt/`, default OFF; fast3d stays the default backend and the fidelity ground truth; Phase 0 is a toolchain spike (RTGL1 under MSYS2/MINGW64). Nothing starts before 1.0. | | HD texture packs: loader + texnum dumper; model packs | post-1.0 | #100. There is a staged design. Step 1 (texture identity registry) touches `src/game`, so it needs Rule-2 framing. **Decided 2026-10-01:** after 1.0; our own opt-in implementation (default OFF; the Original N64 preset restores the N64 look); thepont's fork is reference only. | | Animation smoothing (keyframe slerp) | post-1.0 | **Decided 2026-10-01:** after 1.0; our own opt-in implementation (default OFF; the Original N64 preset restores the N64 look); thepont's fork is reference only. | +| F10 background blur + GUI-only texture filtering | post-1.0 | **Backlogged 2026-10-04 (maintainer): visual nice-to-have after 1.0.** Design done (local `MENU-BLUR-GUIFILTER-DESIGN.md`, ready Sonnet prompts in its §4). Blur: fast3d already decodes PD's framebuffer EXT opcodes and has the 16-tap kernel; only an emitter is missing (`port/fast3d/gbiex.h`); capture once on open (live capture too costly on HD 3000, D481). GUI filter: `Video.GuiTextNearest` point-samples texrects only. Port-only, default off. | ### 5d. Extras (nice to have) @@ -262,18 +269,18 @@ and a stage loader. | Item | Status | Refs | Notes | |---|---|---|---| -| Security review follow-ups | open | maintainer's local security review | Three Low hardening items remain (local-input only; details are kept out of this public file). Done on `release/v0.4.1`: the Medium item (self-hosted workflow input handling), actions pinned by SHA, `pyinstaller` pinned to 6.22.3, and the ROM no longer stored in `actions/cache` by `selfhosted.yml` (fork PR workflows can restore base-branch caches; no cache entry ever existed, checked 2026-09-30; it is now staged from the runner's `_rom-seed` dir only). The Dependabot action bumps (#1/#2) would now need to land as SHA pins, not tag bumps. | -| 1964-vs-port comparison harness | partial | `tools_pc/abpad/` | **2026-10-02:** one millisecond timeline drives 1964 (virtual XInput pad through the maintainer's own NRage profile) and the port (`GE_INPUTSCRIPT_MS`), with both windows captured on one clock and side-by-side sheets; verified through intro skip, file select and folder open. Not frame-identical (1964 has no seed pin or frame dump), so it's a timing/behaviour comparison, not a pixel diff. **Left:** in-level timelines (load a level on both sides, e.g. the D486 turret check); the two sides use different save files. **2026-10-03:** scripted menu navigation dropped (the free cursor lands on different cells on each side); replaced by `live_ab.ps1` (1964 then port after `-PortDelayMs`, 1280x720 each, position-only) with one real pad (`GE_PAD_BACKGROUND=1`). C-button hold tokens (`CUPHOLD`...`CNONE`) added for 1.2 walking in scripts. | -| Bond seems to move faster on the port than on 1964 (live A/B) | parked | — | Maintainer, 2026-10-03, live one-pad A/B (`tools_pc/abpad/live_ab.ps1`). Not investigated. Likely input mapping, not game speed: the port forces 1.2 SOLITARE and turns the left stick into digital C-buttons past a threshold (`port/src/input.c` ~2717), so a slightly off-centre push strafe-runs; 1964 is probably 1.1 analog through NRage (speed depends on Range/deadzone). Next step: match control styles (1964 save on 1.2, NRage left stick as C-buttons), push straight forward, then compare on a fixed route. | -| Scripted-playthrough harness | feasibility spike (after D441 and the worker tasks) | `GE_INPUTSCRIPT`, D117 | Input script + `GE_PCDUMP` + crash-log check, for unattended regression runs. **Maintainer concern (2026-10-03):** aiming/shooting, PRNG guards and timed events may block open-loop scripts, and runs aren't deterministic (D117). Levers: the game's own Invincibility + Invisibility cheats, closed-loop waypoint steering from Bond's position (port hook), a pinned seed (`GE_RSEED`), and coverage routes (load, walk, doors) instead of objectives. Spike: one Dam route run 5 times, measure end-position spread, then go/no-go. | -| Cheap worker for mechanical, build-checked tasks | open | `delegate-local` MCP; memory of past Qwen issues | Birdman64's fast worker model wrote 44% of its commits while the lead planned and reviewed. Ours: pattern-applied edits (D441 fix batches, scaffolding strips, small tools), each gated by a clean build + a specific check + the lead's diff review, in the worker's own worktree. Local Qwen for edit-only tasks (it fabricates on reading tasks), or a hosted fast model via `delegate_to_provider` (cost is the maintainer's call). | -| Golden baselines | partial | `tools_pc/verify.sh`, `tools_pc/golden/README.md`, D117 | **Windows done 2026-10-02:** all 21 levels re-based (`tools_pc/golden//win/`) with a near-deterministic recipe (pinned seed, START skips the intro flyby, frames 900-1500 of settled gameplay; Cuba captures its ending); two-tier gate (structural + per-pixel with per-level limits) catches a global texture-filter change (22% vs <= 2.3% noise). verify.sh now quits via `GE_QUITFRAME` and never kills other game instances. **Left:** Linux baselines (needs a real-GPU Linux box: `GE_PCDUMP` reads black on llvmpipe/WSLg), then the parity gate per platform. `GE_DETERM` stays experimental (not needed for this recipe). | -| Flicker scan (A/B/A frame alternation) | parked (2026-10-03: tooling, nothing blocked on it; spec in the local investigation report `scratch/birdman64-rows-investigation.md` §4) | `GE_PCDUMP`, `GE_INPUTSCRIPT` | Dump **every** frame of a scripted run at low resolution and flag frame n when it differs from n-1 but matches n-2. Dumping every Nth frame hides this kind of flicker by design. Cheap once the scripted-playthrough harness exists. For any old-vs-new pixel diff outside the golden gate, run a same-commit control first to measure the noise floor, as the golden gate already does. Reference: Birdman64 `tools/flicker_scan.py`, `docs/notes/sweep.md`. | -| Release build-provenance attestation | decided | `.github/workflows/ci.yml` release job; `.github/SECURITY.md` | **Decided 2026-10-03:** add SHA-pinned `actions/attest-build-provenance` to the tag-only release job, with `id-token: write` on that job only. It backs SECURITY.md's "unsigned, but reproducible from this repository" stance with a check anyone can run (`gh attestation verify --repo jkdansereau/goldeneye-pc-port`); document that in SECURITY.md and the README. Verifiable only on the next tag. Dropped: a combined `SHA256SUMS` (no security gain over the per-file `.sha256` from the same page) and `READ ME FIRST.txt` (duplicates the bundled README). | +| Security review follow-ups | done | maintainer's local security review | All items done on `release/v0.4.1`: the Low hardening items, the Medium item (self-hosted workflow input handling), actions pinned by SHA, `pyinstaller` pinned to 6.22.3, and the ROM no longer stored in `actions/cache` by `selfhosted.yml` (staged from the runner's `_rom-seed` dir only). Dependabot bumps (#134, #135) landed as SHA pins. | +| 1964-vs-port comparison harness | partial | `tools_pc/abpad/` | **2026-10-02/03:** one millisecond timeline drives 1964 (virtual XInput pad) and the port (`GE_INPUTSCRIPT_MS`), both windows captured on one clock with side-by-side sheets; verified through intro skip, file select and folder open. Not frame-identical (1964 has no seed pin or frame dump). Menu scripting dropped for `live_ab.ps1` (position-only, one real pad, `GE_PAD_BACKGROUND=1`). **Left:** in-level timelines (e.g. the D486 turret check); the two sides use different save files. | +| Bond seems to move faster on the port than on 1964 (live A/B) | closed (2026-10-04, maintainer) | — | Seen 2026-10-03 in a live one-pad A/B. Closed as an input-mapping difference, not game speed: the GEPD 1964 bundle is set up for mouse and keyboard and the port then forced 1.2 SOLITARE (D513/D516 now offer the game's own styles). A valid future A/B is the port on a pad against a standard emulator with plain pad input. | +| Scripted-playthrough harness | post-1.0 (maintainer 2026-10-04: not a good use of current LLMs; revisit with newer models) | `GE_INPUTSCRIPT`, D117 | Input script + `GE_PCDUMP` + crash-log check for unattended regression runs. **Maintainer concern (2026-10-03):** aiming, PRNG guards and timed events may block open-loop scripts, and runs aren't deterministic (D117). Levers: Invincibility + Invisibility cheats, closed-loop waypoint steering, pinned seed (`GE_RSEED`), coverage routes. Spike: one Dam route x5, measure end-position spread, then go/no-go. | +| Cheap worker for mechanical, build-checked tasks | open | `delegate-local` MCP; memory of past Qwen issues | Birdman64's fast worker model wrote 44% of its commits while the lead planned and reviewed. Ours: pattern-applied edits (D441 fix batches, scaffolding strips, small tools), each gated by a clean build + a specific check + the lead's diff review, in the worker's own worktree. Local Qwen for edit-only tasks (it fabricates on reading tasks), or a hosted fast model via `delegate_to_provider` (the maintainer's cost call). | +| Golden baselines | partial | `tools_pc/verify.sh`, `tools_pc/golden/README.md`, D117 | **Windows done 2026-10-02:** all 21 levels re-based (`tools_pc/golden//win/`; pinned seed, START skips the intro, frames 900-1500; Cuba captures its ending); two-tier gate (structural + per-pixel with per-level limits). **Left:** Linux baselines (needs a real-GPU Linux box: `GE_PCDUMP` reads black on llvmpipe/WSLg), then the parity gate per platform. | +| Flicker scan (A/B/A frame alternation) | parked (2026-10-03: tooling, nothing blocked on it; spec in the local investigation report `scratch/birdman64-rows-investigation.md` §4) | `GE_PCDUMP`, `GE_INPUTSCRIPT` | Dump **every** frame of a scripted run at low resolution and flag frame n when it differs from n-1 but matches n-2. For any old-vs-new pixel diff outside the golden gate, run a same-commit control first. Reference: Birdman64 `tools/flicker_scan.py`. | +| Release build-provenance attestation | verify | `.github/workflows/ci.yml` release job; `.github/SECURITY.md`; README Status section | **Implemented 2026-10-04:** SHA-pinned `actions/attest-build-provenance` attests `dist/*.zip[.sha256]` + `dist/*.tar.gz[.sha256]` in the tag-only release job (`id-token: write` + `attestations: write` on that job only). Backs SECURITY.md's "unsigned, but reproducible" stance with `gh attestation verify --repo jkdansereau/goldeneye-pc-port`; documented in SECURITY.md and the README. **Owed:** the v0.5.0 tag build is the first real run; confirm the step passes and verification succeeds on a downloaded asset, then delete this row. | | Self-hosted CI runner link failure | open | D244 | Needs a `pacman -Syu` on the runner (maintainer-only access). Release tag builds use GitHub runners, so it doesn't block a release. | -| Remove temporary scaffolding before a release | partial | [`dev/GE-ENV-PROBES.md`](dev/GE-ENV-PROBES.md), D302 | The 2026-09-30 pass removed D207S, D157, D252POOL, D245V, D288, D309, the D318B suite, the D320 repro harness and `scan_op12/13.py`, demoted the D318 watchdog to detect-and-log, and cached the per-frame `getenv` sites. **2026-10-03 (batch 5, `chore/strip-batch5`):** removed the dead registry probes D116, D51, D56, D60-D63, D69/D69BB/D69STAN, D71LOG, D85DUMP, D86-D88, D90, D96, D154, D178 (logging only; ~500 lines; build clean, intro + `-level_09` smokes clean). **Left:** D104 (kept while D294 is in verify); closed-finding TEMPs (**removed 2026-10-02 (batch 4, `chore/strip-batch4`):** `GE_D235_OFF`/`_RELOAD`/`_NEXT` (D235 closed; the fix and the `GE_D235` log stay), `GE_D245V`, `GE_D245_FIXEDSHIFT` and the inert sky tile-capture/shift code in `sky.c`; registry drift fixed and `gen_env_probes.py --check` passing again; **removed 2026-10-01:** `GE_D306C`, `GE_D318T` (batch 2); the whole D243 diagnostic set -- `GE_D243X2`, `GE_D243X4`, `GE_D243M` (+ its `d243mProbeActive`/frame-counter plumbing and all `D243M:` log lines in `bondview2.c`/`chr.c`/`chraction.c`/`chrai.c`/`model.c`/`objecthandler.c`), `GE_D243CAM`, `GE_D243SWIRL`, `GE_D243` (front.c menu trace) (batch 3, `chore/strip-d243-probes`; the shipped fix and the M-183/M-185 clamps are kept); `GE_DYNTEXHASH_OFF`, `GE_FORCEALARM`, `GE_D204_OLD`, `GE_D252`, `GE_RSEED_LV` were already gone); and the M-183/M-185 clamps, which need a full cutscene re-check first. | -| Findings log and doc size | partial | `tools_pc/docs_budget.py` | The budget checker exists (per-file and tier-1 token budgets; `--toc FILE`), and everything is within budget. `findings.md` is still about 2 MB (~500k tokens), so agents must keep using the index. Remaining: nothing blocks; trim only if the budget check starts failing. | -| Branch and PR housekeeping | open | — | 30 landed local branches pruned 2026-09-30 (tips kept in the notes archive, `prune-branches-2026-09-30.txt`). `docs/m138-security-disclosure` landed on `release/v0.4.1` with a stale-fact pass (SECURITY.md, the security-and-fidelity page, README roadmap bullet). `feat/unlocked-fps-phase1-audit`'s commit (Phase 0 resolved by D248, Phase 1 audit) landed as `docs/unlocked-fps-audit`. **On hold by the maintainer (2026-09-30, do not prune yet):** probe-only leftovers `d243-verify-local`, `investigate/d243-cutscene-race`, `park/d236-probes-m139`. Local `main` and `release/v0.4.1` are deliberately not pushed. **Pushed on 2026-09-30:** the `macos` integration branch; `ci/selfhosted-fork-runners` (PR #131 into `macos`, open); `fix/macos-errno-include` (PR #132, merged into `macos`). Pruned after merging, 2026-09-30: `fix/selfhosted-rom-no-cache`, `docs/macos-ci-tracking` and `fix/macos-errno-include` (local and remote), plus the `review/pr88` and `review/pr95` read-only refs. PR #62 (mouse dt-decouple) stays held until mouse feel is fundamentally better. | +| Remove temporary scaffolding before a release | partial | [`dev/GE-ENV-PROBES.md`](dev/GE-ENV-PROBES.md), D302 | Passes 2026-09-30 to 2026-10-03 removed the closed-finding probe suites (registry in `GE-ENV-PROBES.md`, `gen_env_probes.py --check` passing); TEMP probes `GE_TESTSTYLE` / `GE_D516` removed 2026-10-04. **Left:** D104 (kept while D294 is in verify); the M-183/M-185 clamps, **in scope 2026-10-03 (maintainer)**: re-verify all scripted-camera cutscenes with the clamps compiled out (intro flyover, per-level endings incl. the Dam abseil), then delete both blocks in `model.c`. | +| Findings log and doc size | partial | `tools_pc/docs_budget.py` | The budget checker exists (per-file and tier-1 token budgets; `--toc FILE`). ROADMAP.md was trimmed under budget 2026-10-04. `findings.md` is still about 2 MB (~500k tokens), so agents must keep using the index. Nothing blocks; trim only if the budget check starts failing. | +| Branch and PR housekeeping | open | — | 30 landed local branches pruned 2026-09-30 (tips in `prune-branches-2026-09-30.txt`, notes archive). **On hold by the maintainer (do not prune yet):** probe-only `d243-verify-local`, `investigate/d243-cutscene-race`, `park/d236-probes-m139`. Local `main` and `release/v0.4.1` are deliberately not pushed. PR #62 (mouse dt-decouple) stays held until mouse feel is fundamentally better. | | Docs site: Web 1.0 phase 2 | decision | — | Phase 1 shipped. Phase 2 (centred serif page) is plan-only. | ## Community forks (not ours; reference only) @@ -287,22 +294,18 @@ and a stage loader. ## Decisions owed (maintainer) -Decided 2026-09-30 (moved into the rows above): PAL/JP goes in Stage B; -`All unlocked` becomes a RAM override; D357 approved with changes; -platform parity gate; feature PRs get a design review first; the -#88/#95 address model is port_addr. Decided 2026-10-01: D417 approved (split-screen merged); the gameplay-visibility policy (§2); §5b, §5c and §6 are post-1.0 and opt-in (co-op included); D433 gets a code audit, D323 goes post-1.0; #94 closes as stale on release. Still owed: +Decided 2026-09-30 and 2026-10-01 (all moved into the rows above): PAL/JP in Stage B; `All unlocked` as a RAM override; D357 approved with changes; platform parity gate; feature PRs get a design review first; port_addr for #88/#95; D417 approved; the gameplay-visibility policy (§2); §5b, §5c and §6 post-1.0 and opt-in; D433 code audit; D323 post-1.0; #94 closes as stale on release. Nothing else owed right now (outside PRs decided 2026-10-01, see §5a). ## Not planned Online (non-LAN) multiplayer, matchmaking, leaderboards, anti-cheat, -cross-play · Steamworks, achievements, cloud saves (no storefront) · HDR, -frame generation, stereo 3D (ray tracing only as the opt-in post-1.0 -experiment in §5c) · remake-scope assets (new -models, music or voice) · new movement mechanics (jump, roll, melee) · the -007 Plus ROM patch as such (#110) · a per-user save folder (decided -2026-10-03: saves stay next to the game, which keeps the security doc's -"never writes to `%APPDATA%`" promise; README *Updating* covers moving them) · -a first-launch controls card and controller toasts (decided 2026-10-03: new +cross-play · Steamworks, achievements, cloud saves · HDR, frame +generation, stereo 3D (ray tracing only as the opt-in post-1.0 experiment +in §5c) · remake-scope assets (new models, music or voice) · new movement +mechanics (jump, roll, melee) · the 007 Plus ROM patch as such (#110) · a +per-user save folder (decided 2026-10-03: saves stay next to the game, +keeping the security doc's "never writes to `%APPDATA%`" promise) · a +first-launch controls card and controller toasts (decided 2026-10-03: new UI for little gain; F10 is documented in both READMEs). diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md index 8c816a37..515fa55c 100644 --- a/docs/dev/GE-ENV-PROBES.md +++ b/docs/dev/GE-ENV-PROBES.md @@ -111,14 +111,13 @@ Two classes: | `GE_BINDCAPTUREPROBE` (+ `GE_BINDCAPTUREACTION`) | `port/src/optionsoverlay.c:1000,1005` | D383 isolated host→menu smoke probe: when the overlay force-opens, injects a scripted bind-capture keypress (ESC/DEL/F2). Test data dir must be private — capture persists the key. | **live** (diagnostic) | | `GE_OPTIONSOVERLAY_SECTION` | `port/src/optionsoverlay.c:989` | Isolated screenshot/probe of a nested F10 page by row key (opens that header; never a menu mode). | **live** (diagnostic) | | `GE_OPTIONSOVERLAY_SELECTBACK` | `port/src/optionsoverlay.c:995` | Screenshot-only: pins the selection to the Back row when the overlay auto-opens, to check it against a normally selected setting on the same page. | **live** (diagnostic) | +| `GE_OPTIONSOVERLAY_DROPDOWN` | `port/src/optionsoverlay.c:1195` | Screenshot-only: with the overlay auto-opened on a page, opens the dropdown popup of the named row key (e.g. `Video.TextureFilter`, `__Resolution`) and focuses it. | **live** (diagnostic) | | `GE_OPTIONTREEPROBE` | `port/src/optionsoverlay.c:888` | D388: opt-in in-memory walk of the real F10 page tree; writes no settings, binds or profile bytes; restores UI state afterward. | **live** (diagnostic) | | `GE_PRESETPROBE` | `port/src/optionsoverlay.c:957` (`presetProbe`) | D440: one-shot at overlay init — drives the "Original N64" and "Port defaults" preset rows through the real arm -> confirm activation and checks the "Active" state; logs `GE_PRESETPROBE: PASS/FAIL`. **Writes the live config** (persisted on exit) — use a throwaway data dir. | **live** (diagnostic) | | `GE_OVPOINTERMAP` | `port/src/optionsoverlay.c:1726` | D391 pointer-map diagnostic: on LMB-down (and every 120 samples) logs SDL mouse coords vs GL drawable pixel coords + the hover row, to separate hit-band/scroll errors from coordinate-space mismatch. | **live** (diagnostic) | -| `GE_FOOTERPROBE` | `port/src/optionsoverlay.c:2232` | One-shot log of the overlay footer/help-line metrics (x extents). | **live** (diagnostic) | -| `GE_FRONTOPTIONS_SECTION` | `port/src/frontoptions.c:295` | Isolated headless screenshot of a nested front-end options page by key (changes navigation only; never edits profile or config values). | **live** (diagnostic) | -| `GE_FRONTNAVLOG` | `port/src/frontoptions.c:430,573` | Logs each front-options navigation select (section/row/item number). | **live** (diagnostic) | -| `GE_FRONTOPTS_WIDTHS` | `port/src/frontoptions.c:673` | D406b: one-shot label-metrics log for the options rows (layout diagnostics). | **live** (diagnostic) | -| `GE_FRONTOPTIONS_AUTO` | `port/src/frontoptions.c:818` | D406 test hook: enters the options screen without a mouse click so headless runs (`GE_INPUTSCRIPT` D-pad stepping + `GE_FRONTNAVLOG`) can exercise it. Never active without the env. | **live** (diagnostic) | +| `GE_FOOTERPROBE` | (removed) | **removed** -- the F10 footer was replaced by the hint line under the box (menu PD parity); tombstone kept. | **removed** | +| `GE_OVNAV` | `port/src/frontoptions.c:106` | D519 follow-up harness: scripted overlay navigation on file select (O toggle, D/U move, A open, B back, P log the place), one token per 20 file-select frames. | **live** (harness) | +| `GE_FILESELECT_OVERLAY` | `port/src/frontoptions.c:96` | One-PC-options-UI (D519) test hook: opens the F10 overlay over file select without a click; value `n` > 1 also closes it `n` label-frames later. Replaces the retired front-screen probes (the FRONTOPTIONS_SECTION, FRONTNAVLOG, FRONTOPTS_WIDTHS and FRONTOPTIONS_AUTO probes). | **live** (harness) | | `GE_WSPROBE` (+ `GE_WSPROBE_FRONT`, `GE_WSPROBE_RESET`, `GE_WSPROBE_F10FRONT`) | `port/src/watchsettings.c:616,568,512`, `port/src/optionsoverlay.c:1566` | D354 watch-settings probe family: `WSPROBE[=tick]` logs before/after the queued settings commit one tick apart; `_FRONT[=tick]` drives a headless front-end change scenario; `_RESET` prepares an options-reset after 120 front ticks and verifies persistence once the stage drains; `_F10FRONT` fires a one-shot F10→front poll after the file-select save becomes available (avoids the joyDisablePoll deadlock). | **live** (diagnostic) | | `GE_ZF` (+ `GE_ZFTOL`, `GE_ZFBOX`) | `port/fast3d/gfx_pc.cpp:1653-1657` | Z-fight grid probe: per tri emit, buckets the centroid cell and flags cells that fight ≥5 times (cap 600 hits); `ZFTOL`=tolerance, `ZFBOX="x0 y0 x1 y1"`=world-box filter. | **live** (diagnostic) | diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index e764f8cc..60aa9051 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -226,7 +226,7 @@ D345,FIXED,"Front-end crosshair snaps away from the pointer on screen/page chang D346,CLOSED,PC-settings wording pass:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest. D347,FIXED,F10 overlay controller:,"FIXED (port-only, optionsoverlay.c):" D348,CLOSED,"File-select ""PC Options"" label:",CLOSED (2026-09-28 bookkeeping: -D349,CLOSED,GE watch settings are not normally saved to the Bond file:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest. +D349,CLOSED,GE watch settings ARE saved to the Bond file when the watch closes:,"SUPERSEDED (2026-10-04, P2b correction):" D350,CLOSED,Watch-backed PC options:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest. D351,FIXED,Existing D346 value/unit rows had positional initializers shifted into hidePtr:,"FIXED (port-only): designated row initializers restore unit, deadzone divisor and hide-if-auto fields; build clean, headless smoke clean." D352,FIXED,D350 review of 1303c0eb:,"FIXED (port-only, port/src/watchsettings.c):" @@ -306,7 +306,7 @@ D432,FIXED,"Gunshot sounds ""off"" after D427:",FIXED in tree (gate offset round D433,OPEN,"Slight audible difference in automatic-gun shot sound vs GEPD/1964 after cadence was matched (user by-ear, Caverns).",OPEN (not a timing issue; audio layer suspected). D434,FIXED,Raw ((u8*)&g_Textures[n])[0] & 0xf hit-type reads are wrong on PC; latent OOB once D431 returned real texture ids (found by sub-agent review).,"FIXED (.hitTexture at 3 sites, port-guarded); live check owed." D440,FIXED,"""Original N64"" / ""Port defaults"" one-press presets in the F10 overlay and front-end PC Options (+ -n64preset/-portpreset).",FIXED (port-only; headless-verified; user live accept owed). -D441,PARTIAL,64-bit pointer-width sweep (part 1):,"PARTIAL (census + GE_HIGHARENA_BASE tier-1 build + 35 sites fixed + CI ratchet, 2026-10-03; tier-1 menu and -level_09 pixel-identical; owed tier-1 all-level sweep and the above-4-GiB tier)." +D441,PARTIAL,64-bit pointer-width sweep (part 1):,"PARTIAL (census + GE_HIGHARENA_BASE tier-1 build + 35 sites fixed + CI ratchet, 2026-10-03; tier-1 all-level sweep 21/21 PASS, 0 crashes, 2026-10-05; the above-4-GiB tier still owed)." D442,FIXED,Game.AllUnlocked reworked into a pure RAM/query-time override:,"FIXED; verified 2026-10-03 headless (mission select all 20 / only real progress when OFF, full cheat menu, eep byte-identical)." D443,FIXED,"D357 settings-display cleanup (mouse ×, whole-% deadzone and volume steps, FOV in horizontal degrees, MSAA 16×) plus a ""Restart game"" action (orderly D344 quit…",FIXED (port-only; reset probes pass; both-UI visual check and one Restart click owed; Linux relaunch path not compiled). D454,FIXED,Knife slash crashed on Windows:,FIXED (verified by maintainer live: @@ -351,7 +351,7 @@ D488,FIXED,PAL build reported the Brazilian MPAL TV type instead of PAL (osTvTyp D489,FIXED,FPS dropped sharply while adjusting F10 watch sliders (music/FX) with a pad or arrow keys:,"FIXED 2026-10-02 (port-only UI layer, fix/d489-pad-slider-commit, merged into release/v0.4.1 2026-10-03); maintainer pad check passed 2026-10-03" D490,FIXED,Dam first-tower guard walked a small circle:,"FIXED 2026-10-02 (fix/d490-glass-hull, merged into release/v0.4.1 2026-10-03); maintainer by-eye Dam check passed 2026-10-02" D491,FIXED,"Struct reads across separately-declared adjacent globals (the D5/D215 class), found by the D24 overflow audit:","FIXED 2026-10-03 (fix/d491-adjacent-global-reads, local); builds clean, 900-frame -level_09 smoke clean; no by-eye effect expected beyond room-membership-driven behaviour" -D492,OPEN,Emulator .eep saves are not drop-in compatible:,OPEN (investigated 2026-10-03; fix decided: +D492,CLOSED,Emulator .eep saves are not drop-in compatible:,CLOSED (converter shipped 2026-10-04: D493,FIXED,Pad rumble kept going after quitting the game:,"FIXED 2026-10-03, maintainer-verified in play (DualSense; Original-mode edges incl." D494,FIXED,"Sniper rifle: the PC crouch key now crouches/stands freely with a disable-crouch weapon (GEPD parity), never injecting C-down for it; supersedes D483's stance…","FIXED 2026-10-03, maintainer-verified in play" D495,FIXED,"Tank: with the default pad preset X changed the tank weapon and Y left it, because in-tank faces fell back to the fixed menu faces; PadMapCtx.tank routes Next/…","FIXED 2026-10-03, maintainer-verified in play" @@ -362,5 +362,22 @@ D499,FIXED,Trilinear texture filter option:,"FIXED 2026-10-03, maintainer-checke D500,FIXED,"Aztec shuttle area: polygons ""fell apart""; the per-frame vtx pool (dyn.c, no bounds check, Aztec -mvtx40) could overrun into the half the render worker draws;…","FIXED 2026-10-03, maintainer-verified at the Aztec spot" D501,CLOSED,Ultrawide 16:9 pillarbox for cutscenes and the watch:,CLOSED (reverted 31ade91f) D502,FIXED,PC Options pad navigation:,"FIXED 2026-10-03, maintainer-verified with a pad" -D503,?,Surface far dish shows as a faint silhouette with straight edges:,KNOWN ISSUE +D503,PARTIAL,"Surface: near-full-fog silhouettes on 32-bit output (fog snap applied); the remaining Surface dish is the widescreen frustum, 4:3 matches N64 —",FIX LANDED (by-eye pass owed) +D504,FIXED,F10 / PC Options menu:,FIXED (port-only; by-eye pass vs the PD port owed) +D505,FIXED,Menu wording pass: 27 maintainer-approved text edits across both PC options UIs —,FIXED (port-only; by-eye pass owed) +D506,FIXED,Menu slider values standardized:,FIXED (port-only; by-eye pass owed) +D507,FIXED,Menu: one-line description of the focused option in both PC options UIs —,FIXED (by-eye pass owed) +D508,FIXED,Forced aspect ratios:,FIXED (by-eye pass owed) +D509,FIXED,Held frames after an aspect-ratio change:,FIXED (by-eye pass owed) +D510,FIXED,F10 overlay + FPS counter the same on-screen size on the front end and in a level —,FIXED (live check owed) +D511,OPEN,Four PD-parity settings:,DONE headless (live checks owed) +D512,OPEN,Game.HudScale also scales the F10 overlay and FPS counter —,DONE headless (live check owed) +D513,OPEN,"Input.ControlScheme: Modern / OG controls (#139, T7) —",DONE headless (pad pass owed) +D514,OPEN,Emulator saves load directly:,DONE headless (by-eye mission select owed) +D515,FIXED,Empty arch tag from a fresh build dir:,FIXED (headless-verified) +D516,FIXED,"Control style Original shows the game's own N64 styles (1.1-2.4, per seat, saved to the Bond file) instead of the D498 presets —",FIXED (live pass 2026-10-04 incl. +D517,FIXED,Holding left/right on a Bond settings row dropped the fps:,FIXED (live pass 2026-10-04) +D518,FIXED,Pad layout preset was one global value:,FIXED (live pass 2026-10-04 incl. +D519,FIXED,"One PC options UI: the file-select ""PC Options"" entry opens the F10 overlay; the MENU_PC_OPTIONS screen is deleted —",FIXED (headless-verified 2026-10-04; by-eye pass owed) +D520,FIXED,"F10 overlay slider wedge, slider marker and dropdown arrow antialiased (they stepped in whole canvas pixels that grow with the window) —",FIXED (headless before/after capture; by-eye owed) D450,FIXED,"Unplugging and replugging a controller was not picked up (user, GameSir-G7 Pro; 2026-09-29).","FIXED — port-only: the render thread's gfx_sdl_handle_events drains the same SDL queue as video.c's host pump but had no case for SDL_CONTROLLERDEVICEADDED/REMOVED, so it usually swallowed them and i…" diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 5027b9ca..cb3553f9 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -634,7 +634,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D346 | **PC-settings wording pass: option labels/values aligned to Nightdive/Turok + PD-port conventions (title-case On/Off, "Frame rate cap", "Anti-aliasing"/"None", units in values not labels, self-explanatory auto-FOV toggles); VIEW section merged into VIDEO (2026-09-27).** — full `## D346` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. | | D347 | **F10 overlay controller: left/right did nothing (only A/Y adjusted values) and no input had hold-to-repeat (2026-09-27).** — full `## D347` entry at file tail | FIXED (port-only, `optionsoverlay.c`): D-pad left/right + stick-X wired into value adjust (A/X/B/Y kept); 18/4-frame hold-to-repeat on adjust and up/down nav, matching the options screen's D345(e)/(f) cadence. User validated physical-controller pass. | | D348 | **File-select "PC Options" label: no settings icon exists in the game art (mainfolderimages = copy/del/select-arrow/X/check/dot only) -- a mini slider glyph is drawn instead (2026-09-27).** — full `## D348` entry at file tail | **CLOSED (2026-09-28 bookkeeping: superseded by D398).** Earlier: SUPERSEDED by D398 (glyph replaced with the IMG_DOT texture; no new art). PCDUMP pixel-verified when shipped (135 knob px vs 9 paper). | -| D349 | **GE watch settings are not normally saved to the Bond file: erase is the only caller of the settings serializer, and stage start reloads the file's old values.** — full `## D349` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. | +| D349 | **GE watch settings ARE saved to the Bond file when the watch closes: close/abort routes through `deleteCurrentSelectedFolder` → `fileClearSavefileForFolder` (the same path as Erase), which serializes live settings; stage start reloads them. The original "session-only" static audit was wrong (2026-10-04 runtime correction, run 2).** — full `## D349` entry at file tail | **SUPERSEDED (2026-10-04, P2b correction):** session-only claim false — watch close persists per Bond file (`run_p2b2.log`, options `0x3A→0x17A`); no-write control run matches the live-vs-saved memcmp gate. Earlier 2026-09-28 CLOSED bookkeeping superseded. | | D350 | **Watch-backed PC options: shared front/F10 Bond-file rows, explicit chooser, game-thread F10 bridge and field-scoped PC-only persistence.** — full `## D350` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. | | D351 | **Existing D346 value/unit rows had positional initializers shifted into `hidePtr`: %/x rows hidden or formatted without units.** — full `## D351` entry at file tail | FIXED (port-only): designated row initializers restore unit, deadzone divisor and hide-if-auto fields; build clean, headless smoke clean. Manual settings visual check owed. | | D352 | **D350 review of 1303c0eb: stage transitions silently drop queued F10 setting changes (queue drained before the `stageActive` check); the UI thread's chooser state is unsynchronized; rapid slider drags push one RSP command per detent and re-persist the same value each frame; persist failures are silent (2026-09-27).** — full `## D352` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): hold-not-drop at stage transitions, spinlock on chooser state + front-screen guard, per-field coalesced queue commits, warning logs on all no-op paths, `PersistField` made static. NTSC build clean, headless menu smoke clean. | @@ -714,7 +714,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D433 | **Slight audible difference in automatic-gun shot sound vs GEPD/1964 after cadence was matched (user by-ear, Caverns).** — full `## D433` entry at file tail | OPEN (not a timing issue; audio layer suspected). | | D434 | **Raw `((u8*)&g_Textures[n])[0] & 0xf` hit-type reads are wrong on PC; latent OOB once D431 returned real texture ids (found by sub-agent review).** — full `## D434` entry at file tail | FIXED (`.hitTexture` at 3 sites, port-guarded); live check owed. | | D440 | **"Original N64" / "Port defaults" one-press presets in the F10 overlay and front-end PC Options (+ `-n64preset`/`-portpreset`).** — full `## D440` entry at file tail | FIXED (port-only; headless-verified; user live accept owed). Follow-up: preset stretches 4:3 in non-4:3 windows (no pillarbox mode). | -| D441 | **64-bit pointer-width sweep (part 1): 17 files widened `(u32)`/`(s32)` pointer casts to `uintptr_t` under `#ifdef PORT` (ABI/layout exception; ROADMAP §2 "Latent 64-bit ABI hazards").** — full `## D441` entry at file tail | PARTIAL (census + `GE_HIGHARENA_BASE` tier-1 build + 35 sites fixed + CI ratchet, 2026-10-03; tier-1 menu and `-level_09` pixel-identical; owed tier-1 all-level sweep and the above-4-GiB tier). | +| D441 | **64-bit pointer-width sweep (part 1): 17 files widened `(u32)`/`(s32)` pointer casts to `uintptr_t` under `#ifdef PORT` (ABI/layout exception; ROADMAP §2 "Latent 64-bit ABI hazards").** — full `## D441` entry at file tail | PARTIAL (census + `GE_HIGHARENA_BASE` tier-1 build + 35 sites fixed + CI ratchet, 2026-10-03; **tier-1 all-level sweep 21/21 PASS, 0 crashes, 2026-10-05**; the above-4-GiB tier still owed). | | D442 | **`Game.AllUnlocked` reworked into a pure RAM/query-time override: #ifdef PORT hooks in `fileGetIsCheatUnlocked` and `fileIsStageUnlockedAtDifficulty` (every stage reports COMPLETED); the EEPROM patch and the D387 merge are removed (RULE-2-SIGNOFF, maintainer opt-in feature).** Full `## D442` entry at file tail | FIXED; verified 2026-10-03 headless (mission select all 20 / only real progress when OFF, full cheat menu, eep byte-identical). | | D443 | **D357 settings-display cleanup (mouse ×, whole-% deadzone and volume steps, FOV in horizontal degrees, MSAA 16×) plus a "Restart game" action (orderly D344 quit, then relaunch).** Full `## D443` entry at file tail | FIXED (port-only; reset probes pass; both-UI visual check and one Restart click owed; Linux relaunch path not compiled). | | D454 | **Knife slash crashed on Windows: `var_a0_2` in `gunTickHandState` was `u32`, truncating the exe-data address of `D_80034CA4/E0C` (PE image base 0x140000000) before `gunSample1PTransform` (ABI/layout exception, §A1).** Full `## D454` entry at file tail | FIXED (verified by maintainer live: knife slash no longer crashes). | @@ -759,7 +759,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D489 | **FPS dropped sharply while adjusting F10 watch sliders (music/FX) with a pad or arrow keys: every step was a full EEPROM persist on the game thread** — full `## D489` entry at file tail | FIXED 2026-10-02 (port-only UI layer, `fix/d489-pad-slider-commit`, merged into `release/v0.4.1` 2026-10-03); maintainer pad check passed 2026-10-03 | | D490 | **Dam first-tower guard walked a small circle: `sub_GAME_7F03ECC0` overflowed `s32 rem[4]` on flat bboxes (glass/windows), corrupting the collision polygon; `rem[8]` under `AVOID_UB` (UB/stack-layout class, porting-notes D24)** — full `## D490` entry at file tail | FIXED 2026-10-02 (`fix/d490-glass-hull`, merged into `release/v0.4.1` 2026-10-03); maintainer by-eye Dam check passed 2026-10-02 | | D491 | **Struct reads across separately-declared adjacent globals (the D5/D215 class), found by the D24 overflow audit: `bg.c` `sub_GAME_7F0BA2D4` (portal bbox seeds, so every portal's Y/Z max stayed FLT_MAX and props joined extra rooms in `chrpropUpdateRoomList`) and both `gunfire.c` casing `rot` reads (y/z from `g_ZeroTriggerState`); `#ifdef PORT` reads the named globals** — full `## D491` entry at file tail | FIXED 2026-10-03 (`fix/d491-adjacent-global-reads`, local); builds clean, 900-frame `-level_09` smoke clean; no by-eye effect expected beyond room-membership-driven behaviour | -| D492 | **Emulator `.eep` saves are not drop-in compatible: the port stores the save checksum words and `options` little-endian, so an unconverted 1964 save fails `fileValidateSaves` and all five slots are wiped on first boot** — full `## D492` entry at file tail | OPEN (investigated 2026-10-03; fix decided: `tools_pc/eep_convert.py` converter + README section, on-disk format unchanged; queued after the D441 census) | +| D492 | **Emulator `.eep` saves are not drop-in compatible: the port stores the save checksum words and `options` little-endian, so an unconverted 1964 save fails `fileValidateSaves` and all five slots are wiped on first boot** — full `## D492` entry at file tail | CLOSED (converter shipped 2026-10-04: `tools_pc/eep_convert.py`, both directions + `verify`; output byte-identical to the in-game-proven converted save, round-trip clean; README "Using an emulator save" section added; on-disk format unchanged; review fixes 2026-10-04: conversion only re-stamps regions valid in the source, refuses (writes nothing) when nothing validates, bundled as `tools/eep_convert.py`, in-game import planned pre-1.0 (ROADMAP §5a)) | | D493 | **Pad rumble kept going after quitting the game: the clean exit never runs `inputDestroy`, so `portAtExit` now calls `inputRumbleStopAll()`; same commit fixes D493b, a 1-unit live-pixel strip at the pillarbox edges in Original mode** — full `## D493` entry at file tail (D493b is a sub-section) | FIXED 2026-10-03, maintainer-verified in play (DualSense; Original-mode edges incl. ultrawide) | | D494 | **Sniper rifle: the PC crouch key now crouches/stands freely with a disable-crouch weapon (GEPD parity), never injecting C-down for it; supersedes D483's stance freeze** — full `## D494` entry at file tail | FIXED 2026-10-03, maintainer-verified in play | | D495 | **Tank: with the default pad preset X changed the tank weapon and Y left it, because in-tank faces fell back to the fixed menu faces; `PadMapCtx.tank` routes Next/Prev weapon to N64 A and aim/fire to R/Z** — full `## D495` entry at file tail | FIXED 2026-10-03, maintainer-verified in play | @@ -770,7 +770,24 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D500 | **Aztec shuttle area: polygons "fell apart"; the per-frame vtx pool (`dyn.c`, no bounds check, Aztec `-mvtx40`) could overrun into the half the render worker draws; moved to 2x384 KB at DRAM +0x700000 with a budget log** — full `## D500` entry at file tail | FIXED 2026-10-03, maintainer-verified at the Aztec spot | | D501 | **Ultrawide 16:9 pillarbox for cutscenes and the watch: built, then reverted (original framing, not breakage)** — full `## D501` entry at file tail | CLOSED (reverted 31ade91f) | | D502 | **PC Options pad navigation: highlight on enter/back/page, back lands on the origin section, crosshair snap moved left; Trilinear listed after Bilinear** — full `## D502` entry at file tail | FIXED 2026-10-03, maintainer-verified with a pad | -| D503 | **Surface far dish shows as a faint silhouette with straight edges: ~97% fogged, the N64's 16-bit colour hid it, 32-bit output reveals it (any settings)** — full `## D503` entry at file tail | KNOWN ISSUE | +| D503 | **Surface: near-full-fog silhouettes on 32-bit output (fog snap applied); the remaining Surface dish is the widescreen frustum, 4:3 matches N64** — full `## D503` entry at file tail | FIX LANDED (by-eye pass owed) | +| D504 | **F10 / PC Options menu: PD-style dialog look and PD's six groups (Video, Audio, Mouse, Controller, Game, Key Bindings); checkbox / wedge slider / dropdown widgets; Display mode (Modern / Original N64 / Custom) replaces the two preset rows** — full `## D504` entry at file tail | FIXED (port-only; by-eye pass vs the PD port owed) | +| D505 | **Menu wording pass: 27 maintainer-approved text edits across both PC options UIs** — Text only; D504 follow-up; full `## D505` entry at file tail | FIXED (port-only; by-eye pass owed) | +| D506 | **Menu slider values standardized: real units everywhere, no `n/100` or `n/255` scales** — Display only; D505 follow-up; full `## D506` entry at file tail | FIXED (port-only; by-eye pass owed) | +| D507 | **Menu: one-line description of the focused option in both PC options UIs** — Port-only; D505/D506 follow-up; full `## D507` entry at file tail | FIXED (by-eye pass owed) | +| D508 | **Forced aspect ratios: Aspect ratio gains 16:9 and 21:9** — Port-only; D447 extension; full `## D508` entry at file tail | FIXED (by-eye pass owed) | +| D509 | **Held frames after an aspect-ratio change: one mis-projected frame (gun/hand glitch) removed** — Port-only; D508 follow-up; full `## D509` entry at file tail | FIXED (by-eye pass owed) | +| D510 | **F10 overlay + FPS counter the same on-screen size on the front end and in a level** — Port-only; D504 follow-up; full `## D510` entry at file tail | FIXED (live check owed) | +| D511 | **Four PD-parity settings: Fullscreen mode, Center window, Crosshair opacity, Crosshair color by health** — Port-only; ROADMAP PD Extended Options parity; full `## D511` entry at file tail | DONE headless (live checks owed) | +| D512 | **Game.HudScale also scales the F10 overlay and FPS counter** — Port-only; D226/D510 follow-up; full `## D512` entry at file tail | DONE headless (live check owed) | +| D513 | **Input.ControlScheme: Modern / OG controls (#139, T7)** — Port-only; D498/D166/D194 follow-up; full `## D513` entry at file tail | DONE headless (pad pass owed) | +| D514 | **Emulator saves load directly: `ge007.eep` in 1964/Project64 format is converted on first launch** — Port-only; D492/D297 follow-up; full `## D514` entry at file tail | DONE headless (by-eye mission select owed) | +| D515 | **Empty arch tag from a fresh build dir: the re-exec env drops `PROCESSOR_ARCHITECTURE`, CMake caches an empty processor, and the binary is named `ge007..exe`** — Build infra; D513 follow-up; full `## D515` entry at file tail | FIXED (headless-verified) | +| D516 | **Control style Original shows the game's own N64 styles (1.1-2.4, per seat, saved to the Bond file) instead of the D498 presets** — Port-only; D513/D498 follow-up; full `## D516` entry at file tail | FIXED (live pass 2026-10-04 incl. split-screen 2P; adjust-perf follow-up = D517) | +| D517 | **Holding left/right on a Bond settings row dropped the fps: every hold-repeat step committed (D489 staged sliders only)** — Port-only; D489 scope gap, found in the D516 live pass; full `## D517` entry at file tail | FIXED (live pass 2026-10-04) | +| D518 | **Pad layout preset was one global value: setting another player's preset changed the main player's mapping too** — Port-only; D498 follow-up, maintainer report; full `## D518` entry at file tail | FIXED (live pass 2026-10-04 incl. split-screen 2P) | +| D519 | **One PC options UI: the file-select "PC Options" entry opens the F10 overlay; the MENU_PC_OPTIONS screen is deleted** — Port-only (the one front.c entry call is kept); Bond-file rows + watch chooser re-keyed to file select; 4 probes retired, `GE_FILESELECT_OVERLAY` added; full `## D519` entry at the end of this file. | FIXED (headless-verified 2026-10-04; by-eye pass owed) | +| D520 | **F10 overlay slider wedge, slider marker and dropdown arrow antialiased (they stepped in whole canvas pixels that grow with the window)** — Port-only; D504 follow-up, P4k investigation; full `## D520` entry at file tail | FIXED (headless before/after capture; by-eye owed) | | D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). | | D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | OPEN (cosmetic, not root-caused past the handoff site). v0.4.0 decision: SkipIntro stays EXPERIMENTAL / not recommended for regular users — F10 row relabelled "Skip intro (EXPERIMENTAL)". | | D450 | **Unplugging and replugging a controller was not picked up (user, GameSir-G7 Pro; 2026-09-29).** — full `## D450` entry at file tail | FIXED — port-only: the render thread's `gfx_sdl_handle_events` drains the same SDL queue as video.c's host pump but had no case for `SDL_CONTROLLERDEVICEADDED/REMOVED`, so it usually swallowed them and `inputRescanPads` never ran. Both pumps now post a request; the rescan runs at the top of `inputUpdate`, on the thread that reads the pads. | @@ -14013,6 +14030,12 @@ to the user (proportions/knob offsets are one-constant tweaks if it looks off). **Defaults:** `BLANKSAVEDATA` (`src/bondconstants.h:2151`) starts `music_vol`/`sfx_vol` at 0xFF and `options` at `DEFAULT_OPTIONS` (`src/game/file2.h:23`: auto-aim, sight, look-ahead, ammo on). No code change or runtime verification in this finding. +**Correction (2026-10-04, P2b runtime investigation — supersedes the session-only claim):** The premise "erase is the only caller of the settings serializer" is false. **Closing (or aborting) the in-game watch also reaches it:** watch close → `deleteCurrentSelectedFolder` (`src/game/file.c:82`) → `fileClearSavefileForFolder` (`src/game/file2.c:1386`) → `fileSaveSettingsForFolder`. **Runtime proof (run 2, `run_p2b2.log`, pre-run eep `scratch/eep_before_run2.bin`):** with the live mission save in slot 4, opening the watch, changing Screen Full→Wide and closing it logged `fileOverwriteSaveSlotWithNewSave: DoReset slot = 4` + `fileWriteSave slot=4 bitflags=08` with options `0x17A` (base `0x3A` | SCREENWIDE `0x040` | CONTROLTYPE `0x100`); the previously-active slot was reset to `bitflags=80`. Watch settings therefore **persist per Bond file on watch close**, and stage start's `fileLoadSettingsForFolder` (`file2.c:1334-1383`) does apply screen size/ratio from the save — the "re-pick after every mission" consequence above (and §5(a)'s persistence argument in `docs/dev/notes/WATCH-SCREEN-RATIO-DESIGN.md`) is void. Control run (run 1): a plain open/close with no edits wrote nothing — the write is gated by a live-vs-saved `memcmp` early-out (`file2.c:1405`), so unchanged settings produce no save traffic. + +**Test artifact (run 3, `run_p2b3.log`):** an apparent "persistence failure" was an artifact of the test harness, not a bug. `-level_XX` hardcodes `fileSetCurrentFolder(FOLDER1)=0`, and the test eep's FOLDER1 slot held only the blank placeholder save (slots 1–3 = other folders' progress; slot 4 free). Run 3 loaded slot 4's options (`0x17A`) correctly at stage start, and closing the watch produced no write because live == saved (the same `memcmp` gate) — consistent with a successful load. One residual unexplained observation: an early frame read showed the watch displaying FULL despite the persisted Wide — possibly the native-widescreen display bypass (`bondview2.c:8150-8159`) or frame timing. Flagged as a by-eye check only; not chased further (run budget). In normal front-end play (one real save per folder) the round-trip is clean by code reading. + +**Net effect:** D350's PC-only persistence bridge is now a superset of console behaviour rather than a workaround for a session-only gap, and the approved divergence "edits made directly in GE's watch stay session-only (faithful to N64)" no longer describes either platform. Decision on the ROADMAP PD-parity item: **(c) keep Screen size/Ratio on the in-game watch only** — no PC menu rows; the README notes that they combine with the PC Aspect ratio setting. + ## D350 — Watch-backed PC settings: shared rows, explicit chooser and per-file save (2026-09-27) **Port-only, no `src/game` changes.** `port/src/optionsoverlay.c` adds an Audio (Bond File) section (Music/FX sliders) and a Bond File page (explicit file chooser + watch look direction, auto-aim, aim control, sight, look-ahead, ammo). These rows are shared by F10 and `MENU_PC_OPTIONS`; control style, GE Screen/Ratio and unrelated M3 items remain deferred. `watchSettingsFieldForKey` routes them to `port/src/watchsettings.c` rather than `config.c`/ge007.ini. Empty/unvalidated files cannot be edited; the chooser is independent of `selected_folder_num`, skips absent saves, and reads save fields directly rather than loading a folder and changing front-end audio. In a running level, F10 targets the active file and reads a game-thread-published snapshot so direct watch changes appear next frame. The watch's own edits remain session-only. @@ -16049,6 +16072,10 @@ Under PORT `struct image_entry` puts `dataoffset:24` in bits 0-23 (D67), so byte **2026-10-01: #107 folded in.** The two sites from outside PR #107 (italoarruda), both confirmed by the census as REAL hazards for an arena at or above 2 GiB, are fixed with the PR's own `uintptr_t` arithmetic under `#ifdef PORT` (`#else` arms unchanged): `lightfixture.c` `lightFindVertexBaseForTri` (segment-14 vertex base `(s32)vertices + offset` re-cast to `Vtx*`) and `bg.c` `bgRoomCalcBB` (loop bound `(Vtx*)((s32)vertices + usize)` compared with a full-width pointer; above 2 GiB the loop never runs and the room keeps an inverted bounding box). Identical values with today's low arena; `-level_09` load smoke clean (rc=0, 0 red-zone hits). Read-side census (part 2, 403 `-Wint-conversion`/`-Wint-to-pointer-cast` warnings) is in the local note `docs/dev/notes/ABI-CENSUS-D441-PART2.md`; its model.c rows predate D457(c)(d) and need a re-run. **Also 2026-10-01:** `options.c` `draw_watch_controller` second-controller opaque path passed `(s32)(&table2)` (a stack address) into a `WatchContButtonPositions *`, the missed sibling of the D290 fix 60 lines above (census TOP-10 item 8); now `&table2` under `#ifdef PORT`. Read-side census re-run after D457(c)(d): 403 -> 320 warnings (model.c 65 -> 12, bondview2.c 27 -> 14; chr.c 190 = 31 distinct D34 initialiser lines). Same pass: `boss.c` main loop sent the stack-local `localGfxDoneMsg` address to `rspGfxTaskStart` through the `s32 rspReplyMsg` every frame (census TOP-10 item 8); now `(OSMesg)&localGfxDoneMsg` under `#ifdef PORT` (menu + `-level_09` smoke clean). + +**2026-10-05: tier-1 all-level sweep (P6, maintainer time slot).** The tier-1 build (`-DPORT_DRAM_V1_BASE=0x90000000`) ran against the default build on all 21 solo levels, including the Cuba ending cutscene. Gate = the tools_pc/golden recipe per level (seed `0x0123456789abcdef`, `20:START` intro skip, `GE_PCDUMP 900-1500:300`; Cuba = no input, `300-900:300`), ini pinned 640x480, then `tools_pc/framediff.py` with the per-level run-to-run limits verify.sh uses (1 % tol-2; Jungle/Surface 2 3 %; Cuba structural only). Driver: `scratch/d441/sweep2.py` (worktree), levels in parallel in isolated instance dirs (copied `data/`, pinned ini, `MasterVolume = 0`); ~5 min wall at 4-way. **Result: 21/21 PASS, 0 crashes; worst over-tol 2.38 % (Jungle) and 1.38 % (Surface 2), both under their 3 % limit; every 1 %-limit level <= 0.24 % (worst Silo 0.238 %, Egypt 0.173 %) = residual self-noise.** No fix batch was needed, so the abi ratchet stays at 389. + +**Measurement caveats (for future sweeps, all measured 2026-10-05):** (1) the 60-480 window the P6 prompt suggested is invalid — D117 intro-camera nondeterminism gives ~74 % px divergence between two seeded same-build runs there; the seeded golden window (900-1500) is the gate (self-noise in the golden window is well under the per-level limits; e.g. dam build-vs-build 0.01-0.04 % at tol 2). (2) The 60 Hz vsync-on pace is part of the recipe: uncap the frame rate (VSync=0/FpsCap=0) and the wall-clock 100 ms timers' tick alignment shifts, self-noise ~5 % px (tol 0). (3) `GE_DETERM=1` is NOT usable for this gate when uncapped: its pacemaker branch still services wall-clock timers, so self-noise is 33-35 % px at 900-1500 — D117 follow-up: virtualise the timers in determinism mode before trusting it for frame gates. Status: tier-1 sweep owed item CLOSED; remaining owed = the above-4-GiB tier (u32 slots widened / `portHostToN64`), `mema.c` widening (D453), `initanitable.c` heap slots, the remaining `-Wint-conversion`/`-Wpointer-to-int-cast` rows. ## D442 — Game.AllUnlocked reworked to a pure RAM override; save patch + D387 merge removed (2026-09-30) **Status: RULE-2-SIGNOFF** (maintainer-granted opt-in port feature, edit confined to `#ifdef PORT` in `src/game/file2.c`). Maintainer, 2026-09-30: "All unlock - want that to allow for all levels/cheats to be unlocked, don't really care how we do it if debug/overrides are better than what we currently have" and, on the proposed hook design, "new plan sounds great". Runtime-verified (falsifier passed, first hook); second hook build-verified only, maintainer to check level select by eye. @@ -16732,7 +16759,7 @@ Found by the porting-notes D24 audit (a `-Warray-bounds=2 -Wstringop-overflow=4` **Verified (headless A/B):** converted save → all five slots `-> OK`, `wsfront` reads its music byte, mission select shows all 20 levels; blank control → only Dam. `AllUnlocked = 0` in the stage ini (checked). The investigation's Python CRC replica (`scratch/eepinv/eepinv_analyze.py`, from `fileGenerateCRC` + `src/random.s`) matches the binary on both files. **Decision (maintainer, 2026-10-03):** converter tool (`tools_pc/eep_convert.py`, both directions, `--verify`) + a README "Using an emulator save" section; the on-disk format stays as is (no save-path or game-code change). Queued after the D441 census. Seed: `scratch/eepinv/convert.py`. **Class:** §A1-adjacent (serialized multi-byte fields are host-endian in this port; raw-byte CRCs hide it until the stored words are compared). Cross-ref D297 (validation-failure wipe class). -**Status:** OPEN (converter not written yet). +**Status:** CLOSED 2026-10-04 — `tools_pc/eep_convert.py` shipped (`n64-to-pc`, `pc-to-n64`, `verify [--as n64|pc]` with auto-detect, faithful `fileGenerateCRC` replica from the seed). Verified against the investigation files: converted output byte-identical to the in-game-proven `1964_converted.eep`, round-trip byte-identical, wrong-size/missing input exits 2. README "Using an emulator save" section added. Review fixes 2026-10-04: conversion only re-stamps regions valid in the source; refuses (writes nothing) when nothing validates; bundled as `tools/eep_convert.py`; in-game import planned pre-1.0 (ROADMAP §5a). ## D493 — Pad rumble kept going after quitting the game (2026-10-03) @@ -16847,4 +16874,243 @@ ROADMAP 5a "Gamepad rebinding + modern controller presets"; reference table in ` **Measured (port capture, `build-pc/ppm/shot_000.ppm`):** dish pixels (93-95, 93-95, 124-127); Surface fog colour (96, 96, 128) (`bgfog.c:138`); sky/haze around (96-100, 96-98, 128-131). The dish is about 97% fogged. **Mechanism:** Surface fog is `gSPFogPosition(996, 1000)` with near 2 / far 2500, a ramp from about 417 to 2456 units; the dish sits near its end. fast3d's vertex fog is the standard `z/w * fm + fo` (same as the PD port), and the Draw/LOD multipliers are exactly 1.0 at 100 (`video.c` `portDrawDistanceMultiplier` / `portLodDistanceMultiplier`; 100 is the slider minimum). The N64's 16-bit framebuffer (5 bits per channel) plus the full-fog clamp make a 2-5 level difference vanish; the port's 32-bit output keeps it, so the nearly fogged dish and the straight edge of GE's room-rect scissor (`propobj.c:7510` `getPropCombinedRoomsBBox2D` -> `bgScissorCurrentPlayerViewF`) become visible. Raised Draw/LOD distance makes more such objects visible. D491 (props filed under fewer, correct rooms) is not the cause. Same "reveal, not regression" pattern as D227/D249. **Options (ROADMAP §5c row):** snap fog at or above ~97% to full (port-only, global by-eye pass); an N64 16-bit colour/dither output mode for the Original preset. -**Status:** KNOWN ISSUE (ROADMAP Known issues). +**Follow-up 2026-10-04 (verdict: both, confidence med-high):** the 247 snap (F) helps the left/right objects, but the dish still showed. At 4:3 with N64 FOV the maintainer reports the view matches 1964, so the remaining dish is (C): the widescreen/FOV frustum, not fog (the D218 / D18-class widening; `camIsPosInScreen`/`c_lodscalez` FOV terms not yet audited by code). The 247 cutoff was never derived from measured vertex fog; no probe was run since the 4:3 test settled it. The Draw/LOD multipliers are exactly 1.0 at the 100% minimum (`video.c`). +**Related change (maintainer request):** the Draw distance / LOD distance slider ceiling is raised 400% to 800% (`video.c` registered max + `portDrawDistanceMultiplier` clamp 6.0 to 8.0 + LOD clamp 800; `optionsoverlay.c` `uiMax=800`, UI normalization `(pct-100)/7`, so the 250% default now shows 21/100). The old note warns about far-field z-fighting beyond about 4x; by-eye check at high values owed. +**2026-10-04 (decision owed):** the residual is tracked under the ROADMAP §3 D222 row ("FOV-scale edge culling"), not D218 (closed). Maintainer decision owed: expected widescreen behaviour (the Original N64 / 4:3 view is the faithful one) vs fixing the FOV terms. The 800% ceiling makes near-full-fog objects more visible (opt-in). +**Status:** FIX LANDED 2026-10-04 (`gfx_pc.cpp` vertex fog: `fog >= 247` snaps to 255, port-only); dish residual reclassified as (C), tracked under D218; by-eye pass across levels owed before the known-issue row is deleted. + +## D504: F10 overlay restyled to the PD settings menu; both PC options UIs regrouped to PD's six groups + +Port-only (`port/src/optionsoverlay.c`, `frontoptions.c` grouping fallout, `optionsoverlay.h`). Folds in DMENU-1..7 (worktree notes) and requirements R1-R8 of the menu-parity plan. Cross-ref D440 (preset key table, now behind the Display mode row), D356 (resets), D469 (Select player / Controller), D472 (owner pad), D502 (pad nav). + +**Grouping (R7/R8).** Hub order Video, Audio, Mouse, Controller, Game, Key Bindings, then a separator and Restart game / Quit to desktop. Controller opens Select player (Player 1-4), which opens that seat's Controller page. The Input sub-hub, the Display mode / Graphics / HUD pages and the Movement / Actions sub-pages are gone; Key Bindings is one flat list. Field of view moved from Graphics to Game (PD keeps Vert FOV there); the HUD rows, crosshair rows and Crouch mode sit on Game. Mouse gained rows for the existing ini keys `Input.MenuPointerMode`, `Input.MouseSmoothing`, `Input.MouseRawInput` (reset defaults added). One reset row per dialog (6); each covers exactly its dialog's keys (`GE_WSPROBE_RESET` 6/6 front, 6/6 stage). The hub / tab order is `kRootOrder`; rows stay physically contiguous per header, so the front end walks the same table (`optionsRootHeader`). + +**Display mode (T4-lite).** `__DisplayMode` row: value derived Modern / Original N64 / Custom from `videoPresetIsActive`. F10: a dropdown (all three shown, current marked; picking Custom is a no-op); left/right on either UI steps Modern <-> Original N64 and applies at once (the first arm -> confirm version hid the other modes; maintainer feedback). Persists across restart (maintainer-verified). No new ini key. `GE_PRESETPROBE` walks Modern -> N64 -> edit a bundled key -> Custom -> dropdown pick Modern. Backspace also goes back one page (QoL). + +**Look (R1/R2/R4/R5).** PD's green palette (`g_MenuColours[]`, menu.c:110), content-sized centred box, gradient title bar 2 units wider each side with two shimmer streaks, shadowed yellow title, flat ~78 % body (PD 50 %, raised twice for legibility, maintainer), 1 px borders, light dim. No CONTROLS footer: a two-line hint under the box, shown only while the last input was pad/keyboard (or a bind capture is waiting), with D471 per-family button names. Labels are mixed case in Zurich Bold (the only mixed-case face; Bank Gothic is caps-only) drawn at 78 % through the HUD span scale (`drawBody`), hub rows normal size. Focus: text pulses toward white (40-tick period) with PD's `#004400` outline; no focus line (`focusRule` deleted). Values stay in the unfocused colour. Mouse hover gets the same cue without moving the selection (the D304 feedback loop stays closed). Scroll eases toward the target row; rows only draw while fully inside the body (the scissor does not clip these 2D rects). + +**Widgets (R3).** On/Off rows are PD checkboxes; numeric rows are a PD wedge slider with a triangle marker and the value text right of it; enum, anti-aliasing, resolution, frame cap and output device rows are dropdowns (popup under the row, or above when short of room; up to 9 items then scrolls). Enter/A/click opens; up/down/wheel/hover pick; Enter/A/click confirm; Esc/B/right-click/outside-click/closing the menu cancel; left/right on a closed row still steps. Named two-way toggles (Reverse/Upright, Hold/Toggle, All unlocked) keep value text. Front end: no widgets (R8), grouping only. + +**Deviations / gaps.** `Game.HudScale` is not applied to this menu (PD's menus do not scale either; applying it would need an inverse in the mouse map). Background blur is a logged gap (needs new renderer work), so on bright frozen frames the 50 % body is less legible than PD's. Hover over a row while a pad is also active follows the last input source. No PD reference capture was produced headless. + +**Verified (headless, `GE_QUITFRAME`):** build rc=0, no new warnings in touched files; `GE_OPTIONTREEPROBE` PASS (updated: 6 roots + 3 hub rows, scroll-window hit test instead of a no-scroll row cap, Back last on every page); `GE_PRESETPROBE` PASS; `GE_WSPROBE_RESET` 6/6 front and 6/6 stage, other-file isolation intact; `gen_env_probes.py --check` clean (new `GE_OPTIONSOVERLAY_DROPDOWN`, `GE_FOOTERPROBE` tombstoned). **Owed (maintainer):** live look-and-feel pass vs the PD port; dropdown / checkbox / slider with mouse, keyboard and pad in F10 (in a level and on the front end); the regrouped front-end pages; Display mode round trip across a restart. + +## D505: menu wording pass (text only) + +Port-only (`port/src/optionsoverlay.c`, `frontoptions.c`, one release-notes sentence). Follows D504. A wording table of every user-visible string in both PC options UIs was reviewed by the maintainer; all 27 proposed edits were applied, nothing else (no layout, key, order or behaviour change). + +**Renamed (old -> new).** Aspect -> Aspect ratio (value Window -> Fill window); Mouse horizontal / vertical sensitivity -> Horizontal / Vertical sensitivity; Invert look (mouse) and (controller) -> Invert look; Mouse smoothing -> Smoothing; Deadzone (left/right stick) -> Left/Right stick deadzone; X/Y axis look sensitivity (controller) -> Horizontal / Vertical look sensitivity; Look smoothing (controller) -> Look smoothing; Fire key / Aim key -> Fire / Aim; AI sees the wider view -> Guards see the wider view (release notes quote updated). Values: "ON (restart)" -> "On (restart)" (the front end's green cue now also matches "On ("); capture prompts "Press button..." / "Key/mouse..." -> "Press a button..." / "Press a key..."; resolution n/a -> "(n/a)"; the front-end "[ENTER]" and "for restart options" value texts removed. Tags: front end "(per profile)" -> "(profile)" (matches F10). Hint lines: front-end and F10 key/pad hints share one phrasing ("Keys and mouse only", no colons, "Esc"/"Del", `{BACK}` token). + +**Deliberately not changed (maintainer decisions owed, in the table's flag rows):** Layout preset is NOT renamed "Control style" (the game's own term; T7 / #139); the Use / Action / Use-back naming of the A/B binds; the abstract `n/100` and `n/255` value formats; "No hit flash" polarity; Widescreen auto FOV naming. EXPERIMENTAL labels keep the capitals (red ink matches the literal). + +**Verified (headless, `GE_QUITFRAME` on every run):** `./build-pc.sh ntsc-final` rc=0, no new warnings in touched files; `GE_OPTIONTREEPROBE` PASS; `GE_PRESETPROBE` PASS; `GE_WSPROBE_RESET` 6/6 sections verified, 0 failures, other-file isolation intact; `GE_FRONTOPTS_WIDTHS` widest label "Guards see the wider view" (175) shows with its value unclipped; contact sheet of every F10 dialog and front-end page showed no clipping or overlap. No config key changed, so the written ini is unchanged by construction (byte compare vs 23004d20 not run). **Owed (maintainer):** by-eye read of the new wording in both UIs. + +## D506: slider values standardized (display only) + +Port-only (`port/src/optionsoverlay.c`; both UIs share `valueText`). Maintainer asked for matching values across rows and no odd defaults (e.g. `41/200`). The calibrated "50/100 at the default" mapping (`calibratedDefault`) is retired (now returns "none"); every slider is a linear bar in a real unit. Storage, ini keys, ranges and defaults are unchanged. + +**Now shown as:** multipliers (raw 100 = 1.0x) for Horizontal / Vertical sensitivity and Horizontal / Vertical look sensitivity (vertical mouse and both pad look rows gained linear bars; mouse vertical uses UI range 10..300 like the horizontal row); `%` for Trigger threshold (raw is already a percent, default 23%), Look smoothing (raw 0-10 -> 0..100%), deadzones, volumes, vibration, shake, smoothing, crosshair size, HUD scale and the crosshair R/G/B channels (percent of full); `x` for Draw / LOD distance (ini 100..800 -> 1.0x..8.0x, default 2.5x), anisotropy. Field of view stays in degrees (it is a real unit and depends on the window aspect). The unused `dispMax` row field was removed. + +**Verified (headless):** build rc=0; `GE_OPTIONTREEPROBE` PASS; `GE_WSPROBE_RESET` 6/6 sections 0 failures; F10 Mouse / Controller / Video / Game dumps show the new values (`scratch/vals_sheet.png`). **Owed (maintainer):** drag each changed slider with mouse and pad, both UIs; the vertical-mouse and pad-look bars now start at ~35-57% instead of the middle. + +## D507: option descriptions under the menus + +Port-only (`optionsoverlay.c`, `optionsoverlay.h`, `frontoptions.c`). Maintainer ask: hints that explain the less obvious options (e.g. "Guards see the wider view"). `kRowHelp[]` maps a row key to a one-line description (45 rows; self-explanatory rows have none); `optionsRowHelp(i)` returns it. F10: drawn centred as the first line under the box for the focused row from any input source (mouse included); the two key-hint lines move down one line while it shows. `OV_HINT_H` 26 -> 53 reserves three lines (the safe-area crop eats the bottom margin, so 41 pushed the key hints off screen); a 240-line canvas loses about two rows of page height (Game and Video already scrolled). Front end: the description replaces the bottom hint line while a row is highlighted (page bottom has room for one line of about 44 characters; bind-capture hints still win during capture). Texts are written to stay within that width. + +**Verified (headless):** build rc=0; `GE_OPTIONTREEPROBE` PASS; `GE_PRESETPROBE` PASS; `GE_WSPROBE_RESET` 6/6; F10 Game page dump shows description + both key hints; front-end Game page with "No hit flash" highlighted shows its description (scripted DOWN steps). **Owed (maintainer):** read all descriptions for accuracy (Look ahead, Skip intro, Aim style are my reading of the code, not the watch's wording); front-end line position at your window size; F10 row count on short windows. + +## D508: forced aspect ratios + +Port-only (`port/src/video.c`, `optionsoverlay.c`). `Video.AspectMode` (D447) had Fill window (0) and Original (1). Maintainer ask: let players force other shapes. The D447 output-rect path already letter/pillarboxes to any target aspect (`gfx_set_output_aspect`) and sets `gfx_current_dimensions.aspect_ratio` to it, so Native widescreen projects the world at the forced aspect with no fast3d change. New values 2..3 = 16:9, 21:9 (7:3). 4:3 (same as Original unless the watch Ratio is 16:9) and Cinema 2.35:1 (near-identical to 21:9) were dropped as redundant after the first by-eye pass; ini range widened to 0..3, default and the Original N64 preset (1) unchanged. Any non-Fill, non-Original value counts as "Custom" in the Display mode row (it is a bundled key). The overlay and mouse mapping follow the output rect as before. A window narrower than the target letterboxes, wider pillarboxes. + +**Verified (headless):** build rc=0; `-level_09` frame 400 at 1280x720 for modes 3, 4, 5: 16:9 fills the window, 21:9 and 2.35:1 letterbox with the expected bar sizes (`scratch/asp_sheet.png`); F10 Video dump shows "21:9" with Display mode "Custom"; tree / preset / reset probes pass. **Owed (maintainer):** by-eye in a level and the front end for each ratio, HUD/crosshair/menu placement at 21:9 and 2.35:1 (the HUD uses the same Hor+ path as a real ultrawide window), mouse aim at the letterbox edges, fullscreen on a 16:9 panel. + +## D509: one wrong frame on every aspect-ratio change (Bond's gun/hand glitched) + +Port-only (`port/fast3d/gfx_pc.cpp`). Maintainer saw the first-person gun/hand glitch ("reset") for a moment when changing Aspect ratio with F10 open in Surface 1. Measured with a temporary cycling probe on `-level_36` (since removed): every output-aspect change gave one extra frame that differed across the whole scene from the next one. Cause: `gfx_set_output_aspect` takes effect at the render worker's next frame start (D481), but the game thread has already built the next one or two display lists with the previous projection (`portNativeAspect()` reads the render side's last aspect), so those frames were drawn mis-projected into the new rect; the viewmodel is the most visible part. Fix: after a change `gfx_run` drops the next 2 frames (the same path a minimised window uses), so the previous image is held and the first frame shown is already correct. Only changes of `Video.AspectMode` trigger it; steady state and every other setting are unchanged. Not covered: window resize, fullscreen toggle and Native widescreen toggles have the same one-frame mismatch in principle (not reported). + +**Verified (headless):** on the cycling probe, per-change frame diffs went from a big jump plus a second smaller one (frames N, N+2) to one jump; tree / preset / reset probes pass; 60 s `-level_09` smoke rc=0. A gun-pose reset not caused by the projection mismatch could not be reproduced headless. **Owed (maintainer):** switch Original / 16:9 / 21:9 repeatedly in Surface 1 holding a gun with F10 open; confirm no gun flash. Also: `Input.PadTriggerPct` default 23 -> 25 (maintainer: rounder), in the same commit; an ini that already stores 23 keeps it until reset. + +**Residual (maintainer, after the fix):** the glitch still happens rarely, mostly Fill window to Original or a forced ratio, best reproduced on Surface 1 holding a gun with F10 open for a while. Logged as a ROADMAP known issue; deliberately not investigated further (cosmetic, one-off). If revisited: capture many frames across repeated changes and compare the viewmodel only; the viewmodel (D215) may cache a per-aspect value on the game thread that the 2-frame hold does not cover. + +## D510: the F10 overlay rendered ~27 % smaller on the front end than in a level + +**Symptom (maintainer):** at the same window size the overlay box and text were a different size on the main menu / File Select than in Bond's view. The FPS counter (same display list) too. + +**Diagnosis (logged, 640x480 and 1280x720):** hypotheses (a) and (b) were both true, (c) was not. (b) `viGetX() x viGetY()` is 440x330 on the front end and 320x240 in a level (`front.c` `viSetXY(440, 330)`; `osViSetMode` resizes fast3d's SCREEN_WIDTH to match). The overlay laid out in those raw units (a fixed 196-unit hub card), so it covered 196/440 = 45 % of the window on the front end versus 196/320 = 61 % in a level (294 px vs 392 px at 640 wide). (a) The safe-area crop rect (`gfx_get_ui_screen_rect`) follows the last game viewport, so the overlay was also stretched by the in-level "Full" viewport inset and the 1-unit D246 edge trim (logged rect was the full window at 640x480 in both, but it is viewport-dependent). + +**Fix (port-only):** the overlay is PC chrome. `optionsoverlay.c` now lays out on a canonical 320-wide canvas (`ovW()`/`ovH()`, height kept at the real aspect) and calls `gfx_set_overlay_scale(viGetX()/320)`; fast3d scales the overlay DL's rectangles/texrects by that factor about the origin (after the D226 HUD span scale) and, while the overlay DL runs (`g_overlay_window_space`), bypasses the safe-area crop and the D493b edge trim. `gfx_get_ui_screen_rect` returns the window-space rect, so the mouse mapping (now on the canonical canvas) follows. The overlay-closed frame is untouched (flag only set around the overlay DL; no overlay DL, no change). + +**Verified (headless):** front end vs level overlay shots at 640x480 and 1280x720 identical in box size and position; also with native widescreen and with `SafeAreaCrop=0`; FPS counter identical size and top-right offset at 1280x720; tree / preset / reset probes PASS; 60 s `-level_09` smoke rc=0. **Owed (maintainer):** live check at the usual window size and fullscreen, on the front end and in a level; mouse hover/click rows in both; split-screen (2P) with the overlay open (safe-area crop is already off there). Not done: `Game.HudScale` on the menu (S4). + +## D511: four small PD-parity settings (S3) + +All port-only; no `src/game` edit (the crosshair alpha goes through the existing `portCrosshairApplyTintCombine` hook, so `gunfire.c` is untouched). Each new key defaults to today's behaviour, has a shared row (so both UIs show it), a per-dialog reset entry and a one-line description, and is NOT in the Display mode bundles (the preset probe confirms editing them does not flip Display mode). + +- **`Video.FullscreenMode`** (0 borderless, default; 1 exclusive): dropdown shown only while Fullscreen is on. Applied on the host thread via a new `videoRequestFullscreenMode` (window request kind 4) that calls the backend's existing `set_fullscreen_exclusive` (re-enters fullscreen if already on); at startup it feeds `fullscreen_is_exclusive`. Survives a restart through the ini. +- **Center window**: action row `__CenterWindow`, shown only while Fullscreen is off, no ini key. `videoRequestCenterWindow` (kind 3) reads the current window size and moves it with `get_centered_positions` + `set_dimensions`; logs old and new position. Maximized: SDL ignores the move. +- **`Video.CrosshairAlpha`** 0-100 % (default 100): scales the env-colour alpha byte (0x6E at the call site) after the sprite DL is built. At 100 the command is untouched. +- **`Video.CrosshairHealthColor`** (default off): PD's `sightGetCrosshairHealthColor` ramp (green variant; MIT), ratio = `bondhealth + bondarmour` of `g_CurrentPlayer` (both 0..1, clamped to 0..2; read in `input.c` `portCrosshairHealthRatio`). Red below 0.2, red to yellow to 0.6, yellow to green at 1.0, green to cyan with armour above. Precedence (as PD): when on it replaces Crosshair color's colour; the alpha-mask combiner is used whenever either is non-default. + +**Verified (headless):** build rc=0; `-level_09` with `GE_STARTWEAPON=13` and a scripted aim hold (`400:RHOLD`), frame 700: default red crosshair, opacity 40 visibly faded, health colour on shows green at full health (log: ratio 1.000 -> tint 0,255,0); clean-ini run writes only the new lines (`CrosshairAlpha = 100`, `CrosshairHealthColor = 0`, `FullscreenMode = 0`); tree / preset / reset probes PASS; 60 s `-level_09` smoke rc=0. **Not verified headless:** real window state for fullscreen mode and Center window (a game window would be disruptive here). **Owed (maintainer):** exclusive fullscreen enter/leave and surviving a restart (Windows, Deck if convenient); Center window incl. on a second monitor; crosshair opacity and health colour by eye while taking damage. **Docs:** the ROADMAP "PD Extended Options parity" row was not edited here (it is an uncommitted change in the main checkout); move these four items from "Small, port-only" to done when committing it. + +## D512: Game.HudScale applies to the F10 overlay (S4) + +**PD reference:** pd_port has no HUD/menu scale to follow (the only scale hit is the debug menu, `src/game/debug1.c`), so this is GE-native: the whole dialog scales (box, text, hint line, FPS counter), not only the text. + +**Change (port-only, `optionsoverlay.c`):** D510's canonical canvas now also divides by the HUD scale: `ovW()/ovH()` shrink by 100/hud and `ovScale()` (the fast3d overlay scale) grows by hud/100. The box stays centred, always fully on screen, and a larger scale just shows fewer rows (the existing scroll). Mouse hit-testing, dropdown popups and the FPS corner already derive from `ovW/ovH`, so they follow. The text clip size passed to `textRender` is now the canonical size. Changing the slider re-lays out on the next frame (layout is recomputed every emit). 100 % is the D510 mapping exactly. + +**Not done:** the front-end PC Options page (`frontoptions.c`) is hand-laid on the file-select paper art in the game's own 440x330 display list; scaling only its contents would misalign with the art, so it is left at 100 %. Say if you want it (it would need the paper drawn at the same scale). + +**Verified (headless):** 100 %: overlay-open front-end and `-level_09` dumps match the release build apart from the animated title-bar shimmer (the release build differs from itself in the same rows); level box widths at 640x480 are 295 / 392 / 588 px at 75 / 100 / 150 % (expected 294 / 392 / 588), at 1280x720 588 / 784 / 1176, all inside the window; contact sheet shows the 150 % hub scrolling with the box on screen. Tree / preset / reset probes PASS; clean ini byte-identical to the release build's; 60 s `-level_09` smoke rc=0. Pointer mapping is correct by construction (same helpers) but was not exercised headless. **Owed (maintainer):** by-eye pass at 75 / 100 / 150 in a level and on the front end with mouse and pad; the front-end PC Options page decision above. + +## D513: Input.ControlScheme -- Modern / OG controls (#139, ROADMAP "Control scheme selectable") + +**Design:** `docs/dev/notes/T7-CONTROL-SCHEME-DESIGN.md` (local note; PD review: PD adds an "Ext" fifth entry `CONTROLMODE_PC` to the game's own style list, kept per player in `extcontrols`, forced from the ini at load; GE cannot add a watch entry port-only, so the same idea is a port-side switch). Maintainer answers 2026-10-04: D498 style pins stay in Modern, presets are layout + stick swap only in OG; Display mode does not select OG; no 1.3/1.4 button permutation (OG passes buttons through as N64 buttons, faithful: those styles exist to move fire to A / aim to Z); 2.x: see the correction below; global key for v1, per seat later with Select Player; row at the top of the Controller page via the shared table. + +**Change (port-only; `port/src/input.c`, `port/src/optionsoverlay.c`):** new `Input.ControlScheme` (0 Modern, default; 1 OG controls). `styleIsSolitare()` replaces the four `stickSolitare()` call sites (keyboard forward/back, mouse hipfire pitch, pad sticks, and the per-poll re-assert): Modern returns `stickSolitare()` unchanged; OG reads the slot's live `cur_player_control_type_0` (SOLITARE 1.2 and GOODNIGHT 1.4 use the natural-look roles, everything else the 1.1 roles for the first pad) and the re-assert block no longer writes the style, so the watch/save/MP-setup selection stands. OG logs each slot's style change once (`input: original control style, slot N style S (...)`, plus a dual-controller note for 2.x). `Input.NaturalPitch` is unchanged and still only matters in Modern. Row "Control scheme" (Modern / OG controls) at the top of the Controller page, reset entry, help line, not in the Display mode bundles. Test-only env `GE_TESTSTYLE` (GE-ENV-PROBES.md). + +**Verified (headless, scripted slot 0, `-level_09`, seeded):** Modern: the written ini differs from the release build's only by `ControlScheme = 0`, and the 368-line `GE_INPUTLOG` stream (60 non-zero stick polls) is identical to release/v0.4.1. OG: with `GE_TESTSTYLE` 0 / 1 / 2 / 3 each style is logged once and survives to the end of the 366-poll run unchanged; style 4 logs the dual-controller note once (wording corrected below). At stage start the game loads the style from the Bond file (this eep copy held 1.2, written back by earlier builds: log shows 1.1 at init, then 1.2 from the save, then the test style); the port does not touch it. Tree / preset / reset probes PASS; 60 s `-level_09` smoke rc=0. **Not verified headless:** the scripted source bypasses the keyboard and pad mapping branches, so the role mappings themselves in OG (1.1 vs 1.2 sticks, 1.3/1.4 buttons) and a write-then-reload save round trip were not exercised. **Owed (maintainer):** pad pass in Modern and OG with watch-selected 1.1 / 1.2 / 1.3 / 1.4: movement, look, aim, fire, menu nav; split-screen 2P with different styles; mouse unaffected. + +**README:** Controls section documents the scheme and that saves from earlier builds may hold 1.2 (OG players pick their style on the watch once). + +**ROADMAP row text (not edited here):** "Control scheme selectable: Modern / OG controls | done (D513, pad pass owed) | #139, D513, D498, D166 | `Input.ControlScheme` (Modern default / OG controls) at the top of the Controller page; OG leaves the game's own style (watch / save / MP setup) alone; presets set layout and stick swap only in OG; 2.x runs faithfully (second stick from controller idx+playerCount, not mapped); global in v1, per seat with the Select Player work. Pad pass owed in both modes." + +**Naming follow-up (maintainer, 2026-10-04):** the displayed row is "Control style" with values "Ext" (was Modern) and "N64" (was OG controls), matching PD's "Control Style" / "Ext" and the port's other N64/PC wording. The ini key and values (`Input.ControlScheme` 0/1) are unchanged. README updated. +**Second naming pass (maintainer, 2026-10-04):** the second value is "Original" (not N64), in line with the aspect-ratio and Display mode wording; the log line reads `input: original control style, slot N style S (...)`. Values shown: Ext / Original. + +**Correction on 2.x (maintainer review, 2026-10-04):** the earlier claim "2.x acts as 1.1" was wrong. In Original mode the port does not write the style, so the game really runs the 2.x style, and `bondview2.c:4997-5003` reads the second stick from controller `playernum + playerCount` (faithful N64 dual-controller). The port only maps the first pad, with 1.1 roles, so with one pad the second stick is idle, as on an N64. Kept as is. The log line now reads `...; dual-controller style: second stick read from controller ` (once per change); README says a second pad assigned to that slot should provide the 2.x second stick (untested), otherwise use a 1.x style. `GE_TESTSTYLE` is marked scaffolding to strip before the release. + +**Build note for filing (fresh worktree, empty arch tag):** the first build in the new worktree `gh-fullhist-wt-t7` (from `git worktree add ../gh-fullhist-wt-t7 -b feat/control-scheme release/v0.4.1`, then `build-pc/data` copied in) was `./build-pc.sh ntsc-final` from a Git Bash agent shell (`MSYSTEM=MINGW64`, `PROCESSOR_ARCHITECTURE=AMD64` in that shell, PATH prefixed `/c/msys64/mingw64/bin:/c/msys64/usr/bin`). It went through the PowerShell re-exec path ("native TMP unusable"). The cmake log (`build-pc/ge007-native-reexec-cmake.log`) printed `-- Target arch: (64bit=FALSE)`, `-- Target platform: -windows`, `-- Output binary: ge007.`, and the link produced `build-pc/ge007..exe` (a normal 64-bit exe; compile defs still had `-DPLATFORM_64BIT=1`). The cause is in the cache: `build-pc/CMakeFiles/4.4.2/CMakeSystem.cmake` in the new dir has `set(CMAKE_HOST_SYSTEM_PROCESSOR "")` and `set(CMAKE_SYSTEM_PROCESSOR "")`, while the main repo's older `build-pc` has `"AMD64"` for both (same cmake 4.4.2, same generator Ninja, same `CMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe`). `cmake/TargetArch.cmake` takes `CMAKE_SYSTEM_PROCESSOR` when not cross-compiling (`CMAKE_CROSSCOMPILING` was FALSE), so an empty processor gives an empty `TARGET_ARCH` and `BIN_NAME` `ge007.` + `.exe`. Not verified: why the processor was empty; my hypothesis is that the stripped environment of the `.ps1` re-exec (see AGENTS.md build notes, which restore PATHEXT and ComSpec) lacks `PROCESSOR_ARCHITECTURE`, so cmake sees no host processor. It only bites a fresh build directory (the value is cached in `CMakeSystem.cmake`), which is why older build dirs are fine. Workaround used: copy `ge007..exe` to `ge007.x86_64.exe`. + +## D514: emulator saves load directly — `ge007.eep` in 1964/Project64 format is converted on first launch (ROADMAP §5a, S8) + +**Change (port-only; new `port/src/eepimport.c`, one call in `port/src/libultra.c` `geEepromLoad`, README):** copying an emulator `.eep` in as `data/ge007.eep` now just works. Before the game's first EEPROM read, `geEepImportEmulatorSave()` checks each region (block 0 + the five slots) and converts in place only regions that validate in emulator format AND not in port format; a region invalid in both is left alone for the game's own wipe path; port-valid regions are untouched. If anything converts: the original bytes go to `ge007.eep.emulator.bak` first (never overwriting an existing backup — `.bak2`, `.bak3`, …), then the converted file is written, and one line is logged: `eep: imported emulator save (N regions converted, backup )`. Nothing converted -> no write, no log. + +**Rules mirror `tools_pc/eep_convert.py` exactly** (D492): per slot, byte-swap `options` (u16 @12; it lies INSIDE the CRC range [8..96), so a plain word swap would still fail) then re-stamp `chksum1/2` over [8..96) stored little-endian — via the game's own `fileGenerateCRC`, not a C replica; block 0 (no multi-byte field in its CRC range) is a plain byte-swap of its checksum pair. Validation reads the stored words big- or little-endian and compares against `fileGenerateCRC` of the raw bytes (the CRC value is endian-independent). No CMakeLists change (`SRC_PORT` is `GLOB_RECURSE`, as for D297's `legacycrc.c`). Export to an emulator stays with `eep_convert.py`; README "Using an emulator save" now says "copy it in". + +**Verified (headless, `GE_QUITFRAME=2500` boots, `data/ge007.eep` swapped per case):** +1. `./build-pc.sh ntsc-final` rc=0, no new warnings in touched files; `geEepImportEmulatorSave` defined exactly once (nm on the exe), statics folded away. +2. `scratch/eepinv/1964_orig.eep` (md5 `c9d7da5a…`) -> after boot, `ge007.eep` md5 `3ca7cf84…` = byte-identical to the in-game-proven `1964_converted.eep`; `ge007.eep.emulator.bak` byte-identical to `1964_orig.eep`; log line "6 regions converted" present, before `eeprom: loaded`. +3. `1964_converted.eep` (a port save) -> byte-unchanged (`3ca7cf84…`), no backup created, no log line. +4. `1964_orig.eep` with one byte flipped inside slot 3's CRC region (offset 370) -> "5 regions converted"; `GE_SAVELOG=1` shows slots 0-2/4 `-> OK` and slot 3 `stored_crc=51aea8db,50d2558b calc_crc=840fcfe9,c77fd429 -> BAD -> RESET (progress wiped)` — i.e. the importer left it as-is and the game's own wipe path took over (the wiped slot re-stamps to `d4ca8847,a793c0eb`, exactly the converted original's value, so the final file again equals `1964_converted.eep`); second validation pass all five OK. Backup byte-identical to the corrupted input. +5. Second boot after case 2 -> no change (`3ca7cf84…`), no log line, no `.bak2`. +6. `python tools_pc/eep_convert.py verify --as pc` on case 2's result: all six regions OK. + +**Owed (maintainer):** by-eye mission select after importing a real 1964 save (all levels shown as in the emulator). + +**ROADMAP row text (not edited here — applies in the main checkout):** "Emulator saves load directly in the game (no tool for import) | verify (D514, by-eye mission select owed) | D492; `port/src/eepimport.c` called from `geEepromLoad`; D297 `legacycrc.c` same pattern | Copying a 1964/Project64 `.eep` in as `data/ge007.eep` works: on first launch each region (block 0 + 5 slots) that validates in emulator format and not port format is converted in place (same rules as `eep_convert.py`, CRCs from the game's own `fileGenerateCRC`); regions invalid in both are left to the game's wipe; port saves untouched. Original backed up to `ge007.eep.emulator.bak` (never overwritten). On-disk format stays port format. Export stays with `eep_convert.py`." + +## D515: empty arch tag from a fresh build dir (`ge007..exe`) — the re-exec env drops `PROCESSOR_ARCHITECTURE` (build infra; D513 follow-up) + +**Symptom.** The first build in a fresh worktree configured through +`build-pc.sh`'s PowerShell re-exec cached `CMAKE_HOST_SYSTEM_PROCESSOR ""` +and `CMAKE_SYSTEM_PROCESSOR ""` in `build-*/CMakeFiles//CMakeSystem.cmake`. +`cmake/TargetArch.cmake` takes `CMAKE_SYSTEM_PROCESSOR` when not +cross-compiling, so `TARGET_ARCH` came out empty: configure printed +`Target arch: (64bit=FALSE)` / `Output binary: ge007.` and the link produced +`ge007..exe`. The binary was a **correct 64-bit build** — `PLATFORM_64BIT` +comes from `port/include/platform.h`'s own `__x86_64__`, not the CMake value +(only `tools_pc/d88_layoutprobe.c` reads the CMake side) — so only the name +and tag were wrong. Found during T7 (D513 build note); existing build dirs +were immune because the value was cached from an earlier good configure. + +**Root cause.** The stripped re-exec env (the msys→native conversion that +also dropped PATHEXT/ComSpec, AGENTS.md 2026-09-30 notes) can drop +`PROCESSOR_ARCHITECTURE`, which CMake reads to fill +`CMAKE_HOST_SYSTEM_PROCESSOR`. Same bug class, one more missing var. + +**Fix (`fix/build-arch-tag`).** +1. `build-pc.sh` re-exec `.ps1`: next to the PATHEXT/ComSpec restore, restore + `PROCESSOR_ARCHITECTURE` from the machine environment + (`[Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE','Machine')`, + fallback `'AMD64'`); `SystemRoot` and `windir` the same way (fallback + `C:\Windows`). One diag line logging all four vars goes to + `build-*/ge007-native-reexec-diag.log` (kept permanently; cheap, useful). +2. Belt and braces in `cmake/TargetArch.cmake`: when not cross-compiling and + `CMAKE_SYSTEM_PROCESSOR` is empty, ask the compiler + (`${CMAKE_C_COMPILER} -dumpmachine`, same regex as the cross-compile + branch); if that is empty too, `message(FATAL_ERROR)` naming this bug + instead of silently producing `ge007.`. The non-empty path is unchanged. + +**Verified (Git Bash agent shell).** Note on reproduction: this harness's +msys→native conversion passes the full env through (71 vars intact; a plain +forced re-exec kept `PROCESSOR_ARCHITECTURE`), so the stripped-env case was +simulated with `env -u PROCESSOR_ARCHITECTURE` — the same mechanism D513 hit. +1. Before the fix, fresh dir + forced re-exec (`TMP='C:\Windows\'`) + + stripped env: reproduced `ge007..exe`, diag showed + `PROCESSOR_ARCHITECTURE=[]`, `CMakeSystem.cmake` empty/empty. +2. After the fix, same command: diag shows `PROCESSOR_ARCHITECTURE=[AMD64]` + (restored), `Target arch: x86_64 (64bit=TRUE)`, `ge007.x86_64.exe`, + `CMakeSystem.cmake` AMD64/AMD64. +3. After the fix, direct path (normal TMP), fresh dir: same as 2. +4. TargetArch fallback on its own: MSYS2 cmake + gcc + Ninja, stripped env, + fresh dir → `CMakeSystem.cmake` still empty/empty but + `Target arch: x86_64 (64bit=TRUE)` via `-dumpmachine`. +5. Incremental rerun in 2's dir: one-time relink (cache normalisation), then + `ninja: no work to do`, rc=0. + +**Owed / notes.** `../gh-fullhist-wt-t7/build-pc` still holds the empty cached +value and a `ge007..exe` — it needs one reconfigure (or a fresh build dir); +left untouched by this fix. A build dir configured before the fix is fine to +keep: the next `./build-pc.sh` run reconfigures in place and picks up x86_64. + +## D516: Control style Original offers the game's own styles, not the D498 presets (port-only; D513/D498 follow-up) + +**Problem (maintainer live pass 2026-10-04).** With Control style = Original the Controller page still listed the D498 port presets ("1.1 Jinx", "1.2 Christmas", ...). They reuse the N64 numbering but are port layouts, so in Original they read as "modern", and the game's real styles could only be picked on the watch. + +**Change (port-only: `optionsoverlay.c`, `watchsettings.c/.h`, `input.c`, README).** +1. New `Row.schemeOnly` (1 = Ext only, 2 = Original only; resolved to a pointer at init, checked in both the F10 visibility builder and `optionsRowIsShown`, so the front-end PC Options screen follows). `Input.PadPreset` and the Custom pad-bind rows are Ext-only; Ext keeps exactly today's rows. +2. New row "Controller style" (`Bond.Control`, Original only; 1.1 Honey, 1.2 Solitaire, 1.3 Kissy, 1.4 Goodnight, 2.1 Plenty, 2.2 Galore, 2.3 Domino, 2.4 Goodhead, the names in front.c's `MP_controller_configuration_table`). It is a new watch field `WATCH_SETTING_CONTROL` (3-bit value in `save->options` bits 8-10, `OPTION_CONTROLTYPE`), so it uses the same queue -> game-thread apply -> `watchSettingsPersistField` path as the other Bond rows. Seat = the Controller page's player (`watchSettingsSetControlSeat`, set where `s_padSeat` changes). Solo applies via `cur_player_set_control_type` (the watch's own setter) and persists to the Bond file; split-screen writes the seat's player struct directly (D416 mirror, no `g_CurrentPlayer` swap) and does not persist (file2.c forces 1.1 for 2+ players, as on the N64). No new ini key. +3. **Front end / no live player for the seat: the row reads "(in a mission)"** and does not respond (`watchSettingsRead` returns -1 from the game-thread snapshot; the style lives on the live player, and the front end has none). Seats beyond the player count are the same. +4. Button layout in Original: `padPresetEff()` returns 0 unless the stored preset is Custom, so the stored 1.2/1.3/1.4 preset no longer permutes sticks (`stickSwap`, `stickSolitare` fallback). Custom binds still apply but are hidden (edited in Ext only). The table build itself is unchanged: only `padPreset == 3` ever changed buttons, so "preset 0 mapping" is just the non-custom default table. +5. Per-dialog "Reset to defaults" skips `Bond.Control` (the save's style is not a port setting); `GE_WSPROBE_RESET` skips it too. + +**Verified (headless, `-level_09`, `GE_QUITFRAME`).** Build rc=0, no warnings in touched files. Ext: `GE_OPTIONTREEPROBE` PASS, `GE_PRESETPROBE` PASS, `GE_WSPROBE_RESET` 6/6 sections, saved bytes OK; no ini key added (written ini unchanged); `padPresetEff()` is `padPreset` whenever `ControlScheme` is 0. Original: tree probe + preset probe PASS; `GE_D516=` (TEMP hook, queues the row through the real watch path) took the live style 1 -> 2 and 2 -> 7, `d516: post live=N read=N saved=N`, the D513 "original control style" log line followed (7 logs the dual-controller note), `watchsettings: persisted field 8 in Bond file 1`; rows: "Layout preset" and pad binds hidden, "Controller style" shown (Ext: the reverse). Front end, Original: screenshot shows "Controller style (in a mission)", no crash. 60 s `-level_09` smoke rc=0. + +**Live pass (maintainer, 2026-10-04).** F10 in a mission: set Control style to Original and cycled "Controller style" with left/right on the pad -- works, "mostly fine". Two same-day follow-ups landed on `feat/original-styles`: (a) opening the popup while the row is n/a (front end, no live player -> selection -1) overflowed the overlay display list and froze it ("DL overflow", "unknown GBI opcode") -- the popup now refuses to open while the row is unavailable; (b) the Controller page plus the open 8-item popup needed ~8.5k commands against the 8192-command buffer (`DL overflow (8546)` right after the dropdown opened, then freeze) -- buffer raised to 16384. Adjusting the style in-game dropped the fps while held -- a separate pre-existing bug, **D517**. **Split-screen 2P live pass (maintainer, 2026-10-04):** different styles per seat run correctly in a mission ("all good"). Still owed: the 2.x second-pad pass (a real second controller feeding the second stick). + +**Merged 2026-10-04:** `feat/original-styles` (D516 + D517 + D518) merged into `release/v0.4.1` @ 91595597; the branch was deleted. The temporary `GE_D516`/`GE_D516_SEAT` probe in `watchsettings.c` is stripped before the release (ROADMAP scaffolding row). + +## D517: Holding left/right on a Bond settings row dropped the fps -- every hold-repeat step committed (D489 staged sliders only) + +**Problem (maintainer live pass 2026-10-04, during the D516 test).** In a mission, adjusting "Controller style" (or any other Bond row: the Look/AutoAim/AimControl/Sight/LookAhead/Ammo toggles) with left/right on the pad dropped the frame rate for as long as the direction was held. + +**Root cause.** D489 made *sliders* stage their steps and commit once on release (`s_adjWatchField` + `optionsAdjustCommitPending()`), because "each commit is three joyDisablePoll/joyEnablePoll handshakes plus an EEPROM file rewrite on the game thread". The other value kinds -- ROW_TOGGLE, ROW_ENUM (incl. the new `Bond.Control`), ROW_MSAA, ROW_FPSCAP -- still called `rowSet()` (commit = 1) on EVERY step. Each commit queues through to the game thread's `watchSettingsPersistField()` -> `fileWriteSave()`. Hold-repeat fires every 4 frames, so a held direction kept the game thread persisting: reproduced headlessly with a pad physically held -- `GE_PERFSTAT` showed 60 fps baseline vs 40-50 fps while cycling, and the log shows the style walking 1->0->7->...->3 with `watchsettings: persisted field 8 in Bond file 1` 57 times over a 900-frame run. (The overlay's own render cost is not the cause: `dl=` stays ~0.3 ms with the heaviest page open.) + +**Fix (port-only, `optionsoverlay.c`).** New `rowSetStepWatch()`: a stepped row that has a watch field now stages exactly like a slider (commit = 0; switching the pending field commits the previous one first), and the existing `optionsAdjustCommitPending()` -- which already runs every frame while left/right is not held in BOTH UIs (F10 `overlayHandleInputLocked`, frontoptions release path) and on F10 close -- saves once on release. Non-watch rows fall through to a plain commit, so ini rows are byte-for-byte unchanged. The dropdown popup confirm (`ddConfirm`) still commits immediately: one deliberate pick, not a hold. + +**Verified.** Build rc=0, no warnings in touched files. Headless: `GE_OPTIONTREEPROBE` PASS + `GE_PRESETPROBE` PASS (front), `GE_WSPROBE_RESET` stage 6/6 sections + saved bytes OK; `GE_PERFSTAT` baseline still 60 fps with the overlay closed. **Live pass (maintainer, 2026-10-04):** confirmed fixed -- "those things are fixed". + +## D518: The pad layout preset was one global value -- setting another player's preset changed the main player's mapping too + +**Problem (maintainer report 2026-10-04, during the D516/D517 stress test).** In single player, opening Select player -> Player 2 and changing that seat's "Layout preset" also changed P1's live mapping -- "I don't want it to affect at all". + +**Root cause.** `Input.PadPreset` was a single global int in input.c; the per-seat Controller pages (D469 Select player, `s_padSeat`) all edited the same value, and `padTabGet()` built every seat's table from it. The custom binds were already per seat (`Input.Pad[N].`); only the preset was shared. + +**Fix (port-only).** input.c: `padPreset[MAX_PADS]`; ini keeps `Input.PadPreset` as **seat 1's** key (existing inis keep their P1 value) and adds `Input.PadPreset2/3/4` (default Jinx). All the preset-derived helpers (`padPresetCustom`, `padPresetEff`, `stickSolitare`, `stickSwap`, `styleIsSolitare`) take a seat; every call site is inside `inputComputePadSlot(int idx, ...)` and passes its own slot. The table signature hashes all four presets (custom bind strings included when any seat is Custom). New accessor `inputPadPresetPtr(seat)`. optionsoverlay.c: new `padSetSeat()` re-points the "Layout preset" row's `ptr` and the Custom-only bind rows' `showPtr` at the selected seat's value; all three seat-change sites (F10 link open, front-end `optionsRowLinkOpened`, ROW_PADSEAT step) go through it. GE_PADMAPTEST now drives the per-seat array (all seats Custom in the custom phase). + +**Verified.** Build rc=0. Headless: `GE_PADMAPTEST` PASS (seat-independence checks c1-c11), `GE_OPTIONTREEPROBE` + `GE_PRESETPROBE` PASS, and a temporary `Input.PadPreset2 = 4` in the ini produced exactly `pad table rebuilt (presets 0/4/0/0)` (ini restored byte-identical after). **Live pass (maintainer, 2026-10-04):** single player, P2 preset changed -- P1's page and mapping untouched, bind rows follow the per-seat Custom state; "good". **Split-screen 2P live pass:** each seat ran its own preset in a mission ("all good"). + +## D519: One PC options UI -- the file-select "PC Options" entry opens the F10 overlay; the MENU_PC_OPTIONS screen is gone (port-only) + +**Why (maintainer idea 2026-10-04).** Two PC options UIs (D343 front-end screen, D504-D512 F10 overlay) meant two navigation implementations, two probe sets, and the `frontOptionsBlocksOverlay` gate. + +**Change.** `port/src/frontoptions.c` is now only the file-select entry label: a click (A/Z/Start) calls `optionsOverlayToggle()`; file select stays underneath and its idle timer is held (`g_MenuTimer = 0`, already in the hook) while the overlay is open. Gaps found by the P4 design pass: (1) Bond-file rows ("Controller style", profile chooser) were shown only on the old screen -- `optionsRowIsShown` and the chooser's left/right now key on `current_menu == MENU_FILE_SELECT`; they use the same data path as before (`watchsettings.c` `frontFolder()`: chosen file > `selected_folder_num` > first valid). (2) The watch chooser was keyed to `MENU_PC_OPTIONS`; re-keyed likewise. + +**Deleted.** The screen's init/update/interface/draw, paging, hover/pad/keyboard navigation (about 800 lines), the `MENU_PC_OPTIONS` enum value, its four `front.c` dispatch/draw sites and their `#ifdef PORT` blocks, `frontOptionsBlocksOverlay()` and its gates in `video.c` (F10) and `input.c` (pad Select), and the `input.c` keyboard/wheel branch for that screen. **Kept in `src/game`:** the single `#ifdef PORT` call to `optionsFileSelectLabel()` in `constructor_menu05_fileselect` (it is the entry itself; the D343 Rule-2 sign-off covers it) and the D399 `copypos` shift. `pcOptionsKeyboardPad` in `input.c` is now only used by the self-tests. + +**Probes.** Retired `GE_FRONTOPTIONS_SECTION`, `GE_FRONTNAVLOG`, `GE_FRONTOPTS_WIDTHS`, `GE_FRONTOPTIONS_AUTO`; added `GE_FILESELECT_OVERLAY=` (open; n > 1 closes after n frames) because nothing else opens the overlay from file select headlessly. + +**Verified (headless, 4 build cycles).** Build rc=0. Front end with the overlay open: `GE_OPTIONTREEPROBE` PASS, `GE_PRESETPROBE` PASS, `GE_WSPROBE_RESET` front path all sections failures=0. Frame captures of file select with the overlay open (tree over the folders) and after the close (plain file select, no drop to title). In a level (`-level_09`, 60 s, `GE_QUITFRAME=3600`): tree + preset PASS, stage reset probe failures=0, saved bytes OK, exit 0. **Owed (maintainer, by eye):** mouse, keyboard and pad on the file-select overlay, the profile chooser and Controller style rows with a real profile, and the overlay footer text overlapping the Copy/Erase bar. + +**Follow-ups 2026-10-04 (P4j; maintainer by-eye pass found 4 faults + 1 request).** (1) *Profile chooser missing:* it was never an overlay row -- the old screen drew a virtual "Profile" row at the top of every page (not in the row table), so the re-key in D519 had nothing to show. Now `__BondFile` (ROW_BOND_FILE) is a real table row that `overlayUpdateVisible` injects as the first row of every page that holds profile-backed rows (Audio, Controller, Game), on file select only; left/right/accept step the chosen file. Verified headless (`GE_OVNAV` log: chooser visible at row 1 of Audio; Game capture shows "Profile 1" first). (2) *Pad double-press:* the overlay's edge state (`prev*`) was zeroed while closed, so the A (or click/Enter) that opened it counted as a fresh press on the first handled frame; now the first frame after any open adopts the held buttons as "previous" (`s_seedPrev`). Code-verified only (no headless pad). (3) *Crosshair pointer froze:* the overlay branch in `input.c` returned before the front-end pointer block; now, on a front-end screen with the mouse enabled, `overlayFrontEndCrosshair()` maps the OS mouse to the game's cursor with an exact (non-inset) window-to-canvas map, so the drawn crosshair is where the overlay hit-tests, and the OS cursor is hidden (one pointer). Not verified headless (needs a real mouse). (4) *Hint overlapped Copy/Erase:* the hint lines now sit in a box-body panel under the card (drawn with the card, before any text: a fill after text turned the glyphs into blocks) and are fitted to the box width. The bar is still faintly visible through the translucent panel (a darker underlay would fix it). (5) *Resume where you left off:* `optionsOverlayToggle` records section / focused row / scroll / pad seat on close and restores them on open when the row is still shown, else the top of its section, else the root; backing out to the root remembers the root. Verified headless with `GE_OVNAV`. New harness token hook `GE_OVNAV` (O toggle, D/U, A open, B back, P log; one token per 20 file-select frames). Side note: one reset-probe run on a real two-file eep logged `Bond.LookAhead = 0, want 1` for the Game section (failures=1); two reruns passed -- looks state/timing dependent, not touched by this change, left open. + + +## D520: F10 overlay slider wedge, marker and dropdown arrow antialiased (port-only; D504 follow-up) + +**Problem (maintainer by-eye pass 2026-10-04).** The overlay's sliders and dropdown arrows looked more aliased than the PD port's menus. **Cause (P4k investigation, local note `OVERLAY-AA-INVESTIGATION.md`):** the overlay lays out on a 320-wide canvas scaled to the window (`ovScale`), and `drawWedge` / the dropdown tick built their diagonals from integer-pixel fill rects (2-px columns, whole-pixel heights), so every stair step is `ovScale` screen pixels (4 px at 1280, 8 px at 2560). PD draws the same shapes as triangles (`menugfx.c:861-906`) that MSAA smooths. Text was not the issue (bilinear-magnified glyphs: soft, not jagged). + +**Fix (`port/src/optionsoverlay.c`).** `drawWedge`: the solid body as runs of equal whole height plus one coverage-alpha top pixel per 1-px column; the marker and the dropdown arrow via `drawTriDownAA` (per-pixel area coverage from 4 sub-rows). Render state is set once per shape (`aaBegin`) and each piece is 2 commands (`aaPiece`, pieces under alpha 6 skipped), so the display-list size stays about where it was (D516 overflow history; `OV_BUF_CMDS` 16384). Applies in every Display mode (maintainer: the overlay is port UI, not part of the N64 picture). + +**Verified.** Build rc=0, no warnings; `GE_OPTIONSOVERLAY=1 GE_OPTIONSOVERLAY_SECTION=__HdrAudio GE_PCDUMP=300-300` before/after: the ramps go from ~7 visible steps to a smooth ramp, markers/arrows softened; no DL overflow logged. **Owed:** maintainer by-eye at their window size. diff --git a/docs/porting-notes.md b/docs/porting-notes.md index 7c09580f..eab6c9ed 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -1414,6 +1414,7 @@ A port stub that forces a value to `-1` (D154 `texnum`, D135) can hide latent la - **Triage trap:** check whether a `PROPFLAG_*` hit is on `prop->flags` (runtime u8) or on `obj->flags` / pdef flags (u32 definition bits) before treating it as a reader (`propobj.c:8417`, `prop.c` 233-467 are the latter). - **Remaining class member:** FOV/widescreen widening of `camIsPosInScreen` and `c_lodscalez` (D222). - **Fixed-size per-frame budgets are readers too (D500):** the dyn.c vtx pool (`-mvtx`, as low as 40 KB) is bump-allocated with no bounds check and sized for what the N64 could see; widened views overran it (Aztec triangles). Look for unchecked per-frame pools sized by per-level tokens when a widening feature shows corruption only in busy scenes. +- **A pixel ratio is not a fog factor (D503):** "~97% fogged" was a pixel-colour ratio; derive any fog snap cutoff from measured vertex fog, not from it. And before blaming fog for a far object, do the cheap 4:3 + N64-FOV test: if it vanishes, the cause is the widened frustum, not fog. - **Reveal, not regression (D503):** the N64's 16-bit framebuffer hid 2-5 level differences (near-full fog against haze); 32-bit output shows them, plus the room-scissor edges of nearly fogged objects. Measure pixel values against the fog colour before blaming draw distance. ## D19. A "click after the shot" report needs the trigger-hold duration, not just the state machine (D433/D467) @@ -1876,3 +1877,6 @@ Also the read side: D496 (`generate_language_specific_text_for_weapon` strcat'd **Audio device/gain changes belong on the producer thread (D470).** The SDL audio path is a queue (`SDL_QueueAudio`), fed and sized only from `audioSetNextBuffer`/`audioGetAiLengthBytes`. To change the device live, have UI/event threads only set atomics and do the close/reopen at the top of `audioSetNextBuffer`; never close `dev` from the UI thread. Apply master gain to a scratch copy (never the game's buffer) and pass the original pointer through at 100 so the default stays byte-identical. `SDL_AUDIODEVICEREMOVED.which` is the opened device id for output devices. **Overlay/menu input must name its owner pad; never hard-code seat 0 (D472).** When several pads can open a shared UI, keep a per-pad edge array (one shared `prev` flag masks edges and double-toggles), record the opener, and resolve the effective owner (opener -> lowest attached -> 0) at read time so a detach cannot strand the UI. Port-layer 2D that must pillarbox under native widescreen: wrap it in `PORT_HUD_ASPECT(CENTER)` and map the mouse through `vis = (4/3)/portNativeAspect()`. + +### The options row table has two UIs; new row kinds need a front-end answer (menu PD alignment) +`port/src/optionsoverlay.c`'s `rows[]` is consumed by the F10 overlay and by `frontoptions.c` through the `optionsRow*` accessors. A row kind that only one UI understands (separator, Back, hub-only actions) must be hidden from the other in `optionsRowIsShown()`, and should be a `ROW_ACTION` with a key prefix so the section-reset and reset-probe loops (which skip `ROW_ACTION`) stay correct. Moving a row between sections silently changes what that section's "Reset to defaults" resets: re-run `GE_WSPROBE_RESET` after any regroup. diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index 753ed3fc..4ec7c593 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -279,6 +279,11 @@ extern "C" void gfx_set_safe_area_crop(int on) { * winner alternate frame to frame). While a 2+ player stage is running the * N64's own split layout is used unmodified. */ static bool g_split_screen = false; +/* D510: set while the port-layer overlay DL (F10 panel / FPS counter) runs. + * That is PC chrome, not game content: it maps the logical canvas onto the + * whole window, bypassing the safe-area crop (which follows the game's last + * viewport and so differed between the front end and a level). */ +static bool g_overlay_window_space = false; extern "C" void gfx_set_split_screen(int on) { g_split_screen = !!on; } @@ -290,8 +295,20 @@ extern "C" void gfx_set_split_screen(int on) { static float g_output_aspect = 0.0f; static struct XYWidthHeight g_output_rect = { 0, 0, 0, 0 }; +/* D509: the render worker applies a new output aspect at the start of its next + * frame, but the game thread has already built the next one or two display lists + * with the old projection (portNativeAspect reads the render side's last + * aspect). Drawn into the new rect those frames are mis-projected for one or two + * frames (visible as a flash, worst on the gun/hand model). Hold the previous + * image for the frames that could carry the old aspect: gfx_run drops them through + * the same path a minimised window uses. */ +static int g_aspect_settle = 0; +#define ASPECT_SETTLE_FRAMES 2 + extern "C" void gfx_set_output_aspect(float aspect) { - g_output_aspect = aspect > 0.1f ? aspect : 0.0f; + const float a = aspect > 0.1f ? aspect : 0.0f; + if (a != g_output_aspect) g_aspect_settle = ASPECT_SETTLE_FRAMES; + g_output_aspect = a; } extern "C" void gfx_get_output_rect(int32_t *outX, int32_t *outY, int32_t *outW, int32_t *outH) { @@ -2130,6 +2147,11 @@ static void gfx_sp_vertex(size_t n_vertices, size_t dest_index, const Vtx* verti float fog_z = z * winv * rsp.fog_mul + rsp.fog_offset; d->fog = clampf(fog_z, 0.f, 255.f); + /* D503: the N64's 16-bit framebuffer hid objects ~97% fogged; + * 32-bit output reveals them as silhouettes. Snap to full fog. */ + if (d->fog >= 247.f) { + d->fog = 255.f; + } } else { d->fog = rdp.fog_color.a; } @@ -2962,14 +2984,15 @@ static void gfx_sp_extra_geometry_mode(uint32_t clear, uint32_t set) { gfx_update_aspect_mode(); } -static void gfx_adjust_viewport_or_scissor(XYWidthHeight* area, bool preserve_aspect = false) { +static void gfx_adjust_viewport_or_scissor(XYWidthHeight* area, bool preserve_aspect = false, bool window_space = false) { + window_space = window_space || g_overlay_window_space; /* D510 */ // HACK: assume all target framebuffers have the same aspect // Use floor/ceil to ensure scissor fully contains the logical region // and prevents sub-pixel gaps at viewport edges // g_gpSafeTop already plays the exact role SCREEN_HEIGHT plays below // (both are the bottom-up Y value of the mapped region's TOP edge) -- // this reduces to the untouched original formula when crop is off. - const bool crop = g_safe_area_crop_enabled && !g_split_screen && g_gpSafeHeight > 0.0f; + const bool crop = g_safe_area_crop_enabled && !g_split_screen && !window_space && g_gpSafeHeight > 0.0f; const float safeTop = crop ? g_gpSafeTop : (float)SCREEN_HEIGHT; const float safeHeight = crop ? g_gpSafeHeight : (float)SCREEN_HEIGHT; const float ratioY = gfx_current_dimensions.height / safeHeight; @@ -2987,8 +3010,8 @@ static void gfx_adjust_viewport_or_scissor(XYWidthHeight* area, bool preserve_as // TV-overscan margin) and fold it into the same toggle: trim the same // fixed 1-unit margin from both edges rather than trying to force // content to reach a boundary it may never actually be drawn to. - const float safeLeft = (g_safe_area_crop_enabled && !g_split_screen) ? 1.0f : 0.0f; - const float safeWidth = (g_safe_area_crop_enabled && !g_split_screen) ? (float)SCREEN_WIDTH - 2.0f : (float)SCREEN_WIDTH; + const float safeLeft = (g_safe_area_crop_enabled && !g_split_screen && !window_space) ? 1.0f : 0.0f; + const float safeWidth = (g_safe_area_crop_enabled && !g_split_screen && !window_space) ? (float)SCREEN_WIDTH - 2.0f : (float)SCREEN_WIDTH; const float ratioX = gfx_current_dimensions.width / safeWidth; float x1 = (area->x - safeLeft) * ratioX; @@ -3033,7 +3056,7 @@ static void gfx_adjust_viewport_or_scissor(XYWidthHeight* area, bool preserve_as * and gfx_current_game_window_viewport's documented convention. */ extern "C" void gfx_get_ui_screen_rect(int32_t *outX, int32_t *outY, int32_t *outW, int32_t *outH) { struct XYWidthHeight area = { 0, (int16_t)SCREEN_HEIGHT, (uint32_t)SCREEN_WIDTH, (uint32_t)SCREEN_HEIGHT }; - gfx_adjust_viewport_or_scissor(&area, false); + gfx_adjust_viewport_or_scissor(&area, false, true); /* D510: the overlay is window-space */ *outX = area.x; *outY = area.y; *outW = (int32_t)area.width; @@ -3142,7 +3165,7 @@ static void gfx_dp_set_scissor(uint32_t mode, uint32_t ulx, uint32_t uly, uint32 // edges showed live scene/background pixels. Keep the geometry unscaled // and scissor those two logical columns out instead, so they show the // frame clear (black) like the rest of the overscan border. - if (!g_safe_area_crop_enabled && !g_split_screen) { + if (!g_safe_area_crop_enabled && !g_split_screen && !g_overlay_window_space) { const float edge = 1.0f; if (x < edge) { width -= edge - x; @@ -3539,7 +3562,31 @@ static void gfx_draw_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int32_t lr static float s_hud_scale = 1.0f; static int32_t s_hud_ax = 0, s_hud_ay = 0; /* anchor, 10.2 fixed (px*4) */ +/* D510: uniform scale about the origin for the port overlay DL, which lays + * itself out on a canonical 320-wide canvas whatever the game's current VI + * canvas is (440x330 on the front end). Applied after the HUD span scale. */ +static float s_overlay_scale = 1.0f; +extern "C" void gfx_set_overlay_scale(float s) { + s_overlay_scale = (s > 0.0f) ? s : 1.0f; +} + static inline void gfx_hud_scale_rect(int32_t& ulx, int32_t& uly, int32_t& lrx, int32_t& lry) { + if (s_overlay_scale != 1.0f && g_overlay_window_space) { + const bool hud = (s_hud_scale != 1.0f); + if (hud) { + ulx = s_hud_ax + (int32_t)lroundf((float)(ulx - s_hud_ax) * s_hud_scale); + lrx = s_hud_ax + (int32_t)lroundf((float)(lrx - s_hud_ax) * s_hud_scale); + uly = s_hud_ay + (int32_t)lroundf((float)(uly - s_hud_ay) * s_hud_scale); + lry = s_hud_ay + (int32_t)lroundf((float)(lry - s_hud_ay) * s_hud_scale); + } + ulx = (int32_t)lroundf((float)ulx * s_overlay_scale); + lrx = (int32_t)lroundf((float)lrx * s_overlay_scale); + uly = (int32_t)lroundf((float)uly * s_overlay_scale); + lry = (int32_t)lroundf((float)lry * s_overlay_scale); + if (ulx < 0) ulx = 0; + if (uly < 0) uly = 0; + return; + } if (s_hud_scale == 1.0f) { return; } @@ -3556,10 +3603,11 @@ static inline void gfx_hud_scale_rect(int32_t& ulx, int32_t& uly, int32_t& lrx, static void gfx_dp_texture_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int32_t lry, uint8_t tile, int16_t uls, int16_t ult, int16_t dsdx, int16_t dtdy, bool flip) { - if (s_hud_scale != 1.0f) { /* D226 */ + const float ov = (g_overlay_window_space && s_overlay_scale != 1.0f) ? s_overlay_scale : 1.0f; /* D510 */ + if (s_hud_scale != 1.0f || ov != 1.0f) { /* D226 */ gfx_hud_scale_rect(ulx, uly, lrx, lry); - dsdx = (int16_t)lroundf((float)dsdx / s_hud_scale); - dtdy = (int16_t)lroundf((float)dtdy / s_hud_scale); + dsdx = (int16_t)lroundf((float)dsdx / (s_hud_scale * ov)); + dtdy = (int16_t)lroundf((float)dtdy / (s_hud_scale * ov)); } uint64_t saved_combine_mode = rdp.combine_mode; if ((rdp.other_mode_h & (3U << G_MDSFT_CYCLETYPE)) == G_CYC_COPY) { @@ -4341,6 +4389,11 @@ extern "C" void gfx_run(Gfx* commands) { dropped_frame = true; return; } + if (g_aspect_settle > 0) { /* D509: hold the old image, see gfx_set_output_aspect */ + --g_aspect_settle; + dropped_frame = true; + return; + } dropped_frame = false; if (s_shader_warm_pending) { @@ -4362,7 +4415,10 @@ extern "C" void gfx_run(Gfx* commands) { { Gfx* overlay = optionsOverlayEmit(); if (overlay != nullptr) { + g_overlay_window_space = true; /* D510 */ gfx_run_dl(overlay); + g_overlay_window_space = false; + s_overlay_scale = 1.0f; } } gfx_flush(); diff --git a/port/include/frontoptions.h b/port/include/frontoptions.h index 8e42808a..4a599037 100644 --- a/port/include/frontoptions.h +++ b/port/include/frontoptions.h @@ -2,15 +2,9 @@ #define PORT_FRONTOPTIONS_H /* - * D343: PC options as a GE front-end screen, MENU_PC_OPTIONS - * (port/src/frontoptions.c). - * - * Hooks: - * front.c the five MENU_* dispatch sites : frontOptionsMenu{Init,Update, - * Interface,Draw} and the debug name (all #ifdef PORT) - * front.c constructor_menu05_fileselect : optionsFileSelectLabel() draws the - * file-select entry label and enters MENU_PC_OPTIONS on a click - * video.c / input.c : F10 / Select don't open the overlay on this screen + * The file-select "PC Options" entry (port/src/frontoptions.c): opens the F10 + * overlay over file select. Hook: front.c constructor_menu05_fileselect calls + * optionsFileSelectLabel() (#ifdef PORT). */ #include @@ -20,13 +14,8 @@ extern "C" { #endif -void frontOptionsMenuInit(void); -void frontOptionsMenuUpdate(void); -void frontOptionsMenuInterface(void); -Gfx *frontOptionsMenuDraw(Gfx *DL); Gfx *optionsFileSelectLabel(Gfx *gdl); /* file-select hook */ -int frontOptionsBlocksOverlay(void); /* 1 while MENU_PC_OPTIONS is up */ #ifdef __cplusplus } diff --git a/port/include/input.h b/port/include/input.h index da00694a..f6f432c5 100644 --- a/port/include/input.h +++ b/port/include/input.h @@ -84,7 +84,9 @@ int inputBindingSetSlot(const char *key, int slot, int code); int inputBindingResetKey(const char *key); /* D469: gamepad action table, presets and per-seat rebinding. - * Input.PadPreset 0 = Jinx 1.1 (default), 3 = Custom (Input.Pad[N].). + * D518: the preset is per seat -- Input.PadPreset (seat 1; pre-D518 global + * key) plus Input.PadPreset2/3/4. 0 = Jinx 1.1 (default), 3 = Custom + * (Input.Pad[N].). * Seat = pads[] index. Sources are indices into the SDL-name table * (inputPadSourceName); -1 = none. */ int inputPadActionCount(void); @@ -95,6 +97,7 @@ const char *inputPadSourceName(int src); int inputPadSeatPresent(int seat); unsigned inputPadHeldSources(int seat); /* bit i = source i held */ int inputPadPresetStep(int cur, int dir); +int *inputPadPresetPtr(int seat); /* D518: per-seat preset value */ void inputPadBindingText(int seat, int act, int slot, char *out, int n); int inputPadBindingSet(int seat, int act, int slot, int src); /* src < 0 clears */ int inputPadBindingReset(int act); /* all seats; returns seats changed */ diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h index 947b0cc3..82f92134 100644 --- a/port/include/optionsoverlay.h +++ b/port/include/optionsoverlay.h @@ -35,6 +35,7 @@ extern "C" { /* Toggle open/closed. On the closing edge the config is saved. */ void optionsOverlayToggle(void); +void optionsOverlayTestStep(char c); /* GE_OVNAV harness (frontoptions.c) */ /* ESC: back to categories, or close from the category list (host-thread request). */ void optionsOverlayBack(void); @@ -67,8 +68,19 @@ int optionsRowCount(void); int optionsRowIsHeader(int i); int optionsRowHeaderParent(int i); /* -1 for root sections */ int optionsRowChildHeader(int i); /* -1 unless this row opens a nested section */ +int optionsRootHeader(int k); /* k-th top-level page in PD hub / tab order, rows[] header index; -1 past the end */ +int optionsRowIsBindingsPage(int hdr); /* Key Bindings page (front help line) */ +int optionsRowIsPadPage(int hdr); /* a seat's Controller page (front help line) */ +int optionsRowSectionMixedScope(int hdr); /* page mixes per-file and global rows ("(per profile)" tags) */ +void optionsRowTitle(int i, char *out, int n); /* header title, seat-aware ("PLAYER 2 CONTROLLER") */ +void optionsRowLinkOpened(int i); /* call before opening link row i's child: a Select-player row sets the seat */ +int optionsRowSeat(int i); /* 0-3 for a "Player N" row, else -1 */ +int optionsPadSeat(void); /* seat the Controller / Key bindings pages edit (0-based) */ int optionsRowIsShown(int i); const char *optionsRowLabel(int i); +/* D507: one-line plain-language description of row i, or NULL. Shown under the + * F10 box and on the front-end options page for the focused row. */ +const char *optionsRowHelp(int i); int optionsRowIsSlider(int i); int optionsRowIsBind(int i); void optionsRowBeginBind(int i); diff --git a/port/include/video.h b/port/include/video.h index 4ba07e3b..526b8999 100644 --- a/port/include/video.h +++ b/port/include/video.h @@ -83,6 +83,8 @@ void videoRequestLiveConfigForKey(const char *key); * (host thread) applies. The Get* helpers are read-only and thread-safe. */ void videoRequestWindowSize(int w, int h); void videoRequestFullscreen(int on); +void videoRequestFullscreenMode(int exclusive); /* D511 */ +void videoRequestCenterWindow(void); /* D511 */ void videoGetWindowSize(int *w, int *h); /* D447: the output rect as fractions of the window (0..1, top-left origin); * (0,0,1,1) unless Video.AspectMode = Original is letter/pillarboxing. */ diff --git a/port/include/watchsettings.h b/port/include/watchsettings.h index a61f002b..d479ca25 100644 --- a/port/include/watchsettings.h +++ b/port/include/watchsettings.h @@ -12,6 +12,7 @@ enum WatchSettingField { WATCH_SETTING_SIGHT, WATCH_SETTING_LOOKAHEAD, WATCH_SETTING_AMMO, + WATCH_SETTING_CONTROL, /* D516: control style 0..7 (save options bits 8-10), per seat; stage only */ WATCH_SETTING_COUNT }; @@ -44,5 +45,9 @@ int watchSettingsBlankValue(enum WatchSettingField field); * contexts -- nothing is queued there). Drives the in-stage reset probe's * dispatch check. */ int watchSettingsQueueCount(void); +/* D516: which controller seat WATCH_SETTING_CONTROL reads/edits (the F10 + * Controller page's player). Any thread. WATCH_SETTING_CONTROL reads -1 when + * the seat has no live player (front end, seat beyond the player count). */ +void watchSettingsSetControlSeat(int seat); #endif diff --git a/port/src/eepimport.c b/port/src/eepimport.c new file mode 100644 index 00000000..f869a848 --- /dev/null +++ b/port/src/eepimport.c @@ -0,0 +1,135 @@ +/* + * port/src/eepimport.c — D514: import an emulator (1964 / Project64) .eep + * save on load, so copying a `GOLDENEYE-usa.eep` in as data/ge007.eep just + * works (ROADMAP §5a; supersedes the manual tools_pc/eep_convert.py step). + * + * Why conversion is needed (D492): the layout is byte-identical on both + * sides (2048 B; block 0 smallSave, five 96-byte save_data slots from + * block 4), but this port stores a few multi-byte fields in little-endian + * host order — per slot chksum1/chksum2 (@0/@4) and options (u16 @12), plus + * block 0's checksum pair — while emulator saves store them big-endian. + * The CRC (fileGenerateCRC, src/game/crc.c) hashes raw bytes, so the value + * is endian-independent; an unconverted save fails validation and every + * slot is wiped on first boot. + * + * Rules (mirror tools_pc/eep_convert.py exactly): + * - per region (block 0 + the five slots), convert only regions that + * validate in emulator format AND not in port format; + * - a region invalid in both is left alone — the game wipes it, as today; + * - a port-valid region is untouched (an existing port save therefore + * stays byte-unchanged and gets no backup); + * - per slot: swap options (it lies INSIDE the CRC range [8..96), so a + * plain word swap would still fail), then re-stamp chksum1/chksum2 with + * the game's own fileGenerateCRC, stored little-endian; block 0 has no + * multi-byte field in its CRC range, so its checksum pair is simply + * byte-swapped; + * - before writing anything: back up the original bytes to + * ge007.eep.emulator.bak (never overwriting an existing backup — .bak2, + * .bak3, ...); log one line. Nothing converted -> no write, no log. + */ + +#include +#include + +#include +#include "file.h" /* save_data, smallSave */ +#include "system.h" /* sysLogPrintf */ + +extern void fileGenerateCRC(u8 *addressA, u8 *addressB, save_data *retval); + +#define EEP_SIZE 2048u + +static u32 eepBe32(const u8 *p) +{ + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3]; +} + +/* Does region r (len bytes, checksum words at [0..8), CRC range [8..len)) + * validate with its stored words read in `bigEndian` order? */ +static int eepRegionValid(const u8 *r, int len, int bigEndian) +{ + save_data calc = {0}; /* only chksum1/chksum2 are read back */ + fileGenerateCRC((u8 *)r + 8, (u8 *)r + len, &calc); + u32 s1, s2; + if (bigEndian) { s1 = eepBe32(r); s2 = eepBe32(r + 4); } + else { memcpy(&s1, r, 4); memcpy(&s2, r + 4, 4); } + return s1 == (u32)calc.chksum1 && s2 == (u32)calc.chksum2; +} + +/* Convert one region in place from big- to little-endian. */ +static void eepRegionConvert(u8 *r, int len) +{ + if (len == 96) { /* save_data: options (u16 @12) is inside the CRC range */ + u8 t = r[12]; r[12] = r[13]; r[13] = t; + } + /* checksum pair -> little-endian (block 0: plain swap of both words) */ + for (int w = 0; w < 2; w++) { + const u8 *s = r + 4 * w; + u8 o[4] = { s[3], s[2], s[1], s[0] }; + memcpy(r + 4 * w, o, 4); + } + if (len == 96) { /* re-stamp AFTER the options swap */ + save_data calc = {0}; + fileGenerateCRC(r + 8, r + len, &calc); + memcpy(r, &calc.chksum1, 4); + memcpy(r + 4, &calc.chksum2, 4); + } +} + +/* Convert an emulator-format ge007.eep at `path` in place. Returns the + * number of regions converted (0 = nothing done: no file, wrong size, or a + * save that already is port format / invalid everywhere). */ +int geEepImportEmulatorSave(const char *path) +{ + FILE *fp = fopen(path, "rb"); + if (!fp) return 0; + u8 orig[EEP_SIZE]; + size_t got = fread(orig, 1, EEP_SIZE, fp); + fclose(fp); + if (got != EEP_SIZE) return 0; /* not a save file; the loader handles it */ + + u8 buf[EEP_SIZE]; + memcpy(buf, orig, sizeof(buf)); + + int converted = 0; + for (int i = 0; i < 6; i++) { + const int len = (i == 0) ? (int)sizeof(smallSave) : (int)sizeof(save_data); + u8 *r = buf + (i == 0 ? 0 : 32 + (i - 1) * (int)sizeof(save_data)); + if (eepRegionValid(r, len, 0)) continue; /* port format: untouched */ + if (!eepRegionValid(r, len, 1)) continue; /* invalid in both: game wipes it */ + eepRegionConvert(r, len); + converted++; + } + if (converted == 0) return 0; + + /* Backup first, never overwriting an existing one (.bak2, .bak3, ...). */ + char backup[1024]; + for (int n = 1; n <= 99; n++) { + if (n == 1) + snprintf(backup, sizeof(backup), "%s.emulator.bak", path); + else + snprintf(backup, sizeof(backup), "%s.emulator.bak%d", path, n); + FILE *t = fopen(backup, "rb"); + if (!t) break; + fclose(t); + } + FILE *bf = fopen(backup, "wb"); + if (!bf) { + sysLogPrintf(LOG_ERROR, "eep: cannot write backup %s; save left unconverted", backup); + return 0; + } + fwrite(orig, 1, EEP_SIZE, bf); + fclose(bf); + + fp = fopen(path, "wb"); + if (!fp) { + sysLogPrintf(LOG_ERROR, "eep: cannot write %s; backup kept at %s", path, backup); + return 0; + } + fwrite(buf, 1, EEP_SIZE, fp); + fclose(fp); + + sysLogPrintf(LOG_INFO, "eep: imported emulator save (%d regions converted, backup %s)", + converted, backup); + return converted; +} diff --git a/port/src/frontoptions.c b/port/src/frontoptions.c index 4195b989..670be8c1 100644 --- a/port/src/frontoptions.c +++ b/port/src/frontoptions.c @@ -1,26 +1,13 @@ /* - * frontoptions.c -- D343: PC options as a GE front-end screen. + * frontoptions.c -- the file-select "PC Options" entry. * - * GE's front end has no generic menu system, so PC settings get their own - * screen, MENU_PC_OPTIONS (route B, docs/dev/OPTIONS-MENU-PLAN.md), built the - * way GE builds its Cheat Options screen (front.c init/interface/constructor_ - * menu15_cheat): the open dossier with tabs and a blank CLASSIFIED page, black - * Zurich text on the paper via frontPrintText, GE's translucent highlight - * boxes, the PREVIOUS tab as "back", and the game's crosshair. + * One PC options UI: the label at the right end of file select's + * Select / Copy / Erase bar opens the F10 overlay (optionsoverlay.c) over + * file select. The former MENU_PC_OPTIONS screen (D343) is gone; file select + * stays underneath and is held off its idle timeout while the overlay is open. * - * page 0: five functional categories; page 1: a category's settings; - * deeper pages: Input -> Bindings -> Movement/Actions and Gameplay -> HUD - * - * The sections and rows are the F10 overlay's row table, reached through the - * row API in optionsoverlay.c, so both UIs edit the same settings. - * - * Game-code surface (Rule 2, user sign-off 2026-09-27): the MENU_PC_OPTIONS - * enum value (bondconstants.h) and five one-line dispatch cases (front.c), - * all #ifdef PORT. Entry is the "PC Options" label on file select, drawn by - * the existing D343 hook in constructor_menu05_fileselect. - * - * Everything here runs on the game's main thread (the front-end tick and - * constructor), like GE's own screens. + * Game-code surface: the one existing #ifdef PORT call in + * constructor_menu05_fileselect (front.c). Runs on the game thread. */ #include @@ -30,815 +17,33 @@ #include #include #include -#include #include -#include #include +#include #include "front.h" #include "textrelated.h" #include "language.h" - #include "platform.h" #include "system.h" -#include "config.h" -#include "envflag.h" #include "optionsoverlay.h" -#include "watchsettings.h" #include "frontoptions.h" -#include "input.h" -/* front.c functions this screen shares with the cheat screen (not all are in - * front.h). */ -extern Gfx *frontSetupMenuBackground(Gfx *DL); -extern Gfx *frontAddPreviousTabText(Gfx *DL); -extern s32 frontCheckCursorOnPreviousTab(void); -extern Gfx *frontAddNextTabText(Gfx *DL); -extern u32 frontCheckCursorOnNextTab(void); -extern Gfx *frontDrawCursor(Gfx *DL); -extern Gfx *frontPrintText(Gfx *gdl, s32 *x, s32 *y, s8 *text, struct fontchar *second_font_table, - struct font *first_font_table, s32 arg6, s32 view_x, s32 view_y, - s32 arg9, s32 arga); -extern void load_walletbond(void); -extern void disable_all_switches(Model *arg0); /* front.c:967 */ -extern void set_item_visibility_in_objinstance(Model *objinstance, s32 item, s32 mode); /* front.c:968 */ -extern s32 folder_selection_screen_option_icon; -extern struct rectbbox folder_option_ERASE_bound; /* front.c:439 */ - -/* ---- colours on the paper (RRGGBBAA; 0xFF = opaque black, GE's ink) ---- */ -#define INK 0x000000FFu -#define INK_ON 0xA00000FFu /* the cheat screen's ON red */ -#define INK_DIM 0x00000080u -#define HILITE 0x32 /* GE's menu highlight box (black, alpha 0x32) */ - -/* ---- layout, in the front end's 440x330 canvas (paper; tabs start at 390) ---- */ -#define TITLE_Y 0x2B -#define ROW_X 0x37 /* the cheat list's x */ -#define ROW_Y0 0x41 /* cheat list starts 0x35; +12 for the title line */ -/* D346b: 20 -> 18 px so a full section fits the paper on one page. Hit bands - * stay contiguous at any pitch >= 6; the 22px highlight box still clears the - * next row (y+14 < y+17). D353: the largest section is now GRAPHICS at 11 - * rows (both auto-FOV toggles off); MAX_PROWS=15 keeps headroom, and - * buildPages() logs any future overflow instead of silently truncating. */ -#define ROW_DY 18 -#define NUM_X 0x37 /* "1." on the section list */ -#define SEC_X 0x4B -#define VAL_R 372 /* values right-aligned here */ -#define BAR_X0 232 -#define BAR_X1 316 -#define ROW_HIT_X0 24.0f /* D502: covers the pad crosshair snap at x = 32 */ -#define ROW_HIT_X1 385.0f +extern struct rectbbox folder_option_ERASE_bound; /* front.c */ /* ---- file-select label: right end of the Select / Copy / Erase bar ---- */ #define LABEL_X 351 /* D400: floor = NTSC Erase right (~323) + LABEL_GAP; JP's wider Erase pushes past it */ #define LABEL_CY 285 /* the bar's centre line, as Copy/Erase */ -#define LABEL_GAP 28 /* D400: text-only now (icon removed), a plain word gap after Erase */ +#define LABEL_GAP 28 /* D400: text-only, a plain word gap after Erase */ #define HIT_PAD 4 -#define MAX_PAGES 8 -#define MAX_PROWS 15 /* D346b: 12 -> 15 for the merged Video section (see ROW_DY) */ -/* D406: content rows per page. The 440x330 paper ends at y=330; content row k - * sits at y = ROW_Y0 + (k+2)*ROW_DY (the D356 profile row takes slot 1), so a - * page fits while ROW_Y0 + (CAP+2)*ROW_DY + ~10px of text stays <= 330 (the - * bottom line is reserved for the page hint): CAP = 11 at ROW_DY 18. D346b's 18px pitch was sized for 15 rows with no - * profile row; D356's profile row plus the v0.4.0 Wave-A INPUT rows (17) - * pushed the 14th row to y=335 -- off the bottom of the paper -- and rows - * 15-17 (Crouch mode, Reset to defaults, Bindings...) were dropped by the - * old silent page cap, unreachable from the front screen. Sections that - * outgrow one page now page: the vertical stepper crosses page boundaries - * and the title carries a (p/N) marker. The F10 overlay is unaffected - * (it scrolls its own window, D345-D347). */ -#define ROWS_PER_PAGE 11 - static const char kLabel[] = "PC Options"; /* ASCII only: issue #87 / D295 */ static const char kLabelNL[] = "PC Options\n"; /* height measure only, D400 */ -/* ---- screen state (game thread) ---- */ -static int s_level = 0; /* 0 = categories, 1 = category, 2/3 = nested pages */ -static int s_page = 0; /* current root category */ -static int s_pageno = 0; /* D406: page within the active section */ -static int s_rowTotal = 0; /* D406: visible rows in the active section */ -static int s_subHeader = -1; /* rows[] header index for the nested page */ -static int s_hl = -1; /* highlighted row / section, -1 = none */ -/* D444: hover model. s_hl is now PERSISTENT (set by hover, wheel/keys or - * paging); hover only re-selects when the pointer MOVES onto a DIFFERENT row - * than the last row it hovered (s_lastHover), and never touches the page. The - * keyboard/wheel snap (cursorToItemRaw) is not pointer motion (s_prevH/V). */ -static int s_hoverHit = -1; /* row under the pointer this frame, -1 = none */ -static int s_lastHover = -2; /* last hovered row (-2 = none yet) */ -static f32 s_prevH = -1e9f, s_prevV = -1e9f; /* cursor at the previous frame */ -static int s_dragRow = -1; /* global row index being dragged */ -static int s_repeatDir = 0, s_repeatTimer = 0; -/* D345(e): hold-to-repeat state for vertical row stepping (separate from - * the left/right value-adjust repeat; both can be held at once). */ -static int s_vrepeatDir = 0, s_vrepeatTimer = 0; - -static int s_pageHdr[MAX_PAGES]; -static int s_pageN = 0; -static int s_rowIdx[MAX_PROWS]; -static int s_rowN = 0; -#define MAX_SECT_ROWS 48 /* D406: per-section gather buffer (INPUT = 17) */ - -/* ------------------------------------------------------------------------ */ - static void playSfx(s16 id) { sndPlaySfx((struct ALBankAlt_s *)g_musicSfxBufferPtr, id, NULL); } -static s32 measureW(const char *str) -{ - s32 h = 0, w = 0; - textMeasure(&h, &w, (char *)str, ptrFontZurichBoldChars, ptrFontZurichBold, 0); - return w; -} - -static Gfx *ink(Gfx *DL, s32 x, s32 y, const char *str, u32 colour) -{ - return frontPrintText(DL, &x, &y, (s8 *)str, ptrFontZurichBoldChars, - ptrFontZurichBold, (s32)colour, - viGetX(), viGetY(), 0, 0); -} - -static Gfx *inkR(Gfx *DL, s32 xr, s32 y, const char *str, u32 colour) -{ - return ink(DL, xr - measureW(str), y, str, colour); -} - -/* D406b: per-row slider-bar origin. Long labels ("X axis look sensitivity - * (controller)") used to run into the fixed 232px bar and overlap the track; - * the bar now starts 10px past the label. Rows whose labels leave less than - * 12px of track show the value only. */ -static int rowBarX0(const char *label) -{ - int x0 = ROW_X + measureW(label) + 10; - return x0 < BAR_X0 ? BAR_X0 : x0; -} - -/* "MOUSE / AIM" -> "Mouse / Aim" (GE's menus use title case). */ -static void titleCase(const char *in, char *out, int n) -{ - int j = 0, start = 1; - for (int i = 0; in[i] && j < n - 1; i++) { - char c = in[i]; - if (c >= 'A' && c <= 'Z' && !start) c = (char)(c - 'A' + 'a'); - if (c >= 'a' && c <= 'z' && start) c = (char)(c - 'a' + 'A'); - start = (c == ' ' || c == '/'); - out[j++] = c; - } - out[j] = '\0'; -} - -static int rowY(int k) -{ - /* Every open section, including a nested section, has the profile row. */ - if (s_level >= 1) k++; - return ROW_Y0 + k * ROW_DY; -} - -static int activeHeader(void) -{ - return s_level >= 2 ? s_subHeader : s_pageHdr[s_page]; -} - -static void buildPages(void) -{ - int n = optionsRowCount(); - s_pageN = 0; - for (int i = 0; i < n && s_pageN < MAX_PAGES; i++) { - if (optionsRowIsHeader(i) && optionsRowHeaderParent(i) < 0) - s_pageHdr[s_pageN++] = i; - } - if (s_page >= s_pageN) { - s_page = 0; - } - s_rowN = 0; - s_rowTotal = 0; - if (s_pageN > 0) { - /* D406: gather every visible row of the active section, then page it - * (ROWS_PER_PAGE fits the paper, see above). The old code capped at - * MAX_PROWS-1 and silently dropped the rest -- a warning, no access. - * s_pageno is re-clamped on every build so rows that auto-hide - * (aim range, auto-FOV) cannot strand the cursor past the end. */ - int tmp[MAX_SECT_ROWS]; - int tn = 0; - for (int i = activeHeader() + 1; i < n && !optionsRowIsHeader(i); i++) { - if (!optionsRowIsShown(i)) continue; - if (tn < MAX_SECT_ROWS) tmp[tn++] = i; - } - s_rowTotal = tn; - int pages = (tn + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; - if (pages < 1) pages = 1; - if (s_pageno > pages - 1) s_pageno = pages - 1; - int off = s_pageno * ROWS_PER_PAGE; - for (int k = 0; k < ROWS_PER_PAGE && off + k < tn; k++) - s_rowIdx[s_rowN++] = tmp[off + k]; - } -} - -static int itemCount(void) -{ - /* D356: level 1 carries the top save-file row (item 0, not in rows[]) on - * top of the page's content rows. */ - return s_level == 0 ? s_pageN : s_rowN + 1; -} - -/* Put the crosshair on item k, unconditionally. */ -static void cursorToItemRaw(int k) -{ - /* D345(g): snap into the left gutter (x=44), not onto the row text - * (the old ROW_X+20 centred the sprite over the label/highlight box). - * 32 (D502; was 44) stays inside [ROW_HIT_X0..1] so the hit-test still - * resolves row k, and clears the number column (NUM_X=55) / highlight - * boxes (from x=53/73). The PREVIOUS-tab hitbox (x>390 && y>223) is - * far away. Mouse users are unaffected: the pointer sits under the - * real mouse, never at this snap position. */ - cursor_h_pos = 32.0f; /* D502: was 44; the sprite overlapped the "1." / row text */ - cursor_v_pos = (f32)(rowY(k) + 6); - s_prevH = cursor_h_pos; /* D444: our own snap is not pointer motion */ - s_prevV = cursor_v_pos; -} - -/* Put the crosshair on item k (entering a page / going back). */ -static void cursorToItem(int k) -{ - /* D345(b): while the 1:1 pointer owns the crosshair (mouse recently - * used), it is already under the OS pointer -- snapping would yank it - * to the top-left item and it would stay there until the next mouse - * movement. Keyboard/D-pad users (stale mouse) keep the snap. */ - if (inputMenuPointerLive()) return; - cursorToItemRaw(k); -} - -/* D502: highlight for a screen change. Pad/keyboard users get item k - * highlighted (the crosshair was just snapped onto it); with a live mouse the - * pointer owns the selection, so start unselected and let hover pick. */ -static int navHighlight(int k) -{ - return inputMenuPointerLive() ? -1 : k; -} - -/* ------------------------------------------------------------------------ */ -/* MENU_PC_OPTIONS: init / update / interface / constructor (front.c cases) */ - -void frontOptionsMenuInit(void) -{ - /* As init_menu15_cheat: reset the tab state and make sure the dossier - * model is loaded (the background draws walletinst[0]). */ - tab_start_selected = FALSE; - tab_next_selected = FALSE; - tab_prev_selected = FALSE; - tab_prev_highlight = FALSE; - tab_next_highlight = FALSE; - tab_start_highlight = FALSE; - load_walletbond(); - - /* frontSetupMenuBackground frames the folder at folderpositions[ - * selected_folder_num]; file select leaves it at -1. Use folder 1, as a - * return from mode select would (file select's init does the same). */ - if (selected_folder_num < FOLDER1 || selected_folder_num >= MAX_FOLDER_COUNT) { - selected_folder_num = FOLDER1; - } - folder_selection_screen_option_icon = 0; /* crosshair, not Copy/Erase */ - - s_level = 0; - s_page = 0; - s_pageno = 0; /* D406 */ - s_subHeader = -1; - s_hl = -1; - s_hoverHit = -1; - s_lastHover = -2; - s_prevH = s_prevV = -1e9f; - s_dragRow = -1; - s_repeatDir = 0; - s_vrepeatDir = 0; - buildPages(); - /* Isolated headless screenshot of a nested page. This diagnostic only - * changes menu navigation; it never edits profile or config values. */ - const char *testPage = getenv("GE_FRONTOPTIONS_SECTION"); - if (testPage) { - for (int i = 0; i < optionsRowCount(); i++) { - if (!optionsRowIsHeader(i) || strcmp(optionsRowLabel(i), testPage)) continue; - int root = i, depth = 1; - while (optionsRowHeaderParent(root) >= 0) { - root = optionsRowHeaderParent(root); - depth++; - } - for (int p = 0; p < s_pageN; p++) { - if (s_pageHdr[p] == root) { - s_page = p; - s_pageno = 0; - s_level = depth; - s_subHeader = depth > 1 ? i : -1; - buildPages(); - sysLogPrintf(LOG_INFO, "frontoptions: diagnostic page %s (depth %d, %d rows)", - testPage, depth, s_rowN); - break; - } - } - break; - } - } - cursorToItem(0); - sysLogPrintf(LOG_INFO, "frontoptions: opened"); -} - -void frontOptionsMenuUpdate(void) -{ -} - -static void goBack(void) -{ - playSfx(DOOR_METAL_CLOSE2_SFX); - if (s_dragRow >= 0) optionsRowCommit(s_dragRow); - s_dragRow = -1; - optionsResetClear(); /* D356: navigating away (or closing) disarms a pending reset */ - if (s_level >= 2) { - int child = s_subHeader; - int parent = optionsRowHeaderParent(child); - s_pageno = 0; /* D406: every fresh page view starts at page 1 */ - if (parent == s_pageHdr[s_page]) { - s_level = 1; - s_subHeader = -1; - } else { - s_level--; - s_subHeader = parent; - } - buildPages(); - int selected = 0; - for (int k = 0; k < s_rowN; k++) - if (optionsRowChildHeader(s_rowIdx[k]) == child) selected = k + 1; - cursorToItem(selected); - s_hl = navHighlight(selected); s_lastHover = -2; /* D502 */ - sysLogPrintf(LOG_INFO, "frontoptions: back to %s", optionsRowLabel(activeHeader())); - return; - } - if (s_level == 1) { - s_level = 0; - s_pageno = 0; /* D406 */ - cursorToItem(s_page); - /* D502: the highlight is an item index of the level being shown; - * leaving it at the section's row index landed pad users on another - * section (row 1 inside Input -> "Graphics"). */ - s_hl = navHighlight(s_page); s_lastHover = -2; - sysLogPrintf(LOG_INFO, "frontoptions: back to section list"); - return; - } - configSave(); - sysLogPrintf(LOG_INFO, "frontoptions: closed"); - frontChangeMenu(MENU_FILE_SELECT, FALSE); -} - -/* D497: paging mirrors the mission dossiers (front.c briefing screen): the - * folder's PREVIOUS tab (and B) turns back a page, or leaves the section from - * its first page; a NEXT tab is shown while a later page exists. This replaced - * D444's "Previous page / Next page" links under the rows. */ -static int hasNextPage(void) -{ - return s_level >= 1 && s_pageno * ROWS_PER_PAGE + s_rowN < s_rowTotal; -} - -static void turnPage(int dir) -{ - playSfx(DOOR_METAL_CLOSE2_SFX); - if (s_dragRow >= 0) optionsRowCommit(s_dragRow); - s_dragRow = -1; - optionsResetClear(); - s_pageno += dir; - buildPages(); - cursorToItem(dir > 0 ? 1 : 0); - s_hl = navHighlight(dir > 0 ? 1 : 0); s_lastHover = -2; /* D502 */ -} - -/* PREVIOUS tab / B: previous page, else back out. */ -static void goPrevious(void) -{ - if (s_level >= 1 && s_pageno > 0) turnPage(-1); - else goBack(); -} - -void frontOptionsMenuInterface(void) -{ - viSetFovY(FOV_Y_F); - viSetAspect(ASPECT_RATIO_SD); - viSetZRange(100.0f, 10000.0f); - viSetUseZBuf(0); - - buildPages(); /* an auto toggle may have hidden or shown % rows */ - /* D383: modal key capture owns events on the host thread. Suppress - * navigation until its one-shot key has been consumed and released. */ - if (optionsBindingCaptureTick()) return; - - /* D345(f): NO raw stick integration on this screen (the D345(c) call to - * frontUpdateControlStickPosition() was removed): vertical input is the - * discrete row stepper below, horizontal is the left/right block -- F10 - * model. Raw drift fought the pointer over cursor_h/v_pos. */ - - /* Highlight: as the cheat screen, recomputed while A is not held. */ - if (joyGetButtons(PLAYER_1, A_BUTTON | Z_TRIG) == 0) { - int hit = -1; - int moved = (cursor_h_pos != s_prevH || cursor_v_pos != s_prevV); - tab_prev_highlight = FALSE; - tab_next_highlight = FALSE; - if (frontCheckCursorOnPreviousTab()) { - tab_prev_highlight = TRUE; - } else if (hasNextPage() && frontCheckCursorOnNextTab()) { - tab_next_highlight = TRUE; /* D497 */ - } else if (cursor_h_pos >= ROW_HIT_X0 && cursor_h_pos <= ROW_HIT_X1) { - for (int k = itemCount() - 1; k >= 0; k--) { - if (cursor_v_pos >= (f32)(rowY(k) - 3) && - cursor_v_pos < (f32)(rowY(k) + ROW_DY - 3)) { - hit = k; - break; - } - } - /* D407(b): past the top/bottom edge of the visible rows, clamp - * the hit to the edge row instead of dropping it. */ - if (hit < 0 && itemCount() > 0) { - if (cursor_v_pos < (f32)rowY(0)) - hit = 0; - else if (cursor_v_pos <= (f32)(rowY(itemCount() - 1) + ROW_DY)) - hit = itemCount() - 1; - } - } - s_hoverHit = hit; - /* D444: hover selects only when the pointer moved onto a DIFFERENT - * row; a small nudge inside the same row (or none at all) leaves the - * wheel/key/page-chosen selection alone. */ - if (moved && hit != s_lastHover) { - s_lastHover = hit; - s_hl = hit; - } - s_prevH = cursor_h_pos; - s_prevV = cursor_v_pos; - } - - /* D497: NEXT folder tab (mouse click; Up/Down still page too). */ - if (tab_next_highlight && joyGetButtonsPressedThisFrame(PLAYER_1, A_BUTTON | Z_TRIG)) { - turnPage(+1); - } else if (joyGetButtonsPressedThisFrame(PLAYER_1, A_BUTTON | Z_TRIG | START_BUTTON)) { - /* D444: a click acts on the row under the pointer, not on a stale - * wheel/key selection (keyboard Enter: the snap put the pointer on - * s_hl, so the hit equals it). */ - if (!tab_prev_highlight && joyGetButtonsPressedThisFrame(PLAYER_1, A_BUTTON | Z_TRIG)) - s_hl = s_hoverHit; - if (tab_prev_highlight) { - goPrevious(); /* D497 */ - } else if (s_hl >= 0 && s_level == 0) { - playSfx(DOOR_METAL_CLOSE2_SFX); - s_page = s_hl; - s_level = 1; - s_pageno = 0; /* D406 */ - sysLogPrintf(LOG_INFO, "frontoptions: section %d", s_page + 1); - buildPages(); - cursorToItem(0); - s_hl = navHighlight(0); s_lastHover = -2; /* D502 */ - } else if (s_hl == 0 && s_level >= 1) { - /* D356: the top save-file row -- A steps to the next file (the - * D352 chooser; folders only, "none" retired, plan §5.5). */ - playSfx(DOOR_LOCK_SFX); - watchSettingsChooseFile(1); - } else if (s_hl >= 1 && s_hl <= s_rowN) { - /* s_hl is item-numbered (0 = the save-file row); content row k - * is item k+1, so the row index is s_hl - 1. */ - int i = s_rowIdx[s_hl - 1]; - if (getenv("GE_FRONTNAVLOG")) - sysLogPrintf(LOG_INFO, "frontnav: select %s/%s (item %d)", - optionsRowLabel(activeHeader()), optionsRowLabel(i), s_hl); - playSfx(DOOR_LOCK_SFX); - int child = optionsRowChildHeader(i); - int bx0 = optionsRowIsSlider(i) ? rowBarX0(optionsRowLabel(i)) : BAR_X0; - if (child >= 0) { - optionsResetClear(); - s_subHeader = child; - s_level++; - s_pageno = 0; /* D406 */ - buildPages(); - sysLogPrintf(LOG_INFO, "frontoptions: opened %s (depth %d)", - optionsRowLabel(child), s_level); - cursorToItem(0); - s_hl = navHighlight(0); s_lastHover = -2; /* D502 */ - } else if (optionsRowIsSlider(i) && bx0 <= BAR_X1 - 12 && - cursor_h_pos >= bx0 - 4 && cursor_h_pos <= BAR_X1 + 4) { - optionsRowSetFraction(i, ((double)cursor_h_pos - bx0) / (BAR_X1 - bx0)); - s_dragRow = i; - } else if (optionsRowIsBind(i)) { - /* D395: pad A/X may navigate the keyboard/mouse binding - * pages but cannot enter a modal that only a key can finish. - * Enter or a mouse click still starts ordinary key capture. */ - const Uint8 *ks = SDL_GetKeyboardState(NULL); - if (optionsRowIsPadBind(i) || /* D469: pad-driven capture; arming waits for the A release */ - ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] || - (SDL_GetMouseState(NULL, NULL) & SDL_BUTTON(SDL_BUTTON_LEFT))) - optionsRowBeginBind(i); - } else if (optionsRowIsReset(i)) { - /* D356: reset rows are two-step arm -> confirm (edge - * activation: joyGetButtonsPressedThisFrame fires once per - * press). The value column shows "Confirm" while armed. */ - optionsRowActivateReset(i); - } else { - optionsRowAdjust(i, +1); /* toggle / cycle / step (wraps) */ - } - } - } else if (joyGetButtonsPressedThisFrame(PLAYER_1, B_BUTTON)) { - goPrevious(); /* D497: as the dossiers, B turns back a page first */ - } - - /* Drag a slider while A is held. */ - if (s_dragRow >= 0) { - if (joyGetButtons(PLAYER_1, A_BUTTON | Z_TRIG)) { - int bx0 = rowBarX0(optionsRowLabel(s_dragRow)); - optionsRowSetFraction(s_dragRow, ((double)cursor_h_pos - bx0) / (BAR_X1 - bx0)); - } else { - optionsRowCommit(s_dragRow); - s_dragRow = -1; - } - } - - /* Left/right (D-pad, C buttons; keyboard A/D) adjust the highlighted row, - * with hold-to-repeat. D356: on the save-file row it cycles the file; - * on a reset row a fresh press activates (arm/confirm) and the held - * repeat is suppressed (the activation contract, plan §5.4). */ - if (s_level >= 1 && s_hl >= 0) { - int dir = 0; - /* D345(f): stick X joins the D-pad/C-buttons, so Left/Right arrows - * (and the pad's left stick) adjust values like F10's left/right. */ - s8 vsx = joyGetStickX(PLAYER_1); - if (joyGetButtons(PLAYER_1, L_JPAD | L_CBUTTONS) || vsx < -5) dir = -1; - if (joyGetButtons(PLAYER_1, R_JPAD | R_CBUTTONS) || vsx > 5) dir = +1; - if (dir != 0) { - int fire = 0; - if (dir != s_repeatDir) { - fire = 1; - s_repeatTimer = 18; - } else if (--s_repeatTimer <= 0) { - fire = 1; - s_repeatTimer = 4; - } - if (fire) { - if (s_hl == 0) { - watchSettingsChooseFile(dir); /* save-file row */ - } else { - int i = s_rowIdx[s_hl - 1]; - if (optionsRowIsReset(i)) - optionsRowActivateReset(i); - else if (optionsRowChildHeader(i) < 0) - optionsRowAdjust(i, dir); - } - } - } - s_repeatDir = dir; - if (dir == 0) optionsAdjustCommitPending(); /* D489: save once on release */ - } else { - s_repeatDir = 0; - optionsAdjustCommitPending(); - } - - /* D356: reset arm state -- the screen's selected rows[] index (s_hl 0 is - * the save row, not a rows[] entry); moving on disarms, goBack clears. */ - optionsResetMaintain(s_level >= 1 && s_hl > 0 ? s_rowIdx[s_hl - 1] : -1); - - /* The dossier: tabs + a blank CLASSIFIED page, as the cheat screen. */ - disable_all_switches(walletinst[0]); - set_item_visibility_in_objinstance(walletinst[0], SW_TABS, 1); - set_item_visibility_in_objinstance(walletinst[0], SW_BLANK, 1); - set_item_visibility_in_objinstance(walletinst[0], SW_CLASSIFIED, 1); - - /* D345(e): F10-style discrete vertical navigation. W/S / Up-Down arrows - * (stick via D345(d)) or the pad's stick/D-pad step the highlight one - * row at a time -- clamped at the ends, hold-to-repeat with the same - * 18/4-frame cadence as left/right -- and snap the crosshair onto the - * new row (ungated: explicit keyboard navigation owns the cursor even - * right after mouse use). Replaces the raw stick drift of D345(c), - * which fought the pointer over cursor_v_pos. The mouse is untouched: - * any movement re-owns the cursor and the highlight hit-test above - * follows it next frame. Skipped while a slider drag holds A. */ - if (s_dragRow < 0) { - int vdir = 0; - s8 vsy = joyGetStickY(PLAYER_1); - if (joyGetButtons(PLAYER_1, U_JPAD) || vsy > 5) vdir = -1; /* up */ - else if (joyGetButtons(PLAYER_1, D_JPAD) || vsy < -5) vdir = +1; /* down */ - if (vdir != 0) { - int fire = 0; - if (vdir != s_vrepeatDir) { - fire = 1; - s_vrepeatTimer = 18; - } else if (--s_vrepeatTimer <= 0) { - fire = 1; - s_vrepeatTimer = 4; - } - if (fire) { - int n = itemCount(); - if (n > 0) { - int q = (s_hl < 0 ? 0 : s_hl) + vdir; - /* D406: stepping past the end of the visible page turns - * the page (cursor lands on the first row of the next - * page, or the last row of the previous one); with one - * page the ends simply clamp, as before. */ - if (q >= n && s_pageno * ROWS_PER_PAGE + s_rowN < s_rowTotal) { - s_pageno++; - buildPages(); - q = 1; - } else if (q < 0 && s_pageno > 0) { - s_pageno--; - buildPages(); - q = itemCount() - 1; - } else { - if (q < 0) q = 0; - if (q >= n) q = n - 1; - } - if (q != s_hl) { - if (getenv("GE_FRONTNAVLOG")) - sysLogPrintf(LOG_INFO, "frontnav: %s -> item %d%s%s (page %d)", - s_level ? optionsRowLabel(activeHeader()) : "categories", - q, s_level && q > 0 ? " " : "", - s_level && q > 0 ? optionsRowLabel(s_rowIdx[q - 1]) : "", - s_level ? s_pageno + 1 : 0); - s_hl = q; - cursorToItemRaw(q); - } - } - } - } - s_vrepeatDir = vdir; - } else { - s_vrepeatDir = 0; - } -} - -Gfx *frontOptionsMenuDraw(Gfx *DL) -{ - char buf[48]; - - DL = viSetFillColor(DL, 0, 0, 0); - DL = viFillScreen(DL); -#ifdef VERSION_EU - DL = viFillScreen(DL); - DL = viFillScreen(DL); - DL = viFillScreen(DL); -#endif - DL = frontSetupMenuBackground(DL); - DL = microcode_constructor(DL); - - if (s_level == 0) { - DL = ink(DL, ROW_X, TITLE_Y, "PC Options\n", INK); - for (int k = 0; k < s_pageN; k++) { - char name[32], num[8]; - titleCase(optionsRowLabel(s_pageHdr[k]), name, sizeof(name)); - strcat(name, "\n"); - snprintf(num, sizeof(num), "%d.\n", k + 1); - if (k == s_hl) { - DL = microcode_constructor_related_to_menus(DL, SEC_X - 2, rowY(k) - 1, - SEC_X + measureW(name) + 5, rowY(k) + 0xE, HILITE); - } - DL = ink(DL, NUM_X, rowY(k), num, INK); - DL = ink(DL, SEC_X, rowY(k), name, INK); - } - } else { - char title[32]; - titleCase(optionsRowLabel(activeHeader()), title, sizeof(title)); - int titleW = measureW(title); - strcat(title, "\n"); - DL = ink(DL, ROW_X, TITLE_Y, title, INK); - - /* D356: scope annotation -- a section that carries per-file rows has - * its title carry the active file ("GAMEPLAY (File 2)"), PD-style. - * watchSettingsActiveFolder() is the context-aware accessor (front: - * the resolved front target; -1 while no file is usable, which the - * annotation renders as an unavailable state, never a number the - * write path cannot use). */ - if (optionsRowIsSaveScoped(activeHeader())) { - int f = watchSettingsActiveFolder(); - char note[16]; - 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); - } - - /* D406b: page marker for sections that span pages -- "Page p/N" - * right of the title row, dim, so page 2 is unmistakable. */ - if (s_rowTotal > ROWS_PER_PAGE) { - int pages = (s_rowTotal + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; - char pg[24]; - snprintf(pg, sizeof(pg), "Page %d/%d", s_pageno + 1, pages); - DL = inkR(DL, VAL_R, TITLE_Y, pg, INK_DIM); - } - - /* D356: the top profile row (item 0, not in the row table; the label - * reads "Profile" -- the end-user term for a per-player game file, - * see D357 plan section 4): the single file control of the front - * screen (F10 always targets the active file, so it has no file - * row). L/R (and A) cycle folders; (none) is the unavailable state - * until one is created. */ - { - int y = rowY(0); - char sv[32]; - if (s_hl == 0) { - DL = microcode_constructor_related_to_menus(DL, ROW_X - 2, y - 1, - ROW_X + 140, y + 0xE, HILITE); - } - DL = ink(DL, ROW_X, y, "Profile\n", INK); - int f = watchSettingsActiveFolder(); - if (f < 0) snprintf(sv, sizeof(sv), "(none)"); - else snprintf(sv, sizeof(sv), "%d\n", f + 1); - DL = inkR(DL, VAL_R, y, sv, INK); - } - - for (int k = 0; k < s_rowN; k++) { - int i = s_rowIdx[k]; - /* D406b: one-shot label-metrics log (layout diagnostics). */ - static int widthsLogged = 0; - if (!widthsLogged && getenv("GE_FRONTOPTS_WIDTHS")) { - widthsLogged = 1; - for (int w2 = 0; w2 < s_rowN; w2++) { - int j = s_rowIdx[w2]; - sysLogPrintf(LOG_INFO, "frontoptions: '%s' w=%d barx0=%d", - optionsRowLabel(j), measureW(optionsRowLabel(j)), - rowBarX0(optionsRowLabel(j))); - } - } - /* Content row k is item k+1 (item 0 is the save-file row), so - * it sits one line below the save row -- rowY() already shifts - * level-1 items down by one. */ - int y = rowY(k + 1); - char label[48]; - snprintf(label, sizeof(label), "%s\n", optionsRowLabel(i)); - if (k + 1 == s_hl) { - DL = microcode_constructor_related_to_menus(DL, ROW_X - 2, y - 1, - ROW_X + measureW(label) + 5, y + 0xE, HILITE); - } - DL = ink(DL, ROW_X, y, label, INK); - - /* GAMEPLAY/HUD mix ini and profile options. AUDIO is wholly - * profile-scoped and already has (Profile N) in its title. */ - if (optionsRowIsSaveScoped(i) && - (strcmp(optionsRowLabel(activeHeader()), "GAMEPLAY") == 0 || - strcmp(optionsRowLabel(activeHeader()), "HUD") == 0)) { - DL = ink(DL, ROW_X + measureW(label) + 4, y, "(per profile)\n", INK_DIM); - } - - if (optionsRowIsSlider(i)) { - int x0 = rowBarX0(optionsRowLabel(i)); - if (BAR_X1 - x0 >= 12) { - s32 fx = x0 + (s32)((BAR_X1 - x0) * optionsRowFraction(i) + 0.5); - DL = microcode_constructor_related_to_menus(DL, x0, y + 5, BAR_X1, y + 8, 0x00000040); - DL = microcode_constructor_related_to_menus(DL, x0, y + 5, fx, y + 8, 0xA00000C0); - } - } else if (optionsRowNeedsRestart(i)) { - DL = ink(DL, BAR_X0, y, "(restart)\n", INK_DIM); - } - - optionsRowValueText(i, buf, sizeof(buf) - 1); - if (strcmp(optionsRowLabel(i), "Crosshair colour") == 0 && buf[0]) { - extern void portCrosshairPreview(s32 *, s32 *, s32 *); - s32 red, green, blue; - portCrosshairPreview(&red, &green, &blue); - s32 x = VAL_R - measureW(buf) - 17; - DL = microcode_constructor_related_to_menus(DL, x, y + 2, - x + 10, y + 12, - ((u32)red << 24) | ((u32)green << 16) | - ((u32)blue << 8) | 0xff); - } - if (buf[0]) { - u32 col = (buf[0] == 'O' && buf[1] == 'N') ? INK_ON : INK; - /* D346: kOnOff is title-case; M7 (D387) tags the - * experimental AllUnlocked ON value "ON (restart)". */ - strcat(buf, "\n"); - DL = inkR(DL, VAL_R, y, buf, col); - } - } - if (strcmp(optionsRowLabel(activeHeader()), "MOVEMENT") == 0 || - strcmp(optionsRowLabel(activeHeader()), "ACTIONS") == 0) { - static char d471buf0[96]; /* D471: pad names for the menu pad's family */ - inputPadHelpFmt(d471buf0, sizeof(d471buf0), optionsBindingCaptureActive() - ? "Press key/mouse {B}/ESC cancel DEL clear\n" - : "Keys/mouse only Enter: bind {B}: back\n"); - DL = ink(DL, ROW_X, rowY(s_rowN + 2), d471buf0, INK_DIM); - } else if (strcmp(optionsRowLabel(activeHeader()), "CONTROLLER") == 0) { - static char d471buf1[96]; /* D471: pad names for the menu pad's family */ - inputPadHelpFmt(d471buf1, sizeof(d471buf1), optionsBindingCaptureActive() - ? "Press pad button tap {B} cancel hold Back clear\n" - : "{A}: bind Left/Right: slot {Y}: clear {B}: back\n"); - DL = ink(DL, ROW_X, rowY(s_rowN + 2), d471buf1, INK_DIM); - } - } - - DL = frontAddPreviousTabText(DL); - if (hasNextPage()) DL = frontAddNextTabText(DL); /* D497 */ - DL = frontDrawCursor(DL); - return DL; -} - -/* F10 / Select must not stack the overlay on this screen. */ -int frontOptionsBlocksOverlay(void) -{ - return current_menu == MENU_PC_OPTIONS; -} - -/* ------------------------------------------------------------------------ */ -/* File select: the "Options" entry, drawn from the D343 hook in */ -/* constructor_menu05_fileselect (game thread, after the interface tick). */ -/* ------------------------------------------------------------------------ */ - Gfx *optionsFileSelectLabel(Gfx *gdl) { s32 h = 0, w = 0; @@ -884,16 +89,24 @@ Gfx *optionsFileSelectLabel(Gfx *gdl) /* Same centring front.c uses for Copy/Erase (front.c:2779/2791). */ y = LABEL_CY - (h / 2); - /* D406: test hook -- enter the screen without a mouse click so headless - * runs (GE_INPUTSCRIPT D-pad stepping + GE_FRONTNAVLOG / diagnostics) - * can exercise it. Diagnostic-only: never active without the env. */ - if (getenv("GE_FRONTOPTIONS_AUTO") && menu_update == MENU_INVALID && - folder_selected_for_deletion < 0) { - static int autoEntered = 0; - if (!autoEntered) { - autoEntered = 1; - playSfx(DOOR_LOCK_SFX); - frontChangeMenu(MENU_PC_OPTIONS, FALSE); + /* One-PC-options-UI: test hook -- open the F10 overlay over file select + * without a click so headless runs can capture it. Diagnostic-only: never active without the env. */ + { /* GE_FILESELECT_OVERLAY=: open once; n > 1 also closes it n label-frames later */ + static int envN = -1, openedAt = -1, frames = 0; + if (envN < 0) { const char *e = getenv("GE_FILESELECT_OVERLAY"); envN = e ? (atoi(e) > 0 ? atoi(e) : 1) : 0; } + if (envN && menu_update == MENU_INVALID && folder_selected_for_deletion < 0) { + frames++; + if (openedAt < 0 && !optionsOverlayIsOpen()) { openedAt = frames; optionsOverlayToggle(); } + else if (openedAt >= 0 && envN > 1 && frames == openedAt + envN && optionsOverlayIsOpen()) optionsOverlayToggle(); + } + } + + { /* GE_OVNAV="O,D,D,A,P,...": one token per 20 label-frames (harness: scripted overlay navigation) */ + static const char *nav = (const char *)-1; static int navI = 0, navF = 0; + if (nav == (const char *)-1) nav = getenv("GE_OVNAV"); + if (nav && menu_update == MENU_INVALID && folder_selected_for_deletion < 0 && ++navF % 20 == 0) { + while (nav[navI] == ',' || nav[navI] == ' ') navI++; + if (nav[navI]) optionsOverlayTestStep(nav[navI++]); } } @@ -905,7 +118,7 @@ Gfx *optionsFileSelectLabel(Gfx *gdl) if (hot && joyGetButtonsPressedThisFrame(PLAYER_1, A_BUTTON | Z_TRIG | START_BUTTON)) { playSfx(DOOR_LOCK_SFX); /* Copy/Erase's click */ - frontChangeMenu(MENU_PC_OPTIONS, FALSE); + optionsOverlayToggle(); } /* Same font, size and centre line as Copy/Erase; white idle, the diff --git a/port/src/input.c b/port/src/input.c index 344300f3..d115b382 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -82,7 +82,6 @@ #include "input.h" #include "envflag.h" #include "optionsoverlay.h" -#include "frontoptions.h" #include "video.h" /* D194 absolute aim: read-only access to the live camera (struct player). * Game header pulled in through the same shim path every other compiled game @@ -188,6 +187,7 @@ extern f32 portScaleFovY(f32 fovy, s32 isTitleScreen); extern s32 lvlGetCurrentStageToLoad(void); #define CONTROLLER_CONFIG_HONEY_ 0 #define CONTROLLER_CONFIG_SOLITARE_ 1 +#define CONTROLLER_CONFIG_GOODNIGHT_ 3 /* 1.4: SOLITARE movement, swapped button roles (bondview2.c) */ #define MENU_POINTER_GAIN 1.5 #define TRIG_THRESHOLD (30 * 256) #define RSTICK_THRESHOLD 0x4000 @@ -288,6 +288,7 @@ extern s32 lvlGetCurrentStageToLoad(void); static void applyGrab(int want); static void reconcileGrab(int menuMode); static void applyCursorVisibility(void); +static void overlayFrontEndCrosshair(void); static int numControllers = 1; static int connectedMask = 0x1; /* controller 0 always present */ @@ -375,6 +376,16 @@ static struct player *slotPlayer(void) return g_CurrentPlayer; } +/* D511: Video.CrosshairHealthColor input -- health + armour of the player whose + * crosshair is being built (gunDrawSight runs on that player's game thread). */ +float portCrosshairHealthRatio(void) +{ + struct player *p = g_CurrentPlayer; + if (!p) return 1.0f; + float r = p->bondhealth + p->bondarmour; + return r < 0.0f ? 0.0f : (r > 2.0f ? 2.0f : r); +} + /* In a 2+ player stage this file never calls a game entry point that acts on * g_CurrentPlayer (bond_interact_object, reload, gadget cycle, control-type * setter): the poll thread is not the game thread, so re-pointing the @@ -458,12 +469,13 @@ static const struct { const char *key; const char *label; const char *def; } kPa typedef struct { u32 mask[PA_COUNT]; } PadBinds; typedef struct { PadBinds seat[MAX_PADS]; } PadTable; -static int padPreset = 0; /* Input.PadPreset */ +static int padPreset[MAX_PADS] = {0}; /* D518: per-seat (Input.PadPreset[2/3/4]) */ /* D194/D238: 1 = force GE's own 1.2/SOLITARE * control style for continuous analog pitch; * 0 = legacy 1.1/HONEY + D166 digital pulse. * (D498: declared here so the stick-style helpers below can read it.) */ static int naturalPitchMode = 1; /* Input.NaturalPitch */ +static int controlScheme = 0; /* D513 Input.ControlScheme: 0 Modern (port forces the style), 1 OG (the game's own selection is honoured) */ static char padStr[MAX_PADS][PA_COUNT][32]; /* Input.Pad[N]. */ static char padStrKey[MAX_PADS][PA_COUNT][40]; static PadTable padTabBuf[2]; @@ -502,27 +514,57 @@ static void padBuildBinds(PadBinds *b, int seat, int custom, int *bad) if (!b->mask[PA_START]) b->mask[PA_START] = PSM(PS_START); /* lockout guard: keep a pause path */ } -/* D498: preset 3 is Custom; 4 = 1.4 Elektra is a fixed mapping, not custom. */ -static int padPresetCustom(void) { return padPreset == 3; } +/* D498: preset 3 is Custom; 4 = 1.4 Elektra is a fixed mapping, not custom. + * D518: per seat -- editing another player's preset must not touch this one. */ +static int padPresetCustom(int seat) { return padPreset[seat] == 3; } +/* D516: in Original the D498 port presets are not offered (the game's own + * styles are); the stored preset is ignored (plain N64 layout) unless it is + * Custom, whose binds still apply (edited in Ext only). Ext: exactly padPreset. */ +static int padPresetEff(int seat) { return (controlScheme && padPreset[seat] != 3) ? 0 : padPreset[seat]; } /* D498: stick style for the active preset. 1.2 Christmas / 1.3 Frost force * HONEY and 1.4 Elektra forces SOLITARE (for mouse and keyboard too, since * the game runs one control style per player). 1.1 Jinx (the default) and * Custom keep Input.NaturalPitch, so the NaturalPitch=0 escape hatch still - * works exactly as before D498. */ -static int stickSolitare(void) { - if (padPreset == 1 || padPreset == 2) return 0; - if (padPreset == 4) return 1; + * works exactly as before D498. D518: per seat. */ +static int stickSolitare(int seat) { + if (padPresetEff(seat) == 1 || padPresetEff(seat) == 2) return 0; + if (padPresetEff(seat) == 4) return 1; return naturalPitchMode; } +/* D513: which stick mapping the port emits for the active player. Modern: + * exactly stickSolitare() (the port forces the style, D498 pins included). + * OG: follow the game's own live style -- 1.2 SOLITARE and 1.4 GOODNIGHT use + * the natural-look (stick = look, C-buttons = move) roles; 1.1 / 1.3 and the + * 2.x dual-controller styles use the HONEY roles for the first pad. In OG the + * game really runs a 2.x style and reads its second stick from controller + * playernum + playerCount (bondview2.c ~4997-5003, faithful to the N64): the + * port only maps this pad, so with one pad that second stick is idle, as on an + * N64. Buttons pass through as N64 buttons (no 1.3/1.4 role permutation: + * faithful). With no + * player yet (front end) it falls back to the Modern value, which menus + * ignore anyway. */ +static int styleIsSolitare(int seat) +{ + if (!controlScheme) return stickSolitare(seat); + struct player *p = slotPlayer(); + if (!p) return stickSolitare(seat); + int t = p->cur_player_control_type_0; + return t == CONTROLLER_CONFIG_SOLITARE_ || t == CONTROLLER_CONFIG_GOODNIGHT_; +} /* D498: 1.3 Frost / 1.4 Elektra swap the two sticks (never in menus). */ -static int stickSwap(void) { return padPreset == 2 || padPreset == 4; } +static int stickSwap(int seat) { return padPresetEff(seat) == 2 || padPresetEff(seat) == 4; } -/* Signature of everything the table depends on (preset only unless Custom). */ +/* Signature of everything the table depends on (presets only unless a seat is Custom). */ static unsigned padComputeSig(void) { - unsigned h = 2166136261u ^ (unsigned)padPreset; - if (padPresetCustom()) + int anyCustom = 0; + unsigned h = 2166136261u; + for (int s = 0; s < MAX_PADS; s++) { + h ^= (unsigned)padPreset[s] * 16777619u; + if (padPresetCustom(s)) anyCustom = 1; + } + if (anyCustom) for (int s = 0; s < MAX_PADS; s++) for (int a = 0; a < PA_COUNT; a++) for (const char *p = padStr[s][a]; *p; p++) h = (h ^ (unsigned char)*p) * 16777619u; @@ -543,12 +585,13 @@ static const PadTable *padTabGet(void) PadTable *cur = (PadTable *)SDL_AtomicGetPtr((void **)&padTabPub); PadTable *spare = (cur == &padTabBuf[0]) ? &padTabBuf[1] : &padTabBuf[0]; int bad = 0; - for (int s = 0; s < MAX_PADS; s++) padBuildBinds(&spare->seat[s], s, padPresetCustom(), &bad); + for (int s = 0; s < MAX_PADS; s++) padBuildBinds(&spare->seat[s], s, padPresetCustom(s), &bad); SDL_AtomicSetPtr((void **)&padTabPub, spare); padTabSig = sig; padTabValid = 1; - sysLogPrintf(LOG_INFO, "input: pad table rebuilt (preset %d%s, %d unknown name(s))", - padPreset, padPresetCustom() ? " custom" : "", bad); /* D498 */ + sysLogPrintf(LOG_INFO, "input: pad table rebuilt (presets %d/%d/%d/%d%s, %d unknown name(s))", + padPreset[0], padPreset[1], padPreset[2], padPreset[3], + padPresetCustom(0) || padPresetCustom(1) || padPresetCustom(2) || padPresetCustom(3) ? " custom" : "", bad); /* D498, D518 */ SDL_AtomicUnlock(&rebuildLock); return spare; } @@ -984,7 +1027,7 @@ static int padLookSensX = 100; /* right-stick look: horizontal se static int padLookSensY = 100; /* right-stick look: vertical (pitch) sensitivity, % */ static int padSouthpaw = 0; /* 1 = swap fire (G) / grenade (R) trigger actions */ static int padLookSmooth = 0; /* right-stick look low-pass strength, 0-10 (0 = off, 10 = max) */ -static int padTriggerPct = 23; /* trigger press point, % of travel (~30*256) */ +static int padTriggerPct = 25; /* trigger press point, % of travel (~30*256) */ static int padLookInvertY = 0; /* 1 = invert right-stick (look) Y */ /* Per-pad look-smoothing EMA state (Wave A item 5), one X/Y pair per pad. Reset * on hot-unplug (inputRescanPads) so a smoothed value can't stick after the @@ -1471,8 +1514,6 @@ static unsigned scriptApply(int slot, unsigned button) static void inputRebuildBinds(void); /* D214; defined below with keyDown() */ static void inputMigrateBinds(void); /* D380; after configLoad */ static void inputBindingProbe(void); /* D383/D384; opt-in, restores ini state */ -static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked, - unsigned *button, int *sx, int *sy); static int bindsVersion = 0; /* D380/D386: versioned migration of effective ini binds */ static int crouchMode = 0; /* 0 = hold, 1 = toggle (latched crouch input) */ @@ -1966,19 +2007,8 @@ static void inputBindingProbe(void) ok &= strcmp(g_bindStr[a], "NONE,Mouse 5") == 0; memcpy(g_bindStr[a], saved, sizeof(saved)); inputBindingsApply(); - Uint8 keys[SDL_NUM_SCANCODES] = {0}; - unsigned menuButton = 0; - int menuX = 0, menuY = 0; - keys[SDL_SCANCODE_A] = keys[SDL_SCANCODE_D] = keys[SDL_SCANCODE_C] = 1; - pcOptionsKeyboardPad(keys, 0, 0, &menuButton, &menuX, &menuY); - ok &= !menuButton && !menuX && !menuY; /* gameplay keys cannot steer editor */ - keys[SDL_SCANCODE_LEFT] = keys[SDL_SCANCODE_RETURN] = 1; - pcOptionsKeyboardPad(keys, 0, 0, &menuButton, &menuX, &menuY); - ok &= menuX == -STICK_MAX && !menuY && menuButton == GE_CONT_A; - pcOptionsKeyboardPad(keys, 0, 1, &menuButton, &menuX, &menuY); - ok &= !menuButton && !menuX && !menuY; sysLogPrintf(ok ? LOG_INFO : LOG_ERROR, - "GE_BINDPROBE: %s (2 slots, mouse 1-5, sparse/comma, A/D/C ignored, arrows/Enter, modal isolation; ini restored)", + "GE_BINDPROBE: %s (2 slots, mouse 1-5, sparse/comma, ini restored)", ok ? "PASS" : "FAIL"); } @@ -1999,24 +2029,6 @@ static int actHeld(const Uint8 *ks, int act) return 0; } -/* PC settings use immutable keyboard navigation, independent of gameplay - * bindings (which are being edited here). Do not consume A/D/C as controls. */ -static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked, - unsigned *button, int *sx, int *sy) -{ - *button = 0; - *sx = *sy = 0; - if (blocked) return; - if (ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8] || ks[SDL_SCANCODE_W]) *sy = STICK_MAX; - if (ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2] || ks[SDL_SCANCODE_S]) *sy = -STICK_MAX; - if (ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]) *sx = -STICK_MAX; - if (ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6]) *sx = STICK_MAX; - if (ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]) *button |= GE_CONT_A; - if (ks[SDL_SCANCODE_ESCAPE]) *button |= GE_CONT_B; - if (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) *button |= GE_CONT_A; - if (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) *button |= GE_CONT_B; -} - /* Direct use/reload and modern face buttons share the same game-state gate. * In watch, pause, tank, front end or scripted cameras use native menu bits. */ static int inputCanUseGameplayActions(int menuMode) @@ -2087,9 +2099,21 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * * anything else ever calls the setter (menu, save load) in between. */ if ((idx == 0 || inputMpSplit()) && slotPlayer() != NULL && (idx == 0 || idx < getPlayerCount())) { /* D416: each slot rewrites only its own player's style */ - int wantSolitare = stickSolitare() ? CONTROLLER_CONFIG_SOLITARE_ : CONTROLLER_CONFIG_HONEY_; /* D498 */ + int wantSolitare = stickSolitare(idx) ? CONTROLLER_CONFIG_SOLITARE_ : CONTROLLER_CONFIG_HONEY_; /* D498, D518 */ struct player *sp = slotPlayer(); - if (!inputMpSplit()) { + if (controlScheme) { + /* D513 OG controls: the port never writes the style; the game's own + * selection (watch / save / MP setup) stands. Log each change once. */ + static int lastStyle[4] = { -1, -1, -1, -1 }; + if (idx < 4 && sp->cur_player_control_type_0 != lastStyle[idx]) { + lastStyle[idx] = sp->cur_player_control_type_0; + char dual[80] = ""; + if (lastStyle[idx] >= 4) + snprintf(dual, sizeof(dual), "; dual-controller style: second stick read from controller %d", idx + (int)getPlayerCount()); + sysLogPrintf(LOG_INFO, "input: original control style, slot %d style %d (%s)%s", idx, lastStyle[idx], + styleIsSolitare(idx) ? "natural-look roles" : "1.1 roles", dual); + } + } else if (!inputMpSplit()) { if (cur_player_get_control_type() != wantSolitare) cur_player_set_control_type(wantSolitare); } else if (sp->cur_player_control_type_0 != wantSolitare) { @@ -2147,6 +2171,10 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * if (closeNow) optionsOverlayToggle(); } optionsOverlayHandleInput(); + if (mouseEnabled && current_menu != GE_MENU_RUN_STAGE && current_menu != GE_MENU_INVALID) { + overlayFrontEndCrosshair(); /* D519: crosshair stays the pointer over the overlay */ + mouseRequestCursor(0); /* ...so hide the OS cursor (one pointer, not two) */ + } if (stick_x) *stick_x = 0; if (stick_y) *stick_y = 0; s_menuPointerLive = 0; /* D345(b): the overlay owns the mouse */ @@ -2161,7 +2189,7 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * current_menu != GE_MENU_INVALID); /* D345: detect front-end screen changes (any current_menu flip, - * including into MENU_PC_OPTIONS / the F10 overlay's parent state) + * including into the F10 overlay's parent state) * and open a short re-assert window. Runs every poll so the * countdown drains even when the pointer block below is skipped * (mouse disabled, unfocused window, legacy velocity mode). */ @@ -2220,10 +2248,10 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * /* D345(d): natural pitch is an in-stage aim scheme -- the N64 front * end navigates on the analog stick only, so in menus W/S (and the * Up/Down arrows) must emit stick even with NaturalPitch=1, or no - * front-end screen (file select, cheat, MENU_PC_OPTIONS) can be + * front-end screen (file select, cheat) can be * driven vertically from the keyboard. Watch/pause keeps the C-button * mapping: it is current_menu==RUN_STAGE (menuMode 0), N64-style. */ - if (stickSolitare() && !menuMode) { /* D498 */ + if (styleIsSolitare(idx) && !menuMode) { /* D498, D513, D518 */ if (actHeld(ks, IA_FORWARD)) button |= GE_CONT_E; /* C-up = forward */ if (actHeld(ks, IA_BACK)) button |= GE_CONT_D; /* C-down = back */ } else { @@ -2641,7 +2669,7 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * sx += (int)(hipEdx * hipSens * MOUSE_TURN_GAIN * (inTankHip ? tankAimScale / 100.0 : 1.0)); if (inTankHip) { /* no pitch/drive from the mouse in a tank */ - } else if (stickSolitare()) { /* D498 */ + } else if (styleIsSolitare(idx)) { /* D498, D513, D518 */ /* D194/D238: SOLITARE gives hipfire pitch the same * continuous analog stick treatment as yaw -- same * formula as the sx line above, so X and Y are, by @@ -2683,22 +2711,6 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * * and chasing the menu cursor (user repro, bind log). Physical arrow * keys and Enter/Escape are fixed menu controls; mouse/pad still * work. Scripted QA input remains the sole source when active. */ - if (current_menu == MENU_PC_OPTIONS && !scriptIsActive(0)) - { - pcOptionsKeyboardPad(ks, mb, optionsBindingInputBlocked(), &button, &sx, &sy); - /* D407(b): on this screen the mouse wheel scrolls the row list - * -- wheel up = step UP, wheel down = step DOWN (same sign - * convention as the W/S keys above: +STICK_MAX = up). The - * original mapping had them swapped (2026-09-28 user report). - * Front-end menus otherwise leave the wheel queue unconsumed in - * menu mode -- clear it here so a scroll can't leak into the - * next stage's weapon cycle (D223). */ - if (!optionsBindingInputBlocked()) - { - if (wheelBack > 0) { sy = STICK_MAX; wheelBack = 0; } /* wheel up */ - else if (wheelFwd > 0) { sy = -STICK_MAX; wheelFwd = 0; } /* wheel down */ - } - } menuPrevActive = menuMode; s_menuPointerLive = (menuMode && mouseEnabled && !mouseGrabbed) ? 1 : 0; @@ -2728,14 +2740,14 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * current_menu != GE_MENU_INVALID); /* D498: 1.3 Frost / 1.4 Elektra swap the two sticks; menus never swap. */ - if (!padMenuMode && stickSwap()) { int tx = lx, ty = ly; lx = rx; ly = ry; rx = tx; ry = ty; } + if (!padMenuMode && stickSwap(idx)) { int tx = lx, ty = ly; lx = rx; ly = ry; rx = tx; ry = ty; } /* D518 */ if (padMenuMode) { int px = scaleAxis(lx, padDeadzoneL); int py = -scaleAxis(ly, padDeadzoneL); /* SDL up = negative -> N64 up = positive */ if (px) sx = px; if (py) sy = py; - } else if (stickSolitare()) { /* D498 */ + } else if (styleIsSolitare(idx)) { /* D498, D513, D518 */ /* D194/D238: SOLITARE swaps stick roles -- left stick becomes * digital-step movement (same analog-for-movement tradeoff as * the keyboard remap above), right stick becomes continuous @@ -2821,9 +2833,8 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * * Deck). The game never reads BACK, so nothing is withheld. */ { int selNow = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_BACK); - /* D343: not on the PC options screen (one options UI at a time). */ /* D472: only opens here (closing is the owner's, in the overlay path) */ - if (selNow && !padSelectPrev[idx] && !frontOptionsBlocksOverlay() && !optionsOverlayIsOpen()) { + if (selNow && !padSelectPrev[idx] && !optionsOverlayIsOpen()) { s_ovOwner = idx; optionsOverlayToggle(); } @@ -3092,6 +3103,37 @@ short inputPadAxis(int idx, SDL_GameControllerAxis a) return SDL_GameControllerGetAxis(pads[idx], a); } +/* D519: on the front end the game's red crosshair is the mouse pointer (D345). + * While the overlay is open it keeps following the OS mouse so it can be + * used over the overlay. Unlike the menu-pointer block (which insets the + * range by 20 px like the game's own clamp) this maps window -> canvas + * exactly, so the drawn crosshair sits where the overlay hit-tests the click + * (optionsoverlay.c maps the same OS position). The OS cursor is hidden by the + * caller so there is one pointer, not two. */ +static void overlayFrontEndCrosshair(void) +{ + SDL_Window *w = SDL_GetMouseFocus(); + int mx = 0, my = 0, ww = 0, wh = 0; + if (!w) return; + SDL_GetMouseState(&mx, &my); + SDL_GetWindowSize(w, &ww, &wh); + if (ww <= 0 || wh <= 0) return; + double fx = (double)mx / ww, fy = (double)my / wh; + double rx0, ry0, rx1, ry1; + videoGetOutputRectFrac(&rx0, &ry0, &rx1, &ry1); + if (rx1 > rx0 && ry1 > ry0) { fx = (fx - rx0) / (rx1 - rx0); fy = (fy - ry0) / (ry1 - ry0); } + extern f32 portNativeAspect(void); + f32 na = portNativeAspect(); + if (na > (4.0f / 3.0f)) { double vis = (4.0 / 3.0) / (double)na; fx = (fx - (1.0 - vis) * 0.5) / vis; } + double sw = getPlayer_c_screenwidth(), sh = getPlayer_c_screenheight(); + double sl = getPlayer_c_screenleft(), st = getPlayer_c_screentop(); + if (!(sw > 200.0 && sw < 2000.0 && sh > 150.0 && sh < 2000.0)) return; + double h = sl + fx * sw, v = st + fy * sh; + double loH = sl + 20.0, hiH = sl + sw - 20.0, loV = st + 20.0, hiV = st + sh - 20.0; + cursor_h_pos = (float)(h < loH ? loH : h > hiH ? hiH : h); + cursor_v_pos = (float)(v < loV ? loV : v > hiV ? hiV : v); +} + void inputSuspendForOverlay(void) { if (mouseGrabbed) { @@ -3597,6 +3639,13 @@ unsigned inputPadHeldSources(int seat) { return (seat >= 0 && seat < MAX_PADS) ? padReadRaw(pads[seat]) : 0; } +/* D518: the overlay re-points its "Layout preset" row (and the Custom-only + * bind rows' visibility) at the selected seat's value. */ +int *inputPadPresetPtr(int seat) +{ + return &padPreset[(seat >= 0 && seat < MAX_PADS) ? seat : 0]; +} + int inputPadPresetStep(int cur, int dir) { /* D498: display order Jinx, Christmas, Frost, Elektra, Custom (stored 0,1,2,4,3). */ @@ -3711,10 +3760,11 @@ static void padMapSelfTest(void) } /* Custom: seat 0 Fire := a, seat 1 untouched, seat 2 Use := y. */ - int savedPreset = padPreset; + int savedPreset[MAX_PADS]; char saved[MAX_PADS][PA_COUNT][32]; + memcpy(savedPreset, padPreset, sizeof(savedPreset)); memcpy(saved, padStr, sizeof(saved)); - padPreset = 3; + for (int s = 0; s < MAX_PADS; s++) padPreset[s] = 3; snprintf(padStr[0][PA_FIRE], sizeof(padStr[0][0]), "a"); snprintf(padStr[2][PA_USE], sizeof(padStr[0][0]), "y,none"); snprintf(padStr[2][PA_NEXTWPN], sizeof(padStr[0][0]), "none"); @@ -3741,7 +3791,7 @@ static void padMapSelfTest(void) c1, c2, c3, c4, c5, c6, c7); fail += !(c1 && c2 && c3 && c4 && c5 && c6 && c7); /* Preset != Custom ignores the strings. */ - padPreset = 0; + for (int s = 0; s < MAX_PADS; s++) padPreset[s] = 0; t = padTabGet(); padMapPure(&t->seat[0], PSM(PS_RT), 0, 0, &play, &o); int c8 = (o.button & GE_CONT_G) != 0; @@ -3762,14 +3812,19 @@ static void padMapSelfTest(void) fail += !(c10 && c11); memcpy(padStr, saved, sizeof(saved)); - padPreset = savedPreset; + memcpy(padPreset, savedPreset, sizeof(savedPreset)); padTabValid = 0; sysLogPrintf(fail ? LOG_ERROR : LOG_INFO, "GE_PADMAPTEST: %s", fail ? "FAIL" : "PASS"); } PD_CONSTRUCTOR static void inputConfigInit(void) { - configRegisterInt("Input.PadPreset", &padPreset, 0, 4); /* D498: 0 Jinx 1.1, 1 Christmas 1.2, 2 Frost 1.3, 3 Custom, 4 Elektra 1.4 */ + /* D518: per seat. The pre-D518 global key is kept as seat 1's, so an + * existing ini keeps its value for P1 and P2-P4 start at Jinx. */ + configRegisterInt("Input.PadPreset", &padPreset[0], 0, 4); /* D498: 0 Jinx 1.1, 1 Christmas 1.2, 2 Frost 1.3, 3 Custom, 4 Elektra 1.4 */ + configRegisterInt("Input.PadPreset2", &padPreset[1], 0, 4); + configRegisterInt("Input.PadPreset3", &padPreset[2], 0, 4); + configRegisterInt("Input.PadPreset4", &padPreset[3], 0, 4); for (int seat = 0; seat < MAX_PADS; seat++) for (int a = 0; a < PA_COUNT; a++) { if (seat == 0) snprintf(padStrKey[seat][a], sizeof(padStrKey[seat][a]), "Input.Pad.%s", kPadAct[a].key); @@ -3817,6 +3872,7 @@ PD_CONSTRUCTOR static void inputConfigInit(void) configRegisterInt("Input.MouseSensitivity", &mouseSensitivity, 1, 500); /* D238 */ configRegisterInt("Input.MouseAimCurve", &aimCurveGamma, 50, 400); /* D194(a), x100 */ configRegisterInt("Input.NaturalPitch", &naturalPitchMode, 0, 1); /* D194/D238 */ + configRegisterInt("Input.ControlScheme", &controlScheme, 0, 1); /* D513: 0 Modern, 1 OG controls */ configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); /* legacy; Wave A migrates to L/R */ configRegisterInt("Input.PadDeadzoneL", &padDeadzoneL, 0, 30000); /* Wave A: left (movement) stick */ configRegisterInt("Input.PadDeadzoneR", &padDeadzoneR, 0, 30000); /* Wave A: right (look) stick */ diff --git a/port/src/libultra.c b/port/src/libultra.c index 64709f4d..4e9869c5 100644 --- a/port/src/libultra.c +++ b/port/src/libultra.c @@ -1108,6 +1108,10 @@ static void geEepromLoad(void) if (s_eepromLoaded) return; s_eepromLoaded = 1; const char *path = sysResolvePath(GE_EEP_PATH); + /* D514: before the game's first EEPROM read, convert an emulator-format + * save in place (port/src/eepimport.c). Runs before fopen so the load + * below sees the converted bytes; no-op for a port-format save. */ + geEepImportEmulatorSave(path); FILE *fp = fopen(path, "rb"); if (fp) { fread(s_eeprom, 1, GE_EEP_SIZE, fp); @@ -1132,6 +1136,7 @@ static void geEepromStore(void) } extern int geLegacyCrcMaybeMigrateSlots(u8 *slots5); /* port/src/legacycrc.c (D297) */ +extern int geEepImportEmulatorSave(const char *path); /* port/src/eepimport.c (D514) */ /* D442: Game.AllUnlocked is a pure RAM/query-time override (file2.c * fileGetIsCheatUnlocked + the debug flags seeded in main.c). The EEPROM diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index d0226451..0a832b90 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -6,6 +6,11 @@ * port-owned config.c variables directly. Live knobs apply immediately; the * two that need an FBO/window rebuild (MSAA, Fullscreen) are tagged "(restart)". * + * Menu PD alignment (T2/T3): PD's dialog anatomy -- hub of big-font rows -> + * flat sub-dialogs, gradient title strip, tapering focus rule, separator + + * Back as the last row, Select-player hop before Controller / Key bindings -- + * in GE's watch palette. Rows still resolve to the same ini keys. + * * 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). F10 opens at a @@ -92,11 +97,18 @@ enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA, ROW_RES, ROW_ACTION, ROW_FPSC static const char *const kOnOff[] = { "Off", "On", NULL }; static const char *const kReverse[] = { "Reverse", "Upright", NULL }; static const char *const kHold[] = { "Hold", "Toggle", NULL }; -static const char *const kAspectMode[] = { "Window", "Original", NULL }; /* D447: Original = 4:3 (16:9 with the game Ratio) bars */ +static const char *const kAspectMode[] = { "Fill window", "Original", "16:9", "21:9", NULL }; /* D447: Original = 4:3 (16:9 with the game Ratio) bars; D508: 2..3 force a ratio */ static const char *const kTexFilter[] = { "Nearest", "Bilinear", "3-Point", "Trilinear", NULL }; /* Trilinear option (playtest 2026-10-03) */ static const char *const kPadPreset[] = { "1.1 Jinx", "1.2 Christmas", "1.3 Frost", "Custom", "1.4 Elektra", NULL }; /* D498: index = stored value; inputPadPresetStep gives the display order */ +/* D516: the game's own control styles, shown in Original (index = style value). */ +static const char *const kOrigStyle[] = { "1.1 Honey", "1.2 Solitaire", "1.3 Kissy", "1.4 Goodnight", + "2.1 Plenty", "2.2 Galore", "2.3 Domino", "2.4 Goodhead", NULL }; static int s_padSeat = 0; /* D469: seat edited by the Controller page rows */ -static const char *const kAimMode[] = { "N64", "Centred (PC)", NULL }; /* D337 */ +static const char *const kControlScheme[] = { "Ext", "Original", NULL }; /* D513: PD names the port-driven style "Ext" */ +static const char *const kAimMode[] = { "N64", "Centered (PC)", NULL }; /* D337 */ +static const char *const kMenuPointer[] = { "Velocity (legacy)", "Direct", NULL }; /* Input.MenuPointerMode 0 / 1 */ +static int displayModeNow(void); /* defined with the preset helpers below */ +static const char *const kDisplayModeName[3]; static const char *const kAimRange[] = { "PC", "N64", NULL }; /* D338 */ static const int kMsaaSeq[] = { 1, 2, 4, 8, 16 }; /* D443: 16x added */ #define MSAA_N ((int)(sizeof(kMsaaSeq) / sizeof(kMsaaSeq[0]))) @@ -109,13 +121,14 @@ static const char *const kOnOffRev[] = { "On", "Off", NULL }; /* 0 = On */ * written, so the old D259 silent-volume quirk is gone. It applies from the * next launch. Saves polluted by builds before D442 are not repaired. * Display-only value names; config still stores 0/1. */ -static const char *const kAllUnlocked[] = { "Off", "ON (restart)", NULL }; +static const char *const kAllUnlocked[] = { "Off", "On (restart)", NULL }; /* D379: the authored N64 sprite is red. Original is the identity path; * White at index 7 uses the same alpha-mask combiner as other true hues. */ static const char *const kCrosshairColor[] = { "Original (red)", "Green", "Red", "Blue", "Yellow", "Cyan", "Magenta", "White", "Custom", NULL, }; +static const char *const kFullscreenMode[] = { "Borderless", "Exclusive", NULL }; /* D511 */ static const char *const kCrosshairStyle[] = { "Original", "Thin cross", NULL }; /* D186: the sim's own tick pacemaker is hardcoded to the console's native VI * rate (60Hz NTSC / 50Hz PAL, port/src/libultra.c) -- Video.FpsCap can only @@ -176,13 +189,16 @@ struct Row { const char *shownWhen; int shownValue; int *showPtr; + /* D516: 1 = only with Input.ControlScheme Ext, 2 = only with Original. + * Resolved to schemePtr at init (shownWhen can't express a second condition). */ + int schemeOnly; + int *schemePtr; /* D346: value-text decoration (display only). unit is appended to integer * slider values ("%"/"x"); dispDiv>0 divides the raw value before display * (e.g. deadzone raw 0..30000 -> % of full stick). NULL/0 = plain int. */ const char *unit; int dispDiv; - int dispMax; /* >0: show the int value as "N/dispMax" (e.g. 0-10 -> "5/10") */ /* D356: 1 = this row's value lives in the selected save file (per-file * watch rows). Carries the dim "(per profile)" tag on the only section that @@ -191,267 +207,242 @@ struct Row { * title annotation in both UIs). */ int saveScoped; int bindSlot; /* D383/D385: selected key/mouse slot, 0..1 */ + + /* Menu PD alignment: 1 = hub-only row (F10 hub bottom, after a separator; + * hidden on its section page). */ + int hub; }; +static int rowSchemeOk(const struct Row *r); /* D516 */ +/* Every dialog ends with a separator + Back row (PD pattern). Both are + * non-config ROW_ACTION rows, so every "skip ROW_ACTION" loop (resets, probes) + * already ignores them; they are told apart by key prefix. SEP(n) is a plain + * mid-page group separator (PD: menuitem separators between option groups). */ +#define SEP(n) { .key="__Sep" #n, .label="", .kind=ROW_ACTION } +#define SEP_BACK(n) SEP(n), { .key="__Back" #n, .label="Back", .kind=ROW_ACTION } + +/* Menu PD parity (R7): PD's six groups -- Video, Audio, Mouse, Controller, + * Game, Key Bindings -- shared by the F10 overlay and the front-end PC Options. + * Physical order here keeps each dialog's rows contiguous after its header + * (resets and the probes walk header -> next header); the hub / tab order is + * kRootOrder below. Controller opens a Select player page first (PD pattern). + * All rows use designated initializers (D351 class). */ static struct Row rows[] = { - /* D356/D388: Turok-style functional categories in order (INPUT, - * GAMEPLAY, GRAPHICS, AUDIO, VIDEO; docs/dev/D356-SETTINGS-REGROUP-PLAN.md). - * The D353 "BOND FILE" section -- the one named after WHERE a value is - * saved -- is gone: its surviving rows split into GAMEPLAY (the per-file - * watch toggles, dim "(per profile)" tagged since the section mixes scopes) and - * AUDIO (the per-file volume sliders, homogeneous so title annotation - * only), and the "Edit file" chooser row is retired in favour of the - * single top save-file row on the front options screen (frontoptions.c). - * Per-section "Reset to defaults" rows (D356, plan Gate E) share - * rowResetSection(); "(File N)" title annotations come from - * watchSettingsActiveFolder(). All rows use designated initializers - * (D351 class). */ - { .key="__HdrInput", .label="INPUT", .kind=ROW_HEADER }, - { .key="Input.MouseSensitivity", .label="Mouse horizontal sensitivity", .kind=ROW_SLIDER, .step=10, - .uiMin=10, .uiMax=300 }, /* D443/D357: shown as a multiplier, raw 100 = 1.0x; storage unchanged */ - /* Wave A (v0.5.0, CONTROLLER-INPUT-PLAN item 6): the pre-existing - * Input.MouseYScale key (extra vertical/pitch sensitivity, %) finally gets - * a row so the asymmetric X/Y mouse aim is tunable without editing the ini. - * Row only -- input.c already applies it (mouseYScale). */ - { .key="Input.MouseYScale", .label="Mouse vertical sensitivity", .kind=ROW_SLIDER, .step=5 }, - { .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 - * integrator is driven by the port-supplied turn with PD's near-zero damp - * (0.01), instead of GEPD's overwrite with the weapon's ~0.8 -- which is - * what made the reticule step. Changes aim feel, so opt-in. GE_PDMOUSEAIM=0/1 - * overrides it at launch for A/B runs. */ - /* D337: N64 = the N64 aim model (crosshair travels, camera edge-scrolls) - * with the mouse fed through the game's integrator at PD's mouse damp -- - * the default. CENTRED (PC) = opt-in FPS-style aim (#104), not N64. - * Device-neutral (D404, resolved by implementation 2026-09-28): mouse -- - * N64 feeds the game's integrator, CENTRED drives the camera directly - * (hipDirectCompute); pad -- N64 emits right-stick deflection into the - * same integrator (the original controller feel: crosshair travel + - * edge scroll + spring-back), CENTRED drives the camera directly from - * the stick (padDirectCompute, crosshair pinned at centre). Applies to - * every aim input (RMB, Shift, Q/L, Toggle mode, pad right stick in - * natural-pitch mode). */ - { .key="Input.AimMode", .label="Aim style", .kind=ROW_ENUM, .step=1, .names=kAimMode }, - /* D338: how far the N64-style crosshair travels. PC = to the screen edge - * (GEPD / mouse-injector feel, default); N64 = the original stick limits - * (65% of the half-width, camera turn from ~49%). Hidden while the aim - * 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 }, - /* Wave A (v0.5.0, docs/dev/CONTROLLER-INPUT-PLAN.md): per-stick deadzone - * (left = movement, right = look) replaces the single "Stick deadzone" row; - * the look-sensitivity / look-smoothing / southpaw knobs are right-stick - * (natural-pitch) feel options, identity at their defaults. All port-layer. */ - { .key="Input.PadDeadzoneL", .label="Deadzone (left stick)", .kind=ROW_SLIDER, .step=300, .unit="%", .dispDiv=300 }, - { .key="Input.PadDeadzoneR", .label="Deadzone (right stick)", .kind=ROW_SLIDER, .step=300, .unit="%", .dispDiv=300 }, - { .key="Input.PadLookSensX", .label="X axis look sensitivity (controller)", .kind=ROW_SLIDER, .step=5 }, - { .key="Input.PadLookSensY", .label="Y axis look sensitivity (controller)", .kind=ROW_SLIDER, .step=5 }, - { .key="Input.PadLookSmooth", .label="Look smoothing (controller)", .kind=ROW_SLIDER, .step=1, .dispMax=10 }, - { .key="Input.PadSouthpaw", .label="Southpaw", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="Input.PadTriggerPct", .label="Trigger threshold", .kind=ROW_SLIDER, .step=1 }, - /* D401 item 3 (docs/dev/RUMBLE-PLAN.md): Rumble Pak strength (D224 - * re-promotion). One global Input.RumbleScale for all pads (user call, - * 2026-09-28 -- the original four per-player rows were dropped). The - * value lives in input.c's gRumbleScale (0 = silent, 1 = full, dflt - * 0.5), applied in inputRumble(); 0.05 detents give 20 steps, the - * generic float value path displays them as %.2f. Inert (inputRumble - * no-ops) when no rumble-capable pad is connected -- a generic - * row-disabled state for that case is deferred with the per-pad tuning - * row (QOL-INVENTORY). */ - { .key="Input.RumbleScale", .label="Vibration", .kind=ROW_SLIDER, .step=0.05 }, - /* v0.4.0 M3 (modern options wave, D371): the GEPD mouse-injector - * key-layout preset (docs/dev/notes/GEPORT-REFERENCE-DEEPDIVE.md - * section 7.1) + the crouch bind's fire mode. Turok standard: these are - * control bindings, so they live in INPUT -- no provenance bucket. - * D374: the GEPD layout is the default (a mouse FPS layout is the - * natural default; the N64 had no keyboard); crouch defaults to hold. */ - { .key="Input.CrouchMode", .label="Crouch mode", .kind=ROW_ENUM, .step=1, .names=kHold }, - { .key="__ResetInput", .label="Reset to defaults", .kind=ROW_ACTION }, - { .key="__OpenBindings", .label="Bindings...", .kind=ROW_ACTION }, - { .key="__OpenController", .label="Controller...", .kind=ROW_ACTION }, - - /* D388: nested Input -> Bindings -> two short pages, each with a reset. - * Mouse buttons 1-5 share the visible slots with keys; pads stay fixed. */ - { .key="__HdrBindings", .label="BINDINGS", .kind=ROW_HEADER }, - { .key="__OpenMoveKeys", .label="Movement...", .kind=ROW_ACTION }, - { .key="__OpenActionKeys", .label="Actions...", .kind=ROW_ACTION }, - - { .key="__HdrMoveKeys", .label="MOVEMENT", .kind=ROW_HEADER }, - { .key="Input.Bind.Forward", .label="Move forward", .kind=ROW_BIND }, - { .key="Input.Bind.Back", .label="Move backward", .kind=ROW_BIND }, - { .key="Input.Bind.StrafeLeft", .label="Strafe left", .kind=ROW_BIND }, - { .key="Input.Bind.StrafeRight", .label="Strafe right", .kind=ROW_BIND }, - { .key="Input.Bind.TurnLeft", .label="Turn left", .kind=ROW_BIND }, - { .key="Input.Bind.TurnRight", .label="Turn right", .kind=ROW_BIND }, - { .key="__ResetMoveKeys", .label="Reset to defaults", .kind=ROW_ACTION }, - - { .key="__HdrActionKeys", .label="ACTIONS", .kind=ROW_HEADER }, - { .key="Input.Bind.Fire", .label="Fire key", .kind=ROW_BIND }, - { .key="Input.Bind.Aim", .label="Aim key", .kind=ROW_BIND }, - { .key="Input.Bind.Action", .label="Action / next weapon", .kind=ROW_BIND }, - { .key="Input.Bind.Cancel", .label="Use / back", .kind=ROW_BIND }, - { .key="Input.Bind.LeanLeft", .label="Lean left", .kind=ROW_BIND }, - { .key="Input.Bind.Start", .label="Pause / start", .kind=ROW_BIND }, - { .key="Input.Bind.Reload", .label="Reload", .kind=ROW_BIND }, - { .key="Input.Bind.Crouch", .label="Crouch", .kind=ROW_BIND }, - { .key="__ResetActionKeys", .label="Reset to defaults", .kind=ROW_ACTION }, - - /* D469: nested Input -> Controller. Preset selector; Custom unlocks per-seat, - * per-action pad rebinding (two slots per action, pad-driven capture). */ - { .key="__HdrController", .label="CONTROLLER", .kind=ROW_HEADER }, - { .key="Input.PadPreset", .label="Layout preset", .kind=ROW_ENUM, .step=1, .names=kPadPreset }, - { .key="__PadSeat", .label="Controller", .kind=ROW_PADSEAT, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Fire", .label="Fire", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Aim", .label="Aim", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Use", .label="Use / action", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Reload", .label="Reload", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Crouch", .label="Crouch", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.NextWeapon", .label="Next weapon", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.PrevWeapon", .label="Previous weapon", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Gadget", .label="Cycle gadget", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="Input.Pad.Start", .label="Pause / start", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3 }, - { .key="__ResetController", .label="Reset to defaults", .kind=ROW_ACTION }, - - { .key="__HdrGameplay", .label="GAMEPLAY", .kind=ROW_HEADER }, - /* D356 exposure filter (menu surface only, the D181/D216/D304 pattern -- - * config keys, the N64 watch paths and ini hand-edits all stay live): - * Bond.Look duplicates Input.MouseInvertY + Input.PadLookInvertY - * (stacking both is a double-negation trap); - * Bond.AimControl collides with Input.AimMode (two rows fighting over - * the same N64-vs-PC aim model). - * Two gameplay/profile toggles stay here; HUD-specific save rows move - * under Gameplay -> HUD. Both pages carry their own reset. */ - /* { .key="Bond.Look", .label="Look up/down (watch; stacks)", .kind=ROW_TOGGLE, - .names=kReverse, .found=1, .uiMax=1, .cfgMax=1 }, */ - { .key="Bond.AutoAim", .label="Auto-aim", .kind=ROW_TOGGLE, - .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, - /* { .key="Bond.AimControl", .label="Aim control", .kind=ROW_TOGGLE, - .names=kHold, .found=1, .uiMax=1, .cfgMax=1 }, */ - { .key="Bond.LookAhead", .label="Look ahead", .kind=ROW_TOGGLE, - .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, - /* D216/Game.SkipIntro: user report (v0.2.1 testing) that it breaks audio - * -- pulled from the menu until root-caused; re-exposed since. D408 - * (2026-09-28): still EXPERIMENTAL -- with it on, a failed/aborted - * mission skips the post-mission failure dossier (MENU_MISSION_FAILED) - * and returns straight to the menus. Not recommended for regular users; - * keep the loud label until both quirks are root-caused. */ - { .key="Game.SkipIntro", .label="Skip intro (EXPERIMENTAL)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - /* D232: the community "no damage flash" toggle (suppresses the red/green - * hit-flash overlay in bondview2). */ - { .key="Game.NoHitFlash", .label="No hit flash", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - /* D468: off = AI awareness keeps the cartridge-widest view (16:9 at the - * game's FOV) under ultrawide or a raised FOV; on = AI sees the full view. */ - { .key="Game.AIWideView", .label="AI sees the wider view", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - /* D257: everything-unlocked goodie. The C initializers (port/src/video.c) - * start it OFF (0) -- the old "default ON" note was stale and is - * corrected by D356. Consumed at startup by main.c -- applies from the - * next launch. */ - { .key="Game.AllUnlocked", .label="All unlocked (EXPERIMENTAL)", - .kind=ROW_TOGGLE, .step=1, .names=kAllUnlocked }, - { .key="__ResetGameplay", .label="Reset to defaults", .kind=ROW_ACTION }, - { .key="__OpenHud", .label="HUD...", .kind=ROW_ACTION }, - /* D293: only quit path used to be the OS window-close / Alt+F4 -- no - * discoverable in-game way to exit, a real gap on Deck/controller-only - * setups. Not config-backed (like __Resolution); activating it exits - * the same way video.c's SDL_QUIT/Alt+F4 handlers already do. */ - { .key="__QuitToDesktop", .label="Quit to desktop", .kind=ROW_ACTION }, - /* D443: orderly quit (D344) + relaunch, so "(restart)" rows like - * Anti-aliasing can be applied without leaving the game by hand. */ - { .key="__RestartGame", .label="Restart game (for restart options)", .kind=ROW_ACTION }, - - /* D388: Gameplay -> HUD collects both profile HUD visibility and global - * crosshair/scale controls. Its reset honours each row's own scope. */ - { .key="__HdrHUD", .label="HUD", .kind=ROW_HEADER }, - { .key="Bond.Sight", .label="Sight on screen", .kind=ROW_TOGGLE, - .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, - { .key="Bond.Ammo", .label="Ammo on screen", .kind=ROW_TOGGLE, - .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, - /* D226: scales the ammo counter, pickup/status text and dialogue. */ - { .key="Game.HudScale", .label="HUD scale", .kind=ROW_SLIDER, .step=5, .unit="%" }, - { .key="Video.CrosshairHide", .label="Show crosshair", .kind=ROW_TOGGLE, .step=1, .names=kOnOffRev }, - { .key="Video.CrosshairColor", .label="Crosshair colour", .kind=ROW_ENUM, .step=1, .names=kCrosshairColor }, - { .key="Video.CrosshairRed", .label="Red", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, - { .key="Video.CrosshairGreen", .label="Green", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, - { .key="Video.CrosshairBlue", .label="Blue", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, - { .key="Video.CrosshairSize", .label="Crosshair size", .kind=ROW_SLIDER, .step=5, .unit="%" }, - { .key="Video.CrosshairStyle", .label="Crosshair style", .kind=ROW_ENUM, .step=1, .names=kCrosshairStyle }, - { .key="__ResetHUD", .label="Reset to defaults", .kind=ROW_ACTION }, - - { .key="__HdrGraphics", .label="GRAPHICS", .kind=ROW_HEADER }, - { .key="Video.MSAA", .label="Anti-aliasing", .kind=ROW_MSAA, .restart=1 }, - { .key="Video.TextureFilter", .label="Texture filter", .kind=ROW_ENUM, .step=1, .names=kTexFilter }, - { .key="Video.Anisotropy", .label="Anisotropic filtering", .kind=ROW_SLIDER, .step=1, .unit="x" }, - { .key="Video.FovScale", .label="Field of view", .kind=ROW_SLIDER, .step=1 } /* D443/D357: shown as horizontal degrees; storage stays Video.FovScale % */, + { .key="__HdrVideo", .label="VIDEO", .kind=ROW_HEADER }, + /* T4-lite Display mode: derived Modern / Original N64 / Custom from + * videoPresetIsActive (D440 key table in video.c); a dropdown (F10) or + * left/right (both UIs) applies the picked preset at once. No new ini key: + * Custom is simply "neither preset's keys all match" and flips back when + * they do, so it is shown but not selectable. */ + { .key="__DisplayMode", .label="Display mode", .kind=ROW_ACTION }, + SEP(V1), + { .key="Video.Fullscreen", .label="Fullscreen", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + { .key="Video.FullscreenMode", .label="Fullscreen mode", .kind=ROW_ENUM, .step=1, .names=kFullscreenMode, .shownWhen="Video.Fullscreen", .shownValue=1 }, /* D511 */ + { .key="__CenterWindow", .label="Center window", .kind=ROW_ACTION, .shownWhen="Video.Fullscreen", .shownValue=0 }, /* D511: windowed only */ + { .key="__Resolution", .label="Resolution", .kind=ROW_RES }, + /* D447: Original letter/pillarboxes to the console aspect (4:3, or 16:9 while + * the watch-menu Ratio is 16:9); Window fills the window. */ + { .key="Video.AspectMode", .label="Aspect ratio", .kind=ROW_ENUM, .step=1, .names=kAspectMode }, /* D334: native widescreen (world projected at the window aspect, Hor+). * While on, "Widescreen auto FOV" has no effect (it was the stretch-era * vertical-FOV compensation). */ - /* D447: Original letter/pillarboxes to the console aspect (4:3, or 16:9 while - * the watch-menu Ratio is 16:9); Window fills the window. */ - { .key="Video.AspectMode", .label="Aspect", .kind=ROW_ENUM, .step=1, .names=kAspectMode }, { .key="Video.NativeWidescreen", .label="Native widescreen", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + { .key="Video.MSAA", .label="Anti-aliasing", .kind=ROW_MSAA, .restart=1 }, + SEP(V2), + { .key="Video.VSync", .label="VSync", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + { .key="Video.FpsCap", .label="Frame rate cap", .kind=ROW_FPSCAP }, + { .key="Video.DisplayFPS", .label="Show FPS", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + SEP(V3), + { .key="Video.TextureFilter", .label="Texture filter", .kind=ROW_ENUM, .step=1, .names=kTexFilter }, + { .key="Video.Anisotropy", .label="Anisotropic filtering", .kind=ROW_SLIDER, .step=1, .unit="x" }, + SEP(V4), { .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 }, - /* 100..400% of authored distance; 250% is the midpoint (50/100). + /* 1.0x..8.0x the authored distance (ini 100..800 %; 2.5x default). * 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 - * Input.MouseSensitivity row above is the player-facing knob, mirroring - * the GEPD/PD injector model. Config vars + SensLink logic stay live in - * input.c/rowSetCommit() for ini hand-edits. */ - /* { "Input.AimModeSens", "Mouse aim speed", ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 }, */ - /* { "Input.MouseTurnSpeed", "Mouse turn speed", ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 }, */ - /* { "Input.SensLink", "Link aim/turn sens",ROW_TOGGLE,1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, */ - /* D181/Game.ScreenShakeIntensity: user testing (v0.2.1) found the slider - * "basically useless" -- viShake() is only called from explosion.c, so it - * scales explosion shake alone; it never touches the always-on walking - * head-bob or any getting-shot reaction, which is what "Screen shake" - * reads as to a player. Pulled from the menu until it covers all - * screen-shake/view-bob sources, not just explosions. Config var + fr.c - * hook stay in place. */ - /* D440: one-press presets (arm -> confirm, like the reset - * rows). "Original N64" snaps every value the port changed from the N64 - * back (3-point filter, 4:3 projection, no overscan crop, 100% draw/LOD, - * stock FOV, N64 aim range, ...); "Port defaults" is its inverse. The - * key table lives in video.c (videoApplyPreset). Not a mode: nothing is - * remembered, the keys themselves are written. */ - { .key="__PresetN64", .label="Original N64 preset", .kind=ROW_ACTION }, - { .key="__PresetPort", .label="Port defaults preset", .kind=ROW_ACTION }, - { .key="__ResetGraphics", .label="Reset to defaults", .kind=ROW_ACTION }, + { .key="Video.DrawDistance", .label="Draw distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=800 }, + { .key="Video.LodDistance", .label="LOD distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=800 }, + SEP(V5), + /* D181 re-exposed (PD "Explosion shake"): named for what it scales (explosions only). */ + { .key="Game.ScreenShakeIntensity", .label="Explosion shake", .kind=ROW_SLIDER, .step=0.1, .uiMin=0, .uiMax=3 }, + { .key="__ResetVideo", .label="Reset to defaults", .kind=ROW_ACTION }, + SEP_BACK(Video), { .key="__HdrAudio", .label="AUDIO", .kind=ROW_HEADER }, /* D470: port-level master volume (final gain on the mixed output, whole %) * and the output device (live switch; Default = system default). */ { .key="Audio.MasterVolume", .label="Master volume", .kind=ROW_SLIDER, .step=5, .unit="%" }, { .key="Audio.Device", .label="Output device", .kind=ROW_AUDIODEV }, - /* D356: the per-file volume sliders get a real AUDIO section (the D353 - * "AUDIO (BOND FILE)" header was retired with the BOND FILE section). - * A port-level master volume (M3) will join here as Turok's - * Master/Sound/Music trio. */ + /* D356: the per-file volume sliders (homogeneous section: title annotation only). */ { .key="Bond.Music", .label="Music volume", .kind=ROW_SLIDER, .step=328, .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328, .saveScoped=1 }, { .key="Bond.FX", .label="FX volume", .kind=ROW_SLIDER, .step=328, .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328, .saveScoped=1 }, { .key="__ResetAudio", .label="Reset to defaults", .kind=ROW_ACTION }, + SEP_BACK(Audio), - /* D353's DISPLAY section renamed VIDEO (the presentation knobs), with - * Video.DisplayFPS moved here from the old GAME section. */ - { .key="__HdrVideo", .label="VIDEO", .kind=ROW_HEADER }, - { .key="Video.Fullscreen", .label="Fullscreen", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="__Resolution", .label="Resolution", .kind=ROW_RES }, - { .key="Video.VSync", .label="VSync", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="Video.FpsCap", .label="Frame rate cap", .kind=ROW_FPSCAP }, - { .key="Video.DisplayFPS", .label="Show FPS", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - { .key="__ResetVideo", .label="Reset to defaults", .kind=ROW_ACTION }, - /* D356: the D353 BOND FILE header + "Edit file" chooser row are retired -- - * the front options screen's top save-file row (frontoptions.c, D356) is - * the single file control; F10 always targets the active file. */ - /* { .key="__HdrBond", .label="BOND FILE", .kind=ROW_HEADER, .found=1 }, */ - /* { .key="__BondFile", .label="Edit file", .kind=ROW_BOND_FILE, .found=1 }, */ + { .key="__HdrMouse", .label="MOUSE", .kind=ROW_HEADER }, + /* PD parity (maintainer list 2026-10-04): the long-standing ini key finally gets a row; default on. */ + { .key="Input.MouseEnabled", .label="Mouse enabled", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + { .key="Input.MouseSensitivity", .label="Horizontal sensitivity", .kind=ROW_SLIDER, .step=10, + .uiMin=10, .uiMax=300 }, /* D443/D357: shown as a multiplier, raw 100 = 1.0x; storage unchanged */ + /* Wave A (v0.5.0, CONTROLLER-INPUT-PLAN item 6): Input.MouseYScale (extra + * vertical/pitch sensitivity, %); input.c already applies it. */ + { .key="Input.MouseYScale", .label="Vertical sensitivity", .kind=ROW_SLIDER, .step=10, + .uiMin=10, .uiMax=300 }, /* D506: shown as a multiplier like the horizontal row, raw 100 = 1.0x */ + { .key="Input.MouseInvertY", .label="Invert look", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + /* D337: N64 = the N64 aim model (crosshair travels, camera edge-scrolls) + * with the mouse fed through the game's integrator at PD's mouse damp -- + * the default. CENTRED (PC) = opt-in FPS-style aim (#104), not N64. + * Device-neutral (D404): applies to every aim input. */ + { .key="Input.AimMode", .label="Aim style", .kind=ROW_ENUM, .step=1, .names=kAimMode }, + /* D338: how far the N64-style crosshair travels. Hidden while the aim + * 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" }, + /* Existing ini-only keys exposed by the PD-parity regroup (rows only; input.c + * already reads them). */ + { .key="Input.MenuPointerMode", .label="Menu pointer", .kind=ROW_ENUM, .step=1, .names=kMenuPointer }, + { .key="Input.MouseSmoothing", .label="Smoothing", .kind=ROW_SLIDER, .step=5, .unit="%" }, + { .key="Input.MouseRawInput", .label="Raw input", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + { .key="__ResetMouse", .label="Reset to defaults", .kind=ROW_ACTION }, + SEP_BACK(Mouse), + + /* Controller: top-level hub entry; its page is the one-step Select player + * list (PD pattern, option A ratified) that sets the pad seat (s_padSeat) + * and opens that seat's Controller page. Hub label CONTROLLER, page title + * SELECT PLAYER (optionsRowTitle). */ + { .key="__HdrSelPlayerC", .label="CONTROLLER", .kind=ROW_HEADER }, + { .key="__SelPlayerC1", .label="Player 1", .kind=ROW_ACTION }, + { .key="__SelPlayerC2", .label="Player 2", .kind=ROW_ACTION }, + { .key="__SelPlayerC3", .label="Player 3", .kind=ROW_ACTION }, + { .key="__SelPlayerC4", .label="Player 4", .kind=ROW_ACTION }, + SEP_BACK(SelPlayerC), + + /* D469: preset selector; Custom unlocks per-seat, per-action pad rebinding + * (two slots per action, pad-driven capture). */ + { .key="__HdrController", .label="CONTROLLER", .kind=ROW_HEADER }, + { .key="Input.ControlScheme", .label="Control style", .kind=ROW_ENUM, .step=1, .names=kControlScheme }, /* D513 */ + { .key="Input.PadPreset", .label="Layout preset", .kind=ROW_ENUM, .step=1, .names=kPadPreset, .schemeOnly=1 }, + /* D516: Original shows the game's own styles (the save's, per seat), not the port presets. */ + { .key="Bond.Control", .label="Controller style", .kind=ROW_ENUM, .step=1, .names=kOrigStyle, + .found=1, .uiMax=7, .cfgMax=7, .schemeOnly=2 }, + { .key="Input.Pad.Fire", .label="Fire", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.Aim", .label="Aim", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.Use", .label="Use / action", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.Reload", .label="Reload", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.Crouch", .label="Crouch", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.NextWeapon", .label="Next weapon", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.PrevWeapon", .label="Previous weapon", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.Gadget", .label="Cycle gadget", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.Pad.Start", .label="Pause / start", .kind=ROW_PADBIND, .shownWhen="Input.PadPreset", .shownValue=3, .schemeOnly=1 }, + { .key="Input.PadLookInvertY", .label="Invert look", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + /* Wave A: per-stick deadzone + right-stick (natural-pitch) feel options. */ + { .key="Input.PadDeadzoneL", .label="Left stick deadzone", .kind=ROW_SLIDER, .step=300, .unit="%", .dispDiv=300 }, + { .key="Input.PadDeadzoneR", .label="Right stick deadzone", .kind=ROW_SLIDER, .step=300, .unit="%", .dispDiv=300 }, + { .key="Input.PadLookSensX", .label="Horizontal look sensitivity", .kind=ROW_SLIDER, .step=5 }, + { .key="Input.PadLookSensY", .label="Vertical look sensitivity", .kind=ROW_SLIDER, .step=5 }, + { .key="Input.PadLookSmooth", .label="Look smoothing", .kind=ROW_SLIDER, .step=1 }, /* D506: raw 0-10, shown as whole % */ + { .key="Input.PadSouthpaw", .label="Southpaw", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + { .key="Input.PadTriggerPct", .label="Trigger threshold", .kind=ROW_SLIDER, .step=1, .unit="%" }, /* D506: raw is already a percent */ + /* D401 item 3: one global Rumble Pak strength (0 = silent, 1 = full, dflt 0.5). */ + { .key="Input.RumbleScale", .label="Vibration", .kind=ROW_SLIDER, .step=0.05 }, + { .key="__ResetController", .label="Reset to defaults", .kind=ROW_ACTION }, + SEP_BACK(Controller), + + /* PD Key Bindings: one flat list (movement binds, separator, action binds). + * Mouse buttons 1-5 share the visible slots with keys; pads stay fixed. */ + { .key="__HdrBindings", .label="KEY BINDINGS", .kind=ROW_HEADER }, + { .key="Input.Bind.Forward", .label="Move forward", .kind=ROW_BIND }, + { .key="Input.Bind.Back", .label="Move backward", .kind=ROW_BIND }, + { .key="Input.Bind.StrafeLeft", .label="Strafe left", .kind=ROW_BIND }, + { .key="Input.Bind.StrafeRight", .label="Strafe right", .kind=ROW_BIND }, + { .key="Input.Bind.TurnLeft", .label="Turn left", .kind=ROW_BIND }, + { .key="Input.Bind.TurnRight", .label="Turn right", .kind=ROW_BIND }, + SEP(K1), + { .key="Input.Bind.Fire", .label="Fire", .kind=ROW_BIND }, + { .key="Input.Bind.Aim", .label="Aim", .kind=ROW_BIND }, + { .key="Input.Bind.Action", .label="Action / next weapon", .kind=ROW_BIND }, + { .key="Input.Bind.Cancel", .label="Use / back", .kind=ROW_BIND }, + { .key="Input.Bind.LeanLeft", .label="Lean left", .kind=ROW_BIND }, + { .key="Input.Bind.Start", .label="Pause / start", .kind=ROW_BIND }, + { .key="Input.Bind.Reload", .label="Reload", .kind=ROW_BIND }, + { .key="Input.Bind.Crouch", .label="Crouch", .kind=ROW_BIND }, + SEP(K2), + { .key="__ResetBindings", .label="Reset to defaults", .kind=ROW_ACTION }, + SEP_BACK(Bindings), + + { .key="__HdrGame", .label="GAME", .kind=ROW_HEADER }, + /* D356 hid Bond.Look and Bond.AimControl; re-exposed (menu PD alignment, + * maintainer OK 2026-10-04) under the game's own names/values ("Look + * up/down: Reverse/Upright", "Aim control: Hold/Toggle"). They are per-file + * watch rows like Auto-aim. Reverse/Upright flips pitch in the game (moveData. + * invertPitch) on top of the port's own Invert look (mouse/controller) rows, + * so turning both on cancels out. Aim control (hold vs toggle the aim + * button) is independent of Aim style: the port honours the game's toggle. */ + { .key="Bond.Look", .label="Look up/down", .kind=ROW_TOGGLE, + .names=kReverse, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + { .key="Bond.AimControl", .label="Aim control", .kind=ROW_TOGGLE, + .names=kHold, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + { .key="Bond.AutoAim", .label="Auto-aim", .kind=ROW_TOGGLE, + .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + { .key="Bond.LookAhead", .label="Look ahead", .kind=ROW_TOGGLE, + .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + { .key="Bond.Sight", .label="Sight on screen", .kind=ROW_TOGGLE, + .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + { .key="Bond.Ammo", .label="Ammo on screen", .kind=ROW_TOGGLE, + .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + SEP(G1), + /* Crouch mode lives here (PD keeps it in "Game"; ratified T1 gate 1). + * D371/D374: GEPD layout default; crouch defaults to hold. */ + { .key="Input.CrouchMode", .label="Crouch mode", .kind=ROW_ENUM, .step=1, .names=kHold }, + /* D443/D357: shown as horizontal degrees; storage stays Video.FovScale %. + * Moved here from Graphics (PD puts Vert FOV in Game). */ + { .key="Video.FovScale", .label="Field of view", .kind=ROW_SLIDER, .step=1 }, + /* D468: off = AI awareness keeps the cartridge-widest view (16:9 at the + * game's FOV) under ultrawide or a raised FOV; on = AI sees the full view. */ + { .key="Game.AIWideView", .label="Guards see the wider view", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + /* D232: the community "no damage flash" toggle (red/green hit-flash overlay in bondview2). */ + { .key="Game.NoHitFlash", .label="No hit flash", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + SEP(G2), + { .key="Video.CrosshairHide", .label="Show crosshair", .kind=ROW_TOGGLE, .step=1, .names=kOnOffRev }, + { .key="Video.CrosshairColor", .label="Crosshair color", .kind=ROW_ENUM, .step=1, .names=kCrosshairColor }, + { .key="Video.CrosshairRed", .label="Red", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, + { .key="Video.CrosshairGreen", .label="Green", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, + { .key="Video.CrosshairBlue", .label="Blue", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, + { .key="Video.CrosshairSize", .label="Crosshair size", .kind=ROW_SLIDER, .step=5, .unit="%" }, + { .key="Video.CrosshairStyle", .label="Crosshair style", .kind=ROW_ENUM, .step=1, .names=kCrosshairStyle }, + { .key="Video.CrosshairAlpha", .label="Crosshair opacity", .kind=ROW_SLIDER, .step=5, .unit="%" }, /* D511 */ + { .key="Video.CrosshairHealthColor", .label="Crosshair color by health", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, /* D511 */ + /* D226: scales the ammo counter, pickup/status text and dialogue. */ + { .key="Game.HudScale", .label="HUD scale", .kind=ROW_SLIDER, .step=5, .unit="%" }, + SEP(G3), + /* D216/Game.SkipIntro: D408 -- still EXPERIMENTAL: with it on, a failed/aborted + * mission skips the post-mission failure dossier (MENU_MISSION_FAILED). */ + { .key="Game.SkipIntro", .label="Skip intro (EXPERIMENTAL)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, + /* D257: everything-unlocked goodie (D442 RAM override); applies from the next launch. */ + { .key="Game.AllUnlocked", .label="All unlocked (EXPERIMENTAL)", + .kind=ROW_TOGGLE, .step=1, .names=kAllUnlocked }, + { .key="__ResetGame", .label="Reset to defaults", .kind=ROW_ACTION }, + /* Hub-only rows (.hub): shown after a separator at the bottom of the F10 hub, + * hidden on the Game page. The front options screen still lists them here. */ + { .key="__SepHub", .label="", .kind=ROW_ACTION, .hub=1 }, + /* D293: in-game quit; not config-backed. */ + { .key="__QuitToDesktop", .label="Quit to desktop", .kind=ROW_ACTION, .hub=1 }, + /* D443: orderly quit (D344) + relaunch, so "(restart)" rows like + * Anti-aliasing can be applied without leaving the game by hand. */ + { .key="__RestartGame", .label="Restart game", .kind=ROW_ACTION, .hub=1 }, + SEP_BACK(Game), + /* D519: the Bond-file (profile) chooser. Not a section row: overlayUpdateVisible injects it as the + * first row of every page that has profile-backed rows, on file select only (the old front-end + * screen's top "Profile" row, D356). */ + { .key="__BondFile", .label="Profile", .kind=ROW_BOND_FILE }, }; +/* Hub / tab order (PD's Extended hub: Video, Audio, Mouse, Controller, Game, + * Key Bindings). Differs from the physical order so the hub rows stay with Game. */ +static const char *const kRootOrder[] = { + "__HdrVideo", "__HdrAudio", "__HdrMouse", "__HdrSelPlayerC", "__HdrGame", "__HdrBindings", +}; +#define NUM_ROOTS ((int)(sizeof(kRootOrder) / sizeof(kRootOrder[0]))) #define NUM_ROWS ((int)(sizeof(rows) / sizeof(rows[0]))) static int rowIndexByKey(const char *key) @@ -467,29 +458,57 @@ static int rowIndexByKey(const char *key) int optionsRowHeaderParent(int i) { if (i < 0 || i >= NUM_ROWS || rows[i].kind != ROW_HEADER) return -1; - const char *key = rows[i].key; - if (!strcmp(key, "__HdrBindings") || !strcmp(key, "__HdrController")) - return rowIndexByKey("__HdrInput"); - if (!strcmp(key, "__HdrMoveKeys") || !strcmp(key, "__HdrActionKeys")) - return rowIndexByKey("__HdrBindings"); - if (!strcmp(key, "__HdrHUD")) return rowIndexByKey("__HdrGameplay"); + /* Select player is the Controller hub entry; its Controller page nests under it. */ + if (!strcmp(rows[i].key, "__HdrController")) return rowIndexByKey("__HdrSelPlayerC"); return -1; } +/* k-th top-level page in hub / tab order (PD: Video, Audio, Mouse, Controller, + * Game, Key Bindings) as a rows[] header index, or -1 past the end. */ +int optionsRootHeader(int k) +{ + if (k < 0 || k >= NUM_ROOTS) return -1; + return rowIndexByKey(kRootOrder[k]); +} + +static int isSepRow(const struct Row *r) +{ + return r->kind == ROW_ACTION && !strncmp(r->key, "__Sep", 5); +} + +/* On/Off rows draw as PD checkboxes; named two-way rows (Reverse/Upright, + * Hold/Toggle, the EXPERIMENTAL restart row) keep their value text. */ +static int overlayRowIsCheckbox(const struct Row *r) +{ + return r->kind == ROW_TOGGLE && (r->names == kOnOff || r->names == kOnOffRev); +} + +static int isBackRow(const struct Row *r) +{ + return r->kind == ROW_ACTION && !strncmp(r->key, "__Back", 6); +} + +/* "Select player" rows ("__SelPlayerC2"): the digit is the seat, 1-based. */ +static int selPlayerSeat(const struct Row *r) +{ + if (r->kind != ROW_ACTION || strncmp(r->key, "__SelPlayer", 11)) return -1; + return r->key[12] - '1'; +} + int optionsRowChildHeader(int i) { if (i < 0 || i >= NUM_ROWS) return -1; const char *key = rows[i].key; - if (!strcmp(key, "__OpenBindings")) return rowIndexByKey("__HdrBindings"); - if (!strcmp(key, "__OpenController")) return rowIndexByKey("__HdrController"); - if (!strcmp(key, "__OpenMoveKeys")) return rowIndexByKey("__HdrMoveKeys"); - if (!strcmp(key, "__OpenActionKeys")) return rowIndexByKey("__HdrActionKeys"); - if (!strcmp(key, "__OpenHud")) return rowIndexByKey("__HdrHUD"); + if (!strncmp(key, "__SelPlayerC", 12)) return rowIndexByKey("__HdrController"); return -1; } static int s_inited = 0; static volatile int s_open = 0; +/* D519: the overlay reopens where it was closed. Identity = rows[] indices (the table is + * static; only visibility changes between front end / level / scheme), checked on reopen. */ +static int s_memValid = 0, s_memSec = -1, s_memRow = -1, s_memScroll = 0, s_memSeat = 0; +static int s_seedPrev = 0; /* D519: first handled frame after an open adopts the held buttons as "previous" */ 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 */ @@ -501,6 +520,8 @@ 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 */ +static int s_mouseActive = 0; /* last input was the mouse: hover cues a row + the hint line hides */ +static int s_hover = -1; /* visible-list index under the pointer while s_mouseActive (cue only, never selection) */ /* D383: SDL_KEYDOWN belongs to videoPumpEvents (host), while both menu * controllers consume it on their own threads. Only the one-shot scancode @@ -778,44 +799,78 @@ static void fpsTick(void) /* Layout (game 2D pixel space = viGetX() x viGetY(), ~320x240). Shared by the * emit path and the mouse hit-testing in optionsOverlayHandleInput(). - * BankGothic caps are ~9 units tall here, so rows need ~16 units of pitch and - * values are right-aligned to the panel edge to survive the wide font. */ -#define OV_TOP 8 -#define OV_LINE 16 -#define OV_FOOTER_HEIGHT 38 /* two help lines, including gap below content */ -#define OV_FOOTER_LINE 13 -/* 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. */ + * PD-style dialog (menu PD parity R4): sized to its content and centred, one + * LINEHEIGHT-class title bar, flat translucent body, no footer (the hint line + * sits under the box). Rows are OV_LINE units apart; the label face is Zurich + * Bold (GE's only mixed-case face), see bodyFont(). */ +#define OV_LINE 14 +#define OV_TEXT_PCT 78 /* label face scale, % (see drawBody) */ +#define OV_TITLE_H 13 /* PD title bar: one text line + 2 */ +#define OV_PAD_TOP 3 +#define OV_PAD_BOT 4 +#define OV_HINT_H 53 /* D507: row description + two key-hint lines under the box (reserved when sizing) */ +#define OV_TEXT_DY 1 /* label baseline offset inside a row */ struct OvLayout { - s32 left, right, top, sectionY, contentY, bottom, footerY; + s32 left, right, top, titleB, contentY, bottom, hintY; s32 labelX, valueR, barX0, barX1, maxRows; }; +static double s_scrollF = 0.0; /* eased (drawn) scroll position, in rows; s_scroll is the target */ +static int s_scrollSnap = 1; /* snap s_scrollF to s_scroll on the next emit (page change) */ + +/* D510: the overlay lays out on a canonical 320-wide canvas, whatever the + * game's current VI canvas is (320x240 in a level, 440x330 on the front + * end); fast3d scales the overlay DL's rects by ovScale() (gfx_set_overlay_scale) + * so its on-screen size depends only on the window. */ +extern s32 portHudScalePercent(void); /* video.c: Game.HudScale %, 75..150 (D226) */ +/* D512: Game.HudScale also scales the whole dialog (box, text, FPS counter): + * the layout canvas shrinks by 100/hud and fast3d scales the DL back up by + * hud/100, so the box stays centred, fills the same window area share, and a + * taller scale just means fewer visible rows (scrolls as before). 100 % is + * the S2 mapping exactly. */ +static s32 ovHud(void) +{ + s32 h = portHudScalePercent(); + return h < 75 ? 75 : (h > 150 ? 150 : h); +} +static s32 ovW(void) { return (s32)lround(320.0 * 100.0 / (double)ovHud()); } +static s32 ovH(void) +{ + s32 x = viGetX(), y = viGetY(); + double h = x > 0 ? (double)y * 320.0 / (double)x : 240.0; + return (s32)lround(h * 100.0 / (double)ovHud()); +} +static float ovScale(void) +{ + s32 x = viGetX(); + return (x > 0 ? (float)x / 320.0f : 1.0f) * (float)ovHud() / 100.0f; +} +extern void gfx_set_overlay_scale(float s); + static struct OvLayout overlayLayout(void) { struct OvLayout o; - s32 w = viGetX(), h = viGetY(); - s32 cardW = w - 16; - if (cardW > 376) cardW = 376; + s32 w = ovW(), h = ovH(); + s32 cardW = s_section < 0 ? 196 : w - 16; /* PD hub dialogs are narrow; settings pages wide */ + if (cardW > 304) cardW = 304; + o.maxRows = (h - 2 * 4 - OV_HINT_H - OV_TITLE_H - 1 - OV_PAD_TOP - OV_PAD_BOT) / OV_LINE; + if (o.maxRows < 4) o.maxRows = 4; + int contentN = s_visN - (s_section >= 0 ? 1 : 0); /* a section's title is not a row */ + int drawn = contentN < o.maxRows ? contentN : o.maxRows; + s32 boxH = OV_TITLE_H + 1 + OV_PAD_TOP + drawn * OV_LINE + OV_PAD_BOT; 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 - OV_FOOTER_HEIGHT) / 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 + OV_FOOTER_HEIGHT; - if (o.bottom > h - 6) o.bottom = h - 6; - o.footerY = o.bottom - 29; + o.top = (h - boxH) / 2; + if (o.top + boxH + 4 + OV_HINT_H > h) o.top = h - OV_HINT_H - 4 - boxH; /* tall pages: keep the hint line on screen */ + if (o.top < 4) o.top = 4; + o.titleB = o.top + OV_TITLE_H; + o.contentY = o.titleB + 1 + OV_PAD_TOP; + o.bottom = o.top + boxH; + o.hintY = o.bottom + 3; + o.labelX = o.left + 10; + o.valueR = o.right - 10; + o.barX1 = o.valueR - 54; + o.barX0 = o.barX1 - 66; return o; } @@ -829,49 +884,43 @@ static void sliderBarSpan(s32 *x0, s32 *x1) *x1 = o.barX1; } -/* Visible position or -1. Match the rendered [s_scroll, pLast] window - * exactly (D304: never hit the invisible row below Quit). D391: the text - * baseline is BELOW rowY: measured glyph pixels occupy rowY+3..+9 at - * 320x240, so the former symmetric [rowY-8,rowY+8) hit band assigned - * the bottom of a visible label to the NEXT row. Leave a small dead zone - * between rows instead of guessing which adjacent item was intended. */ +/* Visible position or -1. Follows the drawn (eased) scroll so a click lands on + * the row it visually covers while the list is still gliding. Rows own their + * whole OV_LINE band; hits outside the body are rejected. */ static int overlayRowAtY(double ox, double oy) { struct OvLayout o = overlayLayout(); - if (ox < o.left + 3 || ox >= o.right - 3) return -1; - if (s_section >= 0 && oy >= o.sectionY - 2 && - oy < o.sectionY + 12) 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 y = o.contentY + (p - s_scroll) * OV_LINE; - if (oy >= y - 2 && oy < y + 12) return p; + if (ox < o.left + 1 || ox >= o.right - 1) return -1; + if (oy < o.contentY || oy >= o.contentY + o.maxRows * OV_LINE) return -1; + int p0 = (int)s_scrollF; + for (int p = p0; p <= p0 + o.maxRows && p < s_visN; p++) { + double y = o.contentY + (p - s_scrollF) * OV_LINE; + if (y < o.contentY - 0.5 && y + OV_LINE <= o.contentY) continue; + if (oy >= y && oy < y + OV_LINE) return p; } return -1; } -/* Opt-in geometry check on both the first and last scroll windows. The - * visible glyph ink occupies approximately y+3..y+9, so both ends must - * select the drawn row, with a dead zone after it and no off-card hit. */ +/* Opt-in geometry check on both the first and last scroll windows: every + * drawn row must be hit by its own band (top and bottom edge), and the gutter + * outside the card must never hit. */ static int overlayHitCheck(void) { struct OvLayout o = overlayLayout(); int first = s_section >= 0 ? 1 : 0; int lastScroll = s_visN - first > o.maxRows ? s_visN - o.maxRows : first; - int saved = s_scroll, bad = 0; + int saved = s_scroll; double savedF = s_scrollF; int bad = 0; for (int scroll = first; scroll <= lastScroll; scroll += lastScroll - first ? lastScroll - first : 1) { - s_scroll = scroll; + s_scroll = scroll; s_scrollF = scroll; int last = scroll + o.maxRows < s_visN ? scroll + o.maxRows : s_visN; for (int p = scroll; p < last; p++) { double y = o.contentY + (p - scroll) * OV_LINE; - if (overlayRowAtY(o.labelX, y + 3) != p || - overlayRowAtY(o.labelX, y + 9) != p || - overlayRowAtY(o.labelX, y + 12) != -1 || - overlayRowAtY(o.left - 1, y + 9) != -1) bad++; + if (overlayRowAtY(o.labelX, y + 1) != p || + overlayRowAtY(o.labelX, y + OV_LINE - 1) != p || + overlayRowAtY(o.left - 1, y + 5) != -1) bad++; } - if (first && overlayRowAtY(o.labelX, o.sectionY + 9) != 0) bad++; } - s_scroll = saved; + s_scroll = saved; s_scrollF = savedF; return bad; } @@ -879,34 +928,57 @@ static int overlayHitCheck(void) /* Move through the current category list or section (including its Back row). */ static int overlayStepSel(int pos, int dir) { - int q = pos + dir; - return (q < 0 || q >= s_visN) ? pos : q; + int first = s_section >= 0 ? 1 : 0; /* idx 0 of a section is its title */ + int q = pos; + for (;;) { + q += dir; + if (q < first || q >= s_visN) return pos; + if (!isSepRow(&rows[s_visIdx[q]])) return q; /* separators are not selectable */ + } } static void overlayUpdateVisible(void) { s_visN = 0; for (int i = 0; i < NUM_ROWS; i++) { - /* Root: only top-level headers. Nested sections are opened through - * link rows in Input/Bindings and Gameplay. Section header = Back. */ + /* Root: the six pages, built below. Controller's seat page opens its + * Controller page through the Player N link rows. */ if (s_section < 0) { - if (rows[i].kind == ROW_HEADER && optionsRowHeaderParent(i) < 0) - s_visIdx[s_visN++] = i; + continue; /* root list is built below in hub order */ } else if (i == s_section) { - s_visIdx[s_visN++] = i; /* Back */ + s_visIdx[s_visN++] = i; /* title (not selectable) */ } else if (i > s_section) { if (rows[i].kind == ROW_HEADER) break; + if (rows[i].hub) continue; /* hub-only rows live on the root list */ if (rows[i].kind != ROW_BOND_FILE && !(rows[i].hidePtr && *rows[i].hidePtr) && - (!rows[i].showPtr || *rows[i].showPtr == rows[i].shownValue)) + (!rows[i].showPtr || *rows[i].showPtr == rows[i].shownValue) && + rowSchemeOk(&rows[i])) s_visIdx[s_visN++] = i; } } + if (s_section >= 0 && current_menu == MENU_FILE_SELECT) { /* D519: profile chooser, first row of profile-backed pages */ + int hasBond = 0, chooser = -1; + for (int q = 0; q < s_visN; q++) + if (watchSettingsFieldForKey(rows[s_visIdx[q]].key) >= 0) hasBond = 1; + for (int i = 0; i < NUM_ROWS; i++) if (rows[i].kind == ROW_BOND_FILE) chooser = i; + if (hasBond && chooser >= 0 && s_visN >= 1 && s_visN < (int)(sizeof(s_visIdx) / sizeof(s_visIdx[0]))) { + memmove(&s_visIdx[2], &s_visIdx[1], (size_t)(s_visN - 1) * sizeof(s_visIdx[0])); + s_visIdx[1] = chooser; + s_visN++; + } + } + if (s_section < 0) { /* the six pages in PD hub order, then hub-only rows (separator, Restart, Quit) */ + for (int k = 0; k < NUM_ROOTS; k++) + s_visIdx[s_visN++] = optionsRootHeader(k); + for (int i = 0; i < NUM_ROWS; i++) + if (rows[i].hub) s_visIdx[s_visN++] = i; + } if (s_visN == 0) { /* cannot happen (toggles have no hide source) */ s_visIdx[s_visN++] = 0; } - if (s_sel < 0) { - s_sel = 0; + if (s_sel < (s_section >= 0 ? 1 : 0)) { + s_sel = s_section >= 0 ? 1 : 0; } if (s_sel >= s_visN) { s_sel = s_visN - 1; @@ -945,24 +1017,62 @@ static void overlayUpdateScroll(void) if (s_scroll > s_visN - maxV) s_scroll = s_visN - maxV; } +static void ddClose(void); +static void ddOpen(int rowIdx); +static void ddConfirm(int k); +static void rowAdjust(struct Row *r, int dir); +static int s_ddRow; static void overlayOpenHeader(int hdr) { + ddClose(); s_section = hdr; s_sel = 1; s_scroll = 0; + s_scrollSnap = 1; optionsResetClear(); overlayUpdateVisible(); + /* Select player pages open on the seat last edited. */ + if (!strncmp(rows[hdr].key, "__HdrSelPlayer", 14) && s_padSeat >= 0 && s_padSeat < 4 && + 1 + s_padSeat < s_visN) + s_sel = 1 + s_padSeat; overlayUpdateScroll(); sysLogPrintf(LOG_INFO, "optionsoverlay: opened %s", rows[hdr].label); } +/* D518: pad presets are per seat. The "Layout preset" row and the Custom-only + * bind rows (shownWhen Input.PadPreset) must follow the selected seat, so + * re-point them whenever the seat changes; input.c reads each seat's own + * value, so editing another player's preset can't touch this one. */ +static void padSetSeat(int seat) +{ + if (seat < 0 || seat > 3) return; + s_padSeat = seat; + watchSettingsSetControlSeat(seat); + int *pp = inputPadPresetPtr(seat); + for (int i = 0; i < NUM_ROWS; i++) { + if (!strcmp(rows[i].key, "Input.PadPreset")) rows[i].ptr = pp; + else if (rows[i].kind == ROW_PADBIND) rows[i].showPtr = pp; + } +} + +/* A "Player N" row on a Select player page picks the seat before it opens the + * Controller / Key bindings page; every other link just opens its header. */ +static void overlayOpenLink(int row) +{ + int seat = selPlayerSeat(&rows[row]); + if (seat >= 0) padSetSeat(seat); + overlayOpenHeader(optionsRowChildHeader(row)); +} + static void overlayBackOne(void) { int child = s_section; if (child < 0) return; + ddClose(); s_section = optionsRowHeaderParent(child); s_sel = 0; s_scroll = 0; + s_scrollSnap = 1; optionsResetClear(); overlayUpdateVisible(); /* Re-select the link that opened this child (or the root category). @@ -970,7 +1080,8 @@ static void overlayBackOne(void) for (int p = 0; p < s_visN; p++) { int i = s_visIdx[p]; if ((s_section < 0 && i == child) || - (s_section >= 0 && optionsRowChildHeader(i) == child)) { + (s_section >= 0 && optionsRowChildHeader(i) == child && + (selPlayerSeat(&rows[i]) < 0 || selPlayerSeat(&rows[i]) == s_padSeat))) { s_sel = p; break; } @@ -980,14 +1091,6 @@ static void overlayBackOne(void) rows[child].label, s_section < 0 ? "categories" : rows[s_section].label); } -/* The close box brackets the title row at the panel's right edge. */ -static int overlayInCloseBox(double ox, double oy) -{ - struct OvLayout o = overlayLayout(); - return ox >= o.right - 40 && ox <= o.right - 5 && - oy >= o.top + 3 && oy <= o.top + 22; -} - /* ------------------------------------------------------------------------ */ static void resolveCb(const char *key, int type, void *ptr, double min, double max, @@ -1048,6 +1151,8 @@ static void overlayInit(void) if (rows[i].shownWhen && strcmp(rows[j].key, rows[i].shownWhen) == 0) rows[i].showPtr = rows[j].ptr; + if (rows[i].schemeOnly && strcmp(rows[j].key, "Input.ControlScheme") == 0) + rows[i].schemePtr = rows[j].ptr; } } /* D356: on a header row, saveScoped means "this section contains @@ -1070,59 +1175,64 @@ static void overlayInit(void) int oldSection = s_section, oldSel = s_sel, oldScroll = s_scroll; int bad = 0; static const char *const roots[] = { - "__HdrInput", "__HdrGameplay", "__HdrGraphics", "__HdrAudio", "__HdrVideo" + "__HdrVideo", "__HdrAudio", "__HdrMouse", "__HdrSelPlayerC", "__HdrGame", "__HdrBindings" }; s_section = -1; s_sel = 0; s_scroll = 0; overlayUpdateVisible(); bad += overlayHitCheck(); - if (s_visN != 5) bad++; - for (int p = 0; p < 5 && p < s_visN; p++) + if (s_visN != 9) bad++; /* 6 pages + separator + Restart + Quit */ + for (int p = 0; p < 6 && p < s_visN; p++) if (strcmp(rows[s_visIdx[p]].key, roots[p])) bad++; - static const struct { const char *page, *childA, *childB; int maxRows; } pages[] = { - { "__HdrInput", "__OpenBindings", NULL, 14 }, - { "__HdrBindings", "__OpenMoveKeys", "__OpenActionKeys", 14 }, - { "__HdrMoveKeys", "Input.Bind.Forward", "__ResetMoveKeys", 14 }, - { "__HdrActionKeys", "Input.Bind.Fire", "__ResetActionKeys", 14 }, - { "__HdrGameplay", "__OpenHud", "__ResetGameplay", 14 }, - { "__HdrHUD", "Video.CrosshairColor", "__ResetHUD", 14 }, - { "__HdrController", "Input.PadPreset", "__ResetController", 14 }, - { "__HdrVideo", "Video.FpsCap", "__ResetVideo", 14 }, + /* Pages may scroll (PD dialogs do); overlayHitCheck walks the first and + * last scroll windows instead of a no-scroll row cap. */ + static const struct { const char *page, *childA, *childB; } pages[] = { + { "__HdrVideo", "__DisplayMode", "Game.ScreenShakeIntensity" }, + { "__HdrAudio", "Audio.MasterVolume", "Bond.FX" }, + { "__HdrMouse", "Input.MouseSensitivity", "Input.MouseRawInput" }, + { "__HdrSelPlayerC", "__SelPlayerC1", "__SelPlayerC4" }, + { "__HdrController", "Input.ControlScheme", "__ResetController" }, + { "__HdrBindings", "Input.Bind.Forward", "__ResetBindings" }, + { "__HdrGame", "Bond.Look", "__ResetGame" }, }; for (size_t k = 0; k < sizeof(pages) / sizeof(pages[0]); k++) { int hdr = rowIndexByKey(pages[k].page), gotA = 0, gotB = 0; overlayOpenHeader(hdr); bad += overlayHitCheck(); - if (s_visN - 1 > pages[k].maxRows) bad++; for (int p = 1; p < s_visN; p++) { gotA |= !strcmp(rows[s_visIdx[p]].key, pages[k].childA); if (pages[k].childB) gotB |= !strcmp(rows[s_visIdx[p]].key, pages[k].childB); if (!strcmp(rows[s_visIdx[p]].key, "Video.LowEndMode")) bad++; + if (rows[s_visIdx[p]].hub) bad++; /* hub-only rows never leak onto a page */ } if (!gotA || (pages[k].childB && !gotB)) bad++; + if (strncmp(rows[s_visIdx[s_visN - 1]].key, "__Back", 6)) bad++; /* Back closes every page */ overlayBackOne(); int parent = optionsRowHeaderParent(hdr); if (s_section != parent) bad++; } - int hud = rowIndexByKey("__HdrHUD"); - overlayOpenHeader(hud); - static const char *const hudKeys[] = { - "Bond.Sight", "Bond.Ammo", "Game.HudScale" - }; - for (size_t k = 0; k < sizeof(hudKeys) / sizeof(hudKeys[0]); k++) { - int seen = 0; - for (int p = 1; p < s_visN; p++) - seen |= !strcmp(rows[s_visIdx[p]].key, hudKeys[k]); - if (!seen) bad++; + /* Moved rows: HUD rows and FOV live on Game; no stale pages exist. */ + { + static const char *const gameKeys[] = { + "Bond.Sight", "Bond.Ammo", "Game.HudScale", "Video.FovScale", "Input.CrouchMode", + "Video.CrosshairColor", "Game.SkipIntro" + }; + overlayOpenHeader(rowIndexByKey("__HdrGame")); + for (size_t k = 0; k < sizeof(gameKeys) / sizeof(gameKeys[0]); k++) { + int seen = 0; + for (int p = 1; p < s_visN; p++) + seen |= !strcmp(rows[s_visIdx[p]].key, gameKeys[k]); + if (!seen) bad++; + } } - if (optionsRowChildHeader(rowIndexByKey("__OpenHud")) != hud || - optionsRowHeaderParent(hud) != rowIndexByKey("__HdrGameplay") || - optionsRowHeaderParent(rowIndexByKey("__HdrMoveKeys")) != - rowIndexByKey("__HdrBindings")) bad++; + if (rowIndexByKey("__HdrInput") >= 0 || rowIndexByKey("__HdrHUD") >= 0 || + rowIndexByKey("__HdrGraphics") >= 0) bad++; + if (optionsRowChildHeader(rowIndexByKey("__SelPlayerC1")) != rowIndexByKey("__HdrController") || + optionsRowHeaderParent(rowIndexByKey("__HdrController")) != rowIndexByKey("__HdrSelPlayerC")) bad++; s_section = oldSection; s_sel = oldSel; s_scroll = oldScroll; overlayUpdateVisible(); sysLogPrintf(bad ? LOG_ERROR : LOG_INFO, - "GE_OPTIONTREEPROBE: %s (5 roots, nested links/back, two bind pages, HUD, row caps, no M5)", + "GE_OPTIONTREEPROBE: %s (6 roots + hub rows, Select player -> Controller, flat Key Bindings, Game holds HUD/FOV, scroll windows hit-test, Back last)", bad ? "FAIL" : "PASS"); } @@ -1156,18 +1266,11 @@ static void overlayInit(void) const char *e = getenv("GE_OPTIONSOVERLAY"); if (e && atoi(e) != 0) { s_open = 1; - /* Diagnostic screenshots: 1 = root, 2..6 = five categories; - * GE_OPTIONSOVERLAY_SECTION=__HdrHUD etc. targets nested pages. */ + /* Diagnostic screenshots: 1 = root, 2..7 = the six pages in hub order; + * GE_OPTIONSOVERLAY_SECTION=__HdrController etc. targets nested pages. */ int page = atoi(e) - 2; if (page >= 0) { - int found = 0; - for (int i = 0; i < NUM_ROWS; i++) { - if (rows[i].kind == ROW_HEADER && optionsRowHeaderParent(i) < 0 && - found++ == page) { - overlayOpenHeader(i); - break; - } - } + if (optionsRootHeader(page) >= 0) overlayOpenHeader(optionsRootHeader(page)); } /* Isolated screenshot/probe of a nested page, never a menu mode. */ const char *section = getenv("GE_OPTIONSOVERLAY_SECTION"); @@ -1177,7 +1280,16 @@ static void overlayInit(void) /* Screenshot-only selection state for checking the pinned Back * row against a normal selected setting on the same page. */ if (s_section >= 0 && getenv("GE_OPTIONSOVERLAY_SELECTBACK")) - s_sel = 0; + s_sel = s_visN - 1; /* Back is the last row of every page */ + /* Screenshot-only: open a dropdown popup (GE_OPTIONSOVERLAY_DROPDOWN=) and focus its row. */ + { + const char *ddk = getenv("GE_OPTIONSOVERLAY_DROPDOWN"); + int ddi = ddk ? rowIndexByKey(ddk) : -1; + if (ddi >= 0) { + for (int q = 0; q < s_visN; q++) if (s_visIdx[q] == ddi) s_sel = q; + ddOpen(ddi); + } + } sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY=%s)", e); /* D383 isolated host->menu smoke probe. Only with explicit opt-in; * test data dir must be private because capture persists the key. */ @@ -1215,6 +1327,13 @@ static void overlayInit(void) } } +/* D516: scheme-conditional rows (see Row.schemeOnly). */ +static int rowSchemeOk(const struct Row *r) +{ + if (!r->schemeOnly || !r->schemePtr) return 1; + return (*r->schemePtr != 0) == (r->schemeOnly == 2); +} + static double rowGet(const struct Row *r) { int field = watchSettingsFieldForKey(r->key); @@ -1239,20 +1358,13 @@ 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. */ +/* D506: the old 50/100 "calibrated default" mapping is retired. Every slider is + * a linear bar showing a real unit (%, x, deg), so the same quantity reads the + * same way in every row and in both UIs. Kept as a hook (returns "none") so the + * piecewise mapping below stays a no-op rather than being rewritten. */ static double calibratedDefault(const struct Row *r) { - if (!strcmp(r->key, "Input.MouseYScale")) return 100.0; /* Wave A item 6: native */ - /* D443/D357: MouseSensitivity (x multiplier) and PadDeadzoneL/R (whole %) - * left the calibrated 50/100 mapping -- they now show real units on a - * linear bar (storage unchanged). */ - if (!strcmp(r->key, "Input.PadLookSensX")) return 100.0; /* Wave A: native */ - if (!strcmp(r->key, "Input.PadLookSensY")) return 100.0; /* Wave A: native */ - if (!strcmp(r->key, "Input.PadTriggerPct")) return 23.0; + (void)r; return -1.0; } static double rowFractionAt(const struct Row *r, double v) @@ -1324,6 +1436,8 @@ static void rowSetCommit(struct Row *r, double v, int commit) * directly; rebuilding textures for every RGB/FOV detent caused hitches. */ if (strcmp(r->key, "Video.Fullscreen") == 0) { videoRequestFullscreen((int)lround(v)); + } else if (strcmp(r->key, "Video.FullscreenMode") == 0) { /* D511 */ + videoRequestFullscreenMode((int)lround(v)); } else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) { videoRequestLiveConfigForKey(r->key); } @@ -1358,6 +1472,24 @@ static void rowSetCommit(struct Row *r, double v, int commit) static void rowSet(struct Row *r, double v) { rowSetCommit(r, v, 1); } +/* D516 (extends D489 from sliders to every Bond-file value row): a stepped + * toggle/enum used to commit on EVERY step -- each commit is three + * joyDisablePoll/joyEnablePoll handshakes plus an EEPROM rewrite on the game + * thread, so holding left/right on e.g. the 8-style "Controller style" row + * stalled frames for as long as it was held (maintainer live pass + * 2026-10-04: adjusting the old control style in a mission dropped the fps). + * Stage like the sliders; optionsAdjustCommitPending() saves once on release + * (it runs every frame while left/right is not held, and on F10 close). Rows + * without a watch field fall through to a plain commit. */ +static void rowSetStepWatch(struct Row *r, double v) +{ + int field = watchSettingsFieldForKey(r->key); + if (field < 0) { rowSet(r, v); return; } + int prev = SDL_AtomicSet(&s_adjWatchField, field + 1); + if (prev > 0 && prev != field + 1) watchSettingsCommit(prev - 1); + rowSetCommit(r, v, 0); +} + /* D356 reset rows (defined below, in the reset block). */ static struct Row *rowAt(int i); static int isResetRow(const struct Row *r); @@ -1370,14 +1502,17 @@ static void rowAdjust(struct Row *r, int dir) return; } if (r->kind == ROW_BOND_FILE) { - if (current_menu == MENU_PC_OPTIONS) watchSettingsChooseFile(dir); + if (current_menu == MENU_FILE_SELECT) watchSettingsChooseFile(dir); /* front end: file select */ return; } + if (watchSettingsFieldForKey(r->key) == WATCH_SETTING_CONTROL) { /* D516: no live player -> n/a */ + if (watchSettingsRead(WATCH_SETTING_CONTROL) < 0) return; + } else if (watchSettingsFieldForKey(r->key) >= 0 && !watchSettingsAvailable()) return; double v = rowGet(r); switch (r->kind) { case ROW_TOGGLE: - rowSet(r, (v != 0.0) ? 0.0 : 1.0); + rowSetStepWatch(r, (v != 0.0) ? 0.0 : 1.0); break; case ROW_MSAA: { int idx = 0; @@ -1387,13 +1522,13 @@ static void rowAdjust(struct Row *r, int dir) /* Wrap like a normal settings-menu cycle: OFF->2x->4x->8x->16x->OFF, * in both directions (left/right click and arrows all roll). */ idx = (idx + dir + MSAA_N) % MSAA_N; - rowSet(r, (double)kMsaaSeq[idx]); + rowSetStepWatch(r, (double)kMsaaSeq[idx]); break; } case ROW_ENUM: { double lo = r->cfgMin, hi = r->cfgMax; if (!strcmp(r->key, "Input.PadPreset")) { /* D498: step through the preset display order (0,1,2,4,3) */ - rowSet(r, (double)inputPadPresetStep((int)lround(v), dir)); + rowSetStepWatch(r, (double)inputPadPresetStep((int)lround(v), dir)); break; } if (!strcmp(r->key, "Video.TextureFilter")) { @@ -1404,13 +1539,13 @@ static void rowAdjust(struct Row *r, int dir) int cur = (int)lround(v), i = 0; while (i < 4 && order[i] != cur) i++; if (i == 4) i = 0; - rowSet(r, (double)order[(i + (dir > 0 ? 1 : 3)) % 4]); + rowSetStepWatch(r, (double)order[(i + (dir > 0 ? 1 : 3)) % 4]); break; } v += dir; if (v < lo) v = hi; if (v > hi) v = lo; - rowSet(r, v); + rowSetStepWatch(r, v); break; } case ROW_FPSCAP: { @@ -1419,7 +1554,7 @@ static void rowAdjust(struct Row *r, int dir) if (kFpsCapSeq[i] == (int)lround(v)) idx = i; } idx = (idx + dir + 2) % 2; - rowSet(r, (double)kFpsCapSeq[idx]); + rowSetStepWatch(r, (double)kFpsCapSeq[idx]); break; } case ROW_RES: { @@ -1438,7 +1573,7 @@ static void rowAdjust(struct Row *r, int dir) r->bindSlot = (r->bindSlot + dir + INPUT_BIND_SLOTS) % INPUT_BIND_SLOTS; break; case ROW_PADSEAT: - s_padSeat = (s_padSeat + (dir >= 0 ? 1 : 3)) % 4; + padSetSeat((s_padSeat + (dir >= 0 ? 1 : 3)) % 4); /* D518 */ break; case ROW_AUDIODEV: { /* D470: cycle Default + enumerated outputs; applied live on the audio thread. */ @@ -1452,12 +1587,23 @@ static void rowAdjust(struct Row *r, int dir) break; } case ROW_ACTION: + if (!strcmp(r->key, "__DisplayMode")) { /* left/right steps Modern <-> Original N64; Custom -> Modern (right) / N64 (left) */ + int cur = displayModeNow(); + videoApplyPreset((cur == 0 || (cur == 2 && dir < 0)) ? VIDEO_PRESET_N64 : VIDEO_PRESET_PORT); + break; + } if (optionsRowChildHeader((int)(r - rows)) >= 0) break; /* link, not a Quit or value */ + if (isSepRow(r)) break; + if (isBackRow(r)) { if (dir > 0) overlayBackOne(); break; } /* D356: reset rows arm/confirm through the shared activation * contract (edge-triggered by their callers: fresh key press / fresh * click / A-press -- the held-repeat paths below never re-fire them). * The quit row is the only non-reset ROW_ACTION left. */ if (isArmRow(r)) { rowActivateReset(r); break; } + if (!strcmp(r->key, "__CenterWindow")) { /* D511: host thread does the SDL call */ + if (dir > 0) videoRequestCenterWindow(); + break; + } /* D293: same exit path as SDL_QUIT / Alt+F4 (video.c), just reachable * without OS window chrome or a keyboard. */ if (!strcmp(r->key, "__RestartGame")) { @@ -1500,6 +1646,181 @@ static void rowAdjust(struct Row *r, int dir) } } +static s32 bodyWidth(const char *str); +/* ---- PD dropdown (menuitemDropdownRender / menugfxDrawDropdownBackground) ---- + * Rows that choose one value from a list (enum, anti-aliasing, resolution, + * frame cap, output device) open a popup under the row: up/down/wheel/hover + * pick, Enter/A/click confirm, Esc/B/right-click/outside-click/closing the menu + * cancel. Left/right on the closed row still steps the value (quick cycling). + * Two-way named toggles (Reverse/Upright, Hold/Toggle) stay plain value rows. */ +#define DD_MAX 24 +#define DD_VIS 9 +static int s_ddRow = -1; /* rows[] index of the open dropdown, -1 = closed */ +static int s_ddSel = 0, s_ddTop = 0, s_ddN = 0, s_ddCur = 0; +static int s_ddVals[DD_MAX]; +static char s_ddLab[DD_MAX][40]; + +static int rowIsDropdown(const struct Row *r) +{ + if (!r->found) return 0; + if (r->kind == ROW_ENUM || r->kind == ROW_MSAA || r->kind == ROW_FPSCAP) return 1; + if (r->kind == ROW_RES) return !videoIsFullscreen() && s_resFitN > 0; + if (r->kind == ROW_AUDIODEV) return 1; + if (r->kind == ROW_ACTION && !strcmp(r->key, "__DisplayMode")) return 1; + return 0; +} + +/* Fill the item list in display order; s_ddCur = the item holding the current value. */ +static void ddBuild(const struct Row *r) +{ + double v = rowGet(r); + int cur = (int)lround(v); + s_ddN = 0; s_ddCur = 0; + if (r->kind == ROW_ENUM && r->names) { + int order[DD_MAX], n = 0, cnt = 0; + while (r->names[cnt] && cnt < DD_MAX) cnt++; + if (!strcmp(r->key, "Video.TextureFilter")) { /* D499 menu order */ + static const int o4[4] = { 0, 1, 3, 2 }; + for (int i = 0; i < 4 && i < cnt; i++) order[n++] = o4[i]; + } else if (!strcmp(r->key, "Input.PadPreset")) { /* D498 display order */ + int c = 0; + do { order[n++] = c; c = inputPadPresetStep(c, +1); } while (c != 0 && n < cnt); + } else { + for (int i = 0; i < cnt; i++) order[n++] = i; + } + for (int i = 0; i < n; i++) { + s_ddVals[i] = order[i]; + snprintf(s_ddLab[i], sizeof(s_ddLab[i]), "%s", r->names[order[i]]); + if (order[i] == cur) s_ddCur = i; + } + s_ddN = n; + } else if (r->kind == ROW_ACTION) { /* Display mode: Custom is shown (current) but picking it is a no-op */ + for (int i = 0; i < 3; i++) { s_ddVals[i] = i; snprintf(s_ddLab[i], sizeof(s_ddLab[i]), "%s", kDisplayModeName[i]); } + s_ddN = 3; + s_ddCur = displayModeNow(); + } else if (r->kind == ROW_MSAA) { + for (int i = 0; i < MSAA_N; i++) { + s_ddVals[i] = kMsaaSeq[i]; + if (kMsaaSeq[i] <= 1) snprintf(s_ddLab[i], sizeof(s_ddLab[i]), "None"); + else snprintf(s_ddLab[i], sizeof(s_ddLab[i]), "%dx", kMsaaSeq[i]); + if (kMsaaSeq[i] == cur) s_ddCur = i; + } + s_ddN = MSAA_N; + } else if (r->kind == ROW_FPSCAP) { + for (int i = 0; i < 2; i++) { + s_ddVals[i] = kFpsCapSeq[i]; + snprintf(s_ddLab[i], sizeof(s_ddLab[i]), "%d FPS", kFpsCapSeq[i]); + if (kFpsCapSeq[i] == cur) s_ddCur = i; + } + s_ddN = 2; + } else if (r->kind == ROW_RES) { + for (int k = 0; k < s_resFitN && k < DD_MAX; k++) { + int i = s_resFit[k]; + s_ddVals[k] = k; + snprintf(s_ddLab[k], sizeof(s_ddLab[k]), "%d x %d", kResList[i][0], kResList[i][1]); + } + s_ddN = s_resFitN < DD_MAX ? s_resFitN : DD_MAX; + s_ddCur = s_resSel < s_ddN ? s_resSel : 0; + } else if (r->kind == ROW_AUDIODEV) { + int n = audioDeviceRefresh(); + if (n > DD_MAX - 1) n = DD_MAX - 1; + s_ddVals[0] = 0; + snprintf(s_ddLab[0], sizeof(s_ddLab[0]), "Default"); + for (int k = 0; k < n; k++) { + s_ddVals[k + 1] = k + 1; + snprintf(s_ddLab[k + 1], sizeof(s_ddLab[k + 1]), "%.34s", audioDeviceName(k)); + if (!strcmp(audioDeviceName(k), audioDeviceCurrentName())) s_ddCur = k + 1; + } + s_ddN = n + 1; + } +} + +static void ddOpen(int rowIdx) +{ + struct Row *r = &rows[rowIdx]; + if (!rowIsDropdown(r)) return; + /* D516: no live player -> the style row is n/a (value -1); never open a popup on it. */ + if (watchSettingsFieldForKey(r->key) == WATCH_SETTING_CONTROL && watchSettingsRead(WATCH_SETTING_CONTROL) < 0) return; + ddBuild(r); + if (s_ddN <= 0) return; + s_ddRow = rowIdx; + s_ddSel = s_ddCur; + s_ddTop = s_ddSel >= DD_VIS ? s_ddSel - DD_VIS + 1 : 0; + optionsResetClear(); + sysLogPrintf(LOG_INFO, "optionsoverlay: dropdown '%s' opened (%d items)", r->label, s_ddN); +} + +static void ddClose(void) { s_ddRow = -1; } + +static void ddMove(int dir) +{ + int q = s_ddSel + dir; + if (q < 0 || q >= s_ddN) return; /* clamped, like the main list */ + s_ddSel = q; + if (s_ddSel < s_ddTop) s_ddTop = s_ddSel; + if (s_ddSel >= s_ddTop + DD_VIS) s_ddTop = s_ddSel - DD_VIS + 1; +} + +static void ddConfirm(int k) +{ + if (s_ddRow < 0 || k < 0 || k >= s_ddN) { ddClose(); return; } + struct Row *r = &rows[s_ddRow]; + int val = s_ddVals[k]; + ddClose(); + switch (r->kind) { + case ROW_RES: { + s_resSel = val; + int i = s_resFit[s_resSel]; + videoRequestWindowSize(kResList[i][0], kResList[i][1]); + break; + } + case ROW_AUDIODEV: + audioDeviceRequest(val == 0 ? "" : audioDeviceName(val - 1)); + break; + case ROW_ACTION: /* Display mode */ + if (val == 0) videoApplyPreset(VIDEO_PRESET_PORT); + else if (val == 1) videoApplyPreset(VIDEO_PRESET_N64); + break; + default: + rowSet(r, (double)val); + break; + } +} + +/* Popup rectangle in overlay units; false if the owning row is not on screen. */ +static int ddGeom(s32 *x0, s32 *y0, s32 *x1, s32 *y1, int *vis) +{ + if (s_ddRow < 0) return 0; + struct OvLayout o = overlayLayout(); + int p = -1; + for (int k = 0; k < s_visN; k++) if (s_visIdx[k] == s_ddRow) p = k; + if (p < 0) return 0; + s32 rowY = o.contentY + (s32)lround((p - s_scrollF) * OV_LINE); + s32 w = 40; + for (int i = 0; i < s_ddN; i++) { s32 lw = bodyWidth(s_ddLab[i]) + 20; if (lw > w) w = lw; } + if (w > o.right - o.left - 16) w = o.right - o.left - 16; + int v = s_ddN < DD_VIS ? s_ddN : DD_VIS; + s32 h = v * OV_LINE + 2; + *x1 = o.valueR + 3; + *x0 = *x1 - w; + s32 H = ovH(); + *y0 = rowY + OV_LINE; + if (*y0 + h > H - 4) *y0 = rowY - h; /* no room below: open upward */ + *y1 = *y0 + h; + *vis = v; + return 1; +} + +/* Popup item under overlay-space (ox, oy), or -1. */ +static int ddHit(double ox, double oy) +{ + s32 x0, y0, x1, y1; int vis; + if (!ddGeom(&x0, &y0, &x1, &y1, &vis)) return -1; + if (ox < x0 || ox >= x1 || oy < y0 + 1 || oy >= y1 - 1) return -1; + int k = (int)((oy - y0 - 1) / OV_LINE); + return (k >= 0 && k < vis) ? s_ddTop + k : -1; +} + void optionsAdjustCommitPending(void) { int pending = SDL_AtomicSet(&s_adjWatchField, 0); @@ -1533,6 +1854,19 @@ static int isPresetRow(const struct Row *r) return r->kind == ROW_ACTION && strncmp(r->key, "__Preset", 8) == 0; } +/* T4-lite Display mode (no ini key): derived from the D440 preset key tables. + * 0 = Modern (port defaults), 1 = Original N64, 2 = Custom (a bundled key was + * edited; flips back by itself when the keys match a preset again). Arming + * targets "the other one": Modern -> Original N64, anything else -> Modern. */ +static int displayModeNow(void) +{ + if (videoPresetIsActive(VIDEO_PRESET_PORT)) return 0; + if (videoPresetIsActive(VIDEO_PRESET_N64)) return 1; + return 2; +} +static const char *const kDisplayModeName[3] = { "Modern", "Original N64", "Custom" }; + + static int isArmRow(const struct Row *r) { return isResetRow(r) || isPresetRow(r); @@ -1554,6 +1888,7 @@ int optionsRowIsReset(int i) * VIDEO reset. */ static const struct { const char *key; double def; } kResetDefaults[] = { /* INPUT (port/src/input.c initializers) */ + { "Input.MouseEnabled", 1 }, /* = 1 (on) */ { "Input.MouseSensitivity", 100 }, /* static int mouseSensitivity = 100 */ { "Input.MouseYScale", 100 }, /* = 100 (native), Wave A item 6 */ { "Input.MouseInvertY", 0 }, /* = 0 */ @@ -1566,9 +1901,13 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Input.PadLookSensY", 100 }, /* = 100 (native), Wave A */ { "Input.PadLookSmooth", 0 }, /* = 0 (off), Wave A */ { "Input.PadSouthpaw", 0 }, /* = 0 (off), Wave A */ - { "Input.PadTriggerPct", 23 }, /* = 23 */ + { "Input.PadTriggerPct", 25 }, /* = 25 (was 23; rounded, D508) */ { "Input.RumbleScale", 0.5 }, /* = gRumbleScale (0.5f), D401 */ { "Input.CrouchMode", 0 }, /* = 0 (hold) */ + { "Input.MenuPointerMode", 1 }, /* = 1 (direct pointer) */ + { "Input.MouseSmoothing", 0 }, /* = 0 (raw) */ + { "Input.MouseRawInput", 0 }, /* = 0 (off) */ + { "Input.ControlScheme", 0 }, /* = 0 (Modern), D513 */ { "Input.PadPreset", 0 }, /* = 0 (Jinx 1.1), D469 */ /* GRAPHICS (port/src/video.c initializers) */ { "Video.MSAA", 2 }, /* = 2 */ @@ -1579,8 +1918,8 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Video.WidescreenAuto", 1 }, /* = 1 */ { "Video.SafeAreaCrop", 1 }, /* = 1 */ { "Video.AspectMode", 0 }, /* = 0 (Window), D447 */ - { "Video.DrawDistance", 250 }, /* midpoint: 50/100 */ - { "Video.LodDistance", 250 }, /* midpoint: 50/100 */ + { "Video.DrawDistance", 250 }, /* 2.5x */ + { "Video.LodDistance", 250 }, /* 2.5x */ { "Video.CrosshairHide", 0 }, /* = 0 (on, N64) */ { "Video.CrosshairColor", 0 }, /* = 0 (authored red sprite) */ { "Video.CrosshairRed", 255 }, @@ -1588,13 +1927,17 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Video.CrosshairBlue", 255 }, { "Video.CrosshairSize", 100 }, /* = 100% */ { "Video.CrosshairStyle", 0 }, /* = 0 (original) */ + { "Video.CrosshairAlpha", 100 }, /* = 100% (D511) */ + { "Video.CrosshairHealthColor", 0 }, /* = 0 (off, D511) */ /* GAMEPLAY ini rows (port/src/video.c initializers) */ { "Game.SkipIntro", 0 }, /* = 0 */ { "Game.NoHitFlash", 0 }, /* = 0 */ { "Game.AllUnlocked", 0 }, /* = 0 -- D257's "default ON" note was stale */ { "Game.HudScale", 100 }, /* = 100 (D226) */ + { "Game.ScreenShakeIntensity", 1 }, /* = portScreenShakeScale (1.0f), D181 */ /* VIDEO (port/src/video.c initializers; DisplayFPS in overlayConfigInit) */ { "Video.Fullscreen", 0 }, /* = 0 (windowed) */ + { "Video.FullscreenMode", 0 }, /* = 0 (borderless, D511) */ { "Video.VSync", 1 }, /* = 1 (on) */ { "Video.FpsCap", 60 }, /* = 60 */ { "Video.DisplayFPS", 0 }, /* registered 0 */ @@ -1634,6 +1977,7 @@ static void rowResetSection(int iReset) struct Row *r = &rows[j]; if (r->kind == ROW_ACTION) continue; /* reset rows, Quit */ if (strcmp(r->key, "__Resolution") == 0) continue; /* documented exclusion */ + if (strcmp(r->key, "Bond.Control") == 0) continue; /* D516: the save's style is not a port setting; reset leaves it */ if (r->kind == ROW_BIND) { nBinds += inputBindingResetKey(r->key); r->bindSlot = 0; @@ -1678,8 +2022,7 @@ static void rowActivateReset(struct Row *r) /* D440: ini keys only (live-applied; persisted on * close like any ini edit). Never window/resolution, bindings, * volumes or save-file rows. */ - videoApplyPreset(strcmp(r->key, "__PresetN64") == 0 - ? VIDEO_PRESET_N64 : VIDEO_PRESET_PORT); + videoApplyPreset(VIDEO_PRESET_PORT); return; } rowResetSection(i); /* exactly one commit per confirmed activation */ @@ -1717,36 +2060,73 @@ void optionsResetClear(void) s_resetArmedRow = -1; } -/* D440 GE_PRESETPROBE (dev, opt-in, one-shot at overlay init): - * drives both preset rows through the real arm -> confirm activation - * (rowActivateReset, the path F10 and the front screen use) and checks that - * the row is "Active" afterwards and the other preset is not. Mutates the - * live config like a real press -- use a throwaway data dir. */ +/* D440 GE_PRESETPROBE (dev, opt-in, one-shot at overlay init), T4-lite form: + * drives the Display mode row through the real arm -> confirm activation + * (rowActivateReset, the path F10 and the front screen use): Modern -> Original + * N64 -> edit a bundled key -> Custom -> Modern, checking the derived value text + * at each step. Mutates the live config like a real press -- use a throwaway + * data dir. */ static void valueText(int i, char *out, int n); static void presetProbe(void) { - static const struct { const char *key; int which; } kRows[] = { - { "__PresetN64", VIDEO_PRESET_N64 }, { "__PresetPort", VIDEO_PRESET_PORT }, - }; int bad = 0; - for (int k = 0; k < 2; k++) { - int i = rowIndexByKey(kRows[k].key); - char val[32] = ""; - if (i < 0 || !optionsRowIsReset(i)) { bad++; continue; } - optionsRowActivateReset(i); /* arm */ + int i = rowIndexByKey("__DisplayMode"); + char val[32] = ""; + if (i < 0) { bad++; } else { + if (displayModeNow() != 0) rowAdjust(&rows[i], +1); /* start from Modern whatever the ini held */ valueText(i, val, sizeof(val)); - if (strcmp(val, "Confirm") != 0) bad++; - optionsRowActivateReset(i); /* confirm -> apply */ + if (strcmp(val, "Modern") != 0) bad++; + rowAdjust(&rows[i], +1); /* Modern -> Original N64 */ valueText(i, val, sizeof(val)); - if (!videoPresetIsActive(kRows[k].which) || strcmp(val, "Active") != 0) bad++; - if (videoPresetIsActive(!kRows[k].which)) bad++; - sysLogPrintf(LOG_INFO, "GE_PRESETPROBE: %s -> value '%s', bad=%d", kRows[k].key, val, bad); + if (strcmp(val, "Original N64") != 0 || !videoPresetIsActive(VIDEO_PRESET_N64)) bad++; + int tf = rowIndexByKey("Video.TextureFilter"); /* edit one bundled key */ + if (tf >= 0) rowAdjust(&rows[tf], +1); + valueText(i, val, sizeof(val)); + if (strcmp(val, "Custom") != 0) bad++; + ddOpen(i); /* dropdown path: pick Modern */ + if (s_ddRow != i || s_ddN != 3 || s_ddCur != 2) bad++; + ddConfirm(0); + valueText(i, val, sizeof(val)); + if (strcmp(val, "Modern") != 0 || !videoPresetIsActive(VIDEO_PRESET_PORT)) bad++; + sysLogPrintf(LOG_INFO, "GE_PRESETPROBE: Modern -> N64 -> edit -> Custom -> (dropdown) Modern, last '%s', bad=%d", val, bad); } sysLogPrintf(bad ? LOG_ERROR : LOG_INFO, "GE_PRESETPROBE: %s", bad ? "FAIL" : "PASS"); } /* ------------------------------------------------------------------------ */ +/* GE_OVNAV test step (called from the file-select hook, game thread): + * O toggle, D/U move, A open header/link, B back one level, P log the place. */ +static SDL_SpinLock s_ovLock; /* tentative: the definition below is the same object */ +void optionsOverlayTestStep(char c) +{ + SDL_AtomicLock(&s_ovLock); + if (c == 'O') { + SDL_AtomicUnlock(&s_ovLock); + optionsOverlayToggle(); + return; + } + if (!s_open) { SDL_AtomicUnlock(&s_ovLock); return; } + overlayUpdateVisible(); + if (c == 'D') s_sel = overlayStepSel(s_sel, 1); + else if (c == 'U') s_sel = overlayStepSel(s_sel, -1); + else if (c == 'A' && s_sel >= 0 && s_sel < s_visN) { + int i = s_visIdx[s_sel]; + if (rows[i].kind == ROW_HEADER) { if (s_section < 0) overlayOpenHeader(i); } + else if (optionsRowChildHeader(i) >= 0) overlayOpenLink(i); + } else if (c == 'B') { if (s_section >= 0) overlayBackOne(); } + overlayUpdateVisible(); + overlayUpdateScroll(); + if (c == 'P') { + sysLogPrintf(LOG_INFO, "ovnav: section=%s sel=%s scroll=%d seat=%d vis=%d", + s_section >= 0 ? rows[s_section].key : "(root)", + (s_sel >= 0 && s_sel < s_visN) ? rows[s_visIdx[s_sel]].key : "-", s_scroll, s_padSeat, s_visN); + for (int q = 0; q < s_visN; q++) + if (rows[s_visIdx[q]].kind == ROW_BOND_FILE) sysLogPrintf(LOG_INFO, "ovnav: chooser row visible at %d", q); + } + SDL_AtomicUnlock(&s_ovLock); +} + void optionsOverlayToggle(void) { overlayInit(); @@ -1755,11 +2135,42 @@ void optionsOverlayToggle(void) int pending = SDL_AtomicSet(&s_dragWatchField, 0); if (pending > 0) watchSettingsCommit(pending - 1); optionsAdjustCommitPending(); /* D489 */ + overlayUpdateVisible(); + s_memValid = 1; + s_memSec = s_section; + s_memRow = (s_sel >= 0 && s_sel < s_visN) ? s_visIdx[s_sel] : -1; + s_memScroll = s_scroll; + s_memSeat = s_padSeat; } s_open = !s_open; + ddClose(); /* closing (or reopening) the menu cancels a pending pick */ if (s_open) { s_section = -1; s_sel = s_scroll = 0; + s_scrollSnap = 1; + if (s_memValid && s_memSec >= 0 && s_memSec < NUM_ROWS && rows[s_memSec].kind == ROW_HEADER) { + int seat = s_padSeat; + s_section = s_memSec; + if (s_memSeat >= 0 && s_memSeat < 4) padSetSeat(s_memSeat); + overlayUpdateVisible(); + if (s_visN <= 1) { /* nothing shown there in this context: root */ + s_section = -1; + padSetSeat(seat); + overlayUpdateVisible(); + } + } else if (s_memValid) { + overlayUpdateVisible(); + } + if (s_memValid && s_memRow >= 0) { + int first = s_section >= 0 ? 1 : 0, hit = -1; + for (int q = first; q < s_visN; q++) if (s_visIdx[q] == s_memRow) { hit = q; break; } + if (hit >= 0) { s_sel = hit; s_scroll = s_memScroll < 0 ? 0 : s_memScroll; } + else s_sel = first; /* row not shown here: top of its section */ + overlayUpdateScroll(); + } + s_mouseActive = 0; + s_hover = -1; + s_seedPrev = 1; } SDL_AtomicSet(&s_backPending, 0); sysLogPrintf(LOG_INFO, "optionsoverlay: %s", s_open ? "opened" : "closed"); @@ -1805,7 +2216,10 @@ void optionsOverlayScroll(int dir) static void overlayApplyWheel(void) { int n = SDL_AtomicSet(&s_wheelPending, 0); + if (n != 0) s_mouseActive = 0; /* the wheel moves the selection: cue follows it */ while (n != 0) { + if (s_ddRow >= 0) ddMove((n > 0) ? -1 : 1); + else s_sel = overlayStepSel(s_sel, (n > 0) ? -1 : 1); n += (n > 0) ? -1 : 1; } @@ -1906,7 +2320,9 @@ static void overlayHandleInputLocked(void) * leaves the armed row; the overlay's selection is s_visIdx[s_sel]. */ overlayUpdateVisible(); if (SDL_AtomicSet(&s_backPending, 0)) { - if (s_section >= 0) { + if (s_ddRow >= 0) { + ddClose(); /* Esc cancels the pick, not the page */ + } else if (s_section >= 0) { overlayBackOne(); } else { optionsOverlayToggle(); @@ -1945,7 +2361,12 @@ static void overlayHandleInputLocked(void) inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_A) || inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_X); int rt = rightNav || accept; - int padBack = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B); + int padBack = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B) || ks[SDL_SCANCODE_BACKSPACE]; /* QoL: Backspace goes back like B */ + if (s_seedPrev) { /* D519: whatever opened the overlay (A / Enter / click) is still held: not a press */ + s_seedPrev = 0; + prevUp = up; prevDn = dn; prevLf = lf; prevRt = rt; + prevLmb = lmb; prevRmb = rmb; prevPadBack = padBack; + } static int prevStart = 0; /* not reset while closed: a Start held across close must not re-close on the next open */ @@ -1960,7 +2381,8 @@ static void overlayHandleInputLocked(void) prevPadBack = padBack; navDir = adjDir = 0; prevUp = up; prevDn = dn; prevLf = lf; prevRt = rt; - if (s_section >= 0) overlayBackOne(); + if (s_ddRow >= 0) ddClose(); + else if (s_section >= 0) overlayBackOne(); else optionsOverlayToggle(); return; } @@ -1986,13 +2408,16 @@ static void overlayHandleInputLocked(void) { int vdir = (dn && !prevDn) ? +1 : (up && !prevUp) ? -1 : 0; if (vdir != 0) { + s_mouseActive = 0; navDir = vdir; navTimer = 18; - s_sel = overlayStepSel(s_sel, vdir); /* skips headers */ + if (s_ddRow >= 0) ddMove(vdir); + else s_sel = overlayStepSel(s_sel, vdir); /* skips headers */ } else if ((up || dn) && navDir != 0) { if (--navTimer <= 0) { navTimer = 4; - s_sel = overlayStepSel(s_sel, navDir); + if (s_ddRow >= 0) ddMove(navDir); + else s_sel = overlayStepSel(s_sel, navDir); } } else { navDir = 0; @@ -2003,18 +2428,26 @@ static void overlayHandleInputLocked(void) { int dir = (rt && !prevRt) ? +1 : (lf && !prevLf) ? -1 : 0; if (dir != 0) { + s_mouseActive = 0; adjDir = dir; adjTimer = 18; - if (s_section < 0) { - overlayOpenHeader(s_visIdx[s_sel]); + if (s_ddRow >= 0) { + if (accept && !(dir < 0)) ddConfirm(s_ddSel); /* Enter/A picks; left/right do nothing while open */ adjDir = 0; - } else if (s_sel == 0) { - overlayBackOne(); + } else if (s_section < 0) { + struct Row *hr = &rows[s_visIdx[s_sel]]; + if (hr->kind == ROW_HEADER) + overlayOpenHeader(s_visIdx[s_sel]); + else if (dir > 0 && !isSepRow(hr)) + rowAdjust(hr, +1); /* hub actions: Restart game / Quit to desktop */ adjDir = 0; } else { int child = optionsRowChildHeader(s_visIdx[s_sel]); if (child >= 0) { - if (dir > 0) overlayOpenHeader(child); + if (dir > 0) overlayOpenLink(s_visIdx[s_sel]); + adjDir = 0; + } else if (isBackRow(&rows[s_visIdx[s_sel]])) { + if (dir > 0) overlayBackOne(); /* Back row: activate only */ adjDir = 0; } else { struct Row *r = &rows[s_visIdx[s_sel]]; @@ -2029,6 +2462,9 @@ static void overlayHandleInputLocked(void) /* Pad accept does nothing on keyboard-only binding rows; * D-pad/stick right still changes the displayed slot. */ adjDir = 0; + } else if (rowIsDropdown(r) && dir > 0 && accept && !rightNav) { + ddOpen(s_visIdx[s_sel]); /* Enter/A opens the popup; left/right still step */ + adjDir = 0; } else if (r->kind == ROW_SLIDER && dir > 0 && accept && !rightNav) { /* Accept never moves a slider; left/right own detents. */ adjDir = 0; @@ -2038,7 +2474,7 @@ static void overlayHandleInputLocked(void) } } } - } else if ((rt || lf) && adjDir != 0) { + } else if ((rt || lf) && adjDir != 0 && s_ddRow < 0) { if (--adjTimer <= 0) { adjTimer = 4; /* D356: reset actions are edge-triggered -- the held-repeat @@ -2066,17 +2502,32 @@ static void overlayHandleInputLocked(void) int32_t rx = 0, ry = 0, rw = 0, rh = 0; gfx_get_ui_screen_rect(&rx, &ry, &rw, &rh); if (rw > 0 && rh > 0) { - double ox = (double)(mx - rx) * (double)viGetX() / rw; + double ox = (double)(mx - rx) * (double)ovW() / rw; { /* D335b/D472: the card is drawn into a centred 4:3 region under native widescreen */ f32 na = portNativeAspect(); if (na > 1.3334f) { double vis = (4.0 / 3.0) / (double)na; double fx = (double)(mx - rx) / rw; - ox = ((fx - (1.0 - vis) * 0.5) / vis) * (double)viGetX(); + ox = ((fx - (1.0 - vis) * 0.5) / vis) * (double)ovW(); } } - double oy = (double)(my - ry) * (double)viGetY() / rh; + double oy = (double)(my - ry) * (double)ovH() / rh; int hoverVis = overlayRowAtY(ox, oy); + { /* R2: hover gets the focus cue, never the selection (D304 feedback loop) */ + static int prevMx = -1, prevMy = -1; + if (prevMx < 0) { prevMx = mx; prevMy = my; } + int moved = (mx != prevMx || my != prevMy); + if (moved || (lmb && !prevLmb) || (rmb && !prevRmb)) s_mouseActive = 1; + prevMx = mx; prevMy = my; + s_hover = s_mouseActive ? hoverVis : -1; + if (s_ddRow >= 0) { /* open dropdown owns the pointer: hover picks, click confirms, outside/right cancels */ + int hit = ddHit(ox, oy); + if (hit >= 0 && moved) s_ddSel = hit; + if (lmb && !prevLmb) { if (hit >= 0) ddConfirm(hit); else ddClose(); } + else if (rmb && !prevRmb) ddClose(); + hoverVis = -1; s_hover = -1; + } + } /* Opt-in diagnostic for D391: distinguish hit-band/scroll errors * from logical SDL mouse coords vs GL drawable pixel coords. */ static int pointerLog = -1, pointerSamples = 0; @@ -2096,8 +2547,6 @@ static void overlayHandleInputLocked(void) hoverVis, hoverVis >= 0 ? rows[s_visIdx[hoverVis]].key : "-", lmb && !prevLmb); } - int onClose = overlayInCloseBox(ox, oy); - /* No hover-to-highlight: merely moving the mouse must not move the * selection or scroll the window (hovering near a list edge fed the * new row back into the cursor and made the bottom twitch/echo). @@ -2106,18 +2555,15 @@ static void overlayHandleInputLocked(void) /* 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) { + if (hoverVis >= 0 && !isSepRow(&rows[s_visIdx[hoverVis]])) { s_sel = hoverVis; /* explicit click -> select */ overlayUpdateScroll(); if (rows[s_visIdx[hoverVis]].kind == ROW_HEADER) { if (s_section < 0) overlayOpenHeader(s_visIdx[hoverVis]); - else overlayBackOne(); } else if (optionsRowChildHeader(s_visIdx[hoverVis]) >= 0) { - overlayOpenHeader(optionsRowChildHeader(s_visIdx[hoverVis])); + overlayOpenLink(s_visIdx[hoverVis]); + } else if (isBackRow(&rows[s_visIdx[hoverVis]])) { + overlayBackOne(); } else { int i = s_visIdx[hoverVis]; s32 x0, x1; @@ -2132,7 +2578,8 @@ static void overlayHandleInputLocked(void) } else if (r->kind == ROW_BIND || r->kind == ROW_PADBIND) { bindingBegin(r); /* mouse selects slot via arrows; click captures */ } else { - rowAdjust(r, +1); /* toggle / cycle forward (wraps) */ + if (rowIsDropdown(r)) ddOpen(i); + else rowAdjust(r, +1); /* toggle / cycle forward (wraps) */ } } } @@ -2151,7 +2598,7 @@ static void overlayHandleInputLocked(void) SDL_AtomicSet(&s_dragWatchField, 0); } /* Right press in the same change region: cycle back. */ - if (rmb && !prevRmb && hoverVis >= 0 && !onClose && s_section >= 0) { + if (rmb && !prevRmb && hoverVis >= 0 && s_section >= 0) { int i = s_visIdx[hoverVis]; s32 x0, x1; if (!isArmRow(&rows[i]) && overlayControlSpan(i, &x0, &x1) && @@ -2169,7 +2616,7 @@ static void overlayHandleInputLocked(void) /* ------------------------------------------------------------------------ */ -#define OV_BUF_CMDS 8192 +#define OV_BUF_CMDS 16384 static Gfx s_buf[OV_BUF_CMDS]; static Gfx *fillRect(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, @@ -2192,6 +2639,11 @@ static void valueText(int i, char *out, int n) else snprintf(out, n, "%d", f + 1); return; } + if (watchSettingsFieldForKey(r->key) == WATCH_SETTING_CONTROL) { /* D516 */ + if (v < 0) snprintf(out, n, "(in a mission)"); + else snprintf(out, n, "%s", kOrigStyle[v > 7 ? 7 : (int)v]); + return; + } if (watchSettingsFieldForKey(r->key) >= 0 && !watchSettingsAvailable()) { snprintf(out, n, "Select a profile"); return; @@ -2200,15 +2652,23 @@ static void valueText(int i, char *out, int n) if (videoIsFullscreen()) { snprintf(out, n, "(fullscreen)"); } else if (s_resFitN <= 0) { - snprintf(out, n, "n/a"); + snprintf(out, n, "(n/a)"); } else { int i2 = s_resFit[s_resSel]; snprintf(out, n, "%d x %d", kResList[i2][0], kResList[i2][1]); } return; } + if (r->kind == ROW_ACTION && !strcmp(r->key, "__DisplayMode")) { + snprintf(out, n, "%s", kDisplayModeName[displayModeNow()]); + return; + } if (r->kind == ROW_ACTION) { - if (optionsRowChildHeader(i) >= 0) { + if (isSepRow(r) || isBackRow(r)) { + if (n > 0) out[0] = 0; + } else if (!strcmp(r->key, "__RestartGame")) { + if (n > 0) out[0] = 0; /* was a sentence fragment; the "(restart)" tag explains itself */ + } else if (optionsRowChildHeader(i) >= 0) { /* PD's selectable submenu rows use a title such as "Stick * Settings...", not a second "Open >" value. The link label * above carries that same affordance on both GE PC menus. */ @@ -2220,11 +2680,9 @@ static void valueText(int i, char *out, int n) } else if (isPresetRow(r)) { /* D440: same arm/confirm cue, plus "Active" while every * preset key already holds that preset's value. */ - int which = strcmp(r->key, "__PresetN64") == 0 ? VIDEO_PRESET_N64 : VIDEO_PRESET_PORT; - snprintf(out, n, "%s", s_resetArmedRow == i ? "Confirm" : - videoPresetIsActive(which) ? "Active" : ""); + snprintf(out, n, "%s", s_resetArmedRow == i ? "Confirm" : ""); } else { - snprintf(out, n, "[ENTER]"); + if (n > 0) out[0] = 0; } return; } @@ -2241,7 +2699,7 @@ static void valueText(int i, char *out, int n) } if (r->kind == ROW_PADBIND) { if (optionsBindingCaptureActive() && i == s_bindCaptureRow) { - snprintf(out, n, "Press button..."); + snprintf(out, n, "Press a button..."); } else { static const char *const kSlotName[INPUT_BIND_SLOTS] = { "Primary", "Secondary" }; char nm[32]; @@ -2264,7 +2722,7 @@ static void valueText(int i, char *out, int n) } if (r->kind == ROW_BIND) { if (optionsBindingCaptureActive() && i == s_bindCaptureRow) - snprintf(out, n, "Key/mouse..."); + snprintf(out, n, "Press a key..."); else if (s_bindError && i == s_bindCaptureRow) snprintf(out, n, "Binding too long"); else { @@ -2298,6 +2756,10 @@ static void valueText(int i, char *out, int n) snprintf(out, n, "%.1fx", v / 100.0); return; } + if (!strcmp(r->key, "Game.ScreenShakeIntensity")) { /* D181: shown as whole % */ + snprintf(out, n, "%d%%", (int)lround(v * 100.0)); + return; + } if (!strcmp(r->key, "Input.RumbleScale")) { /* D357: whole %, like deadzone/volume; storage stays 0..1 */ snprintf(out, n, "%d%%", (int)lround(v * 100.0)); return; @@ -2312,29 +2774,33 @@ 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)); + /* D506: multipliers (raw 100 = 1.0x): vertical mouse and controller look sensitivity. */ + if (!strcmp(r->key, "Input.MouseYScale") || !strcmp(r->key, "Input.PadLookSensX") || + !strcmp(r->key, "Input.PadLookSensY")) { + snprintf(out, n, "%.1fx", v / 100.0); return; } - /* Wave A: integer "N/dispMax" knobs (e.g. look smoothing 0-10 -> "5/10"). */ - if (r->dispMax > 0) { - snprintf(out, n, "%d/%d", (int)lround(v), r->dispMax); + /* D506: controller look smoothing, raw 0-10 -> 0..100 %. */ + if (!strcmp(r->key, "Input.PadLookSmooth")) { + snprintf(out, n, "%d%%", (int)lround(v) * 10); return; } - /* Distance sliders are normalized to a 0..100 UI while retaining the - * original percent-based ini format (250% = 50/100). */ + /* D506: draw / LOD distance are multiples of the authored distance + * (ini 100..800 %): 2.5x by default. */ 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); + char t[16]; + snprintf(t, sizeof(t), "%.2f", v / 100.0); + size_t tl = strlen(t); + if (tl > 1 && t[tl - 1] == '0') t[tl - 1] = 0; /* 2.50 -> 2.5, 2.75 stays */ + snprintf(out, n, "%sx", t); return; } - /* RGB channels are 8-bit values; show their endpoint in both UIs. */ + /* D506: crosshair RGB channels as a percent of full intensity. */ if (strcmp(r->key, "Video.CrosshairRed") == 0 || strcmp(r->key, "Video.CrosshairGreen") == 0 || strcmp(r->key, "Video.CrosshairBlue") == 0) { - snprintf(out, n, "%d/255", (int)lround(v)); + snprintf(out, n, "%d%%", (int)lround(v * 100.0 / 255.0)); return; } /* D346: integer sliders -- optional raw->display divide + unit suffix. */ @@ -2357,7 +2823,7 @@ static Gfx *drawText(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour) * (baseline+height > measured h => nothing drawn). Match the game: pass * the full 2D viewport, like bondview2.c's debug-text path. */ return textRender(gdl, &px, &py, (char *)str, ptrFontBankGothicChars, - ptrFontBankGothic, colour, viGetX(), viGetY(), 0, 0); + ptrFontBankGothic, colour, ovW(), ovH(), 0, 0); } static s32 measureText(const char *str) @@ -2375,19 +2841,33 @@ static Gfx *drawTextR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 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 struct font *bodyFont(void) +{ + /* R5: Zurich Bold is GE's only mixed-case face; Bank Gothic (caps-only) is the fallback. */ + return ptrFontZurichBold && ptrFontZurichBoldChars ? ptrFontZurichBold : ptrFontBankGothic; +} +static struct fontchar *bodyChars(void) +{ + return ptrFontZurichBold && ptrFontZurichBoldChars ? ptrFontZurichBoldChars : ptrFontBankGothicChars; +} static s32 bodyWidth(const char *str) { s32 h = 0, w = 0; + if (!bodyFont() || !bodyChars()) return (s32)strlen(str) * 5; /* fonts not loaded yet (early poll tick) */ textMeasure(&h, &w, (char *)str, bodyChars(), bodyFont(), 0); - return w; + return w * OV_TEXT_PCT / 100; } +/* Zurich Bold is ~12 units tall, too big for PD-sized rows: each string is drawn + * through the HUD span scale about its own origin (fast3d G_HUDSCALE_EXT), so + * bodyWidth() reports the scaled width. */ 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); + gSPHudScaleEXT(gdl++, OV_TEXT_PCT * 256 / 100, x * 4, y * 4); + gdl = textRender(gdl, &px, &py, (char *)str, bodyChars(), bodyFont(), + colour, ovW(), ovH(), 0, 0); + gSPHudScaleEXT(gdl++, 256, 0, 0); + return gdl; } static Gfx *drawBodyR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour) { @@ -2402,10 +2882,10 @@ 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)) + if (r->kind == ROW_HEADER || (r->kind == ROW_SLIDER && !r->found) || isSepRow(r)) return 0; *x1 = o.right - 7; - if (isArmRow(r)) { + if (isArmRow(r) || isBackRow(r) || r->hub) { *x0 = o.left + 7; /* reset/preset is an action, not a setting name */ } else { valueText(i, val, sizeof(val)); @@ -2417,29 +2897,6 @@ static int overlayControlSpan(int i, s32 *x0, s32 *x1) } 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; @@ -2452,23 +2909,193 @@ static int sectionIsMixedScope(int header) return scoped && global; } -static Gfx *drawBodyFit(Gfx *gdl, s32 x, s32 y, s32 maxW, const char *str, u32 colour) +/* ---- menu PD parity: palette + drawing helpers ---- + * Palette = PD's own green dialog colours (g_MenuColours[], src/game/menu.c:110, + * PD is MIT, Ryan Dwyer). RGBA, alpha as PD stores it (0x7f = ~50 %). */ +#define PD_DIALOG_BORDER1 0x00bf007fu /* left/lower border, title gradient start */ +#define PD_TITLEBG 0x0050007fu /* title gradient middle */ +#define PD_DIALOG_BORDER2 0x00ff007fu /* right border, title gradient end */ +#define PD_TITLE_TEXT 0xffff00ffu /* yellow */ +#define PD_BODY 0x002f00c8u /* PD stores 0x7f; raised after the first look (maintainer: easier to read) */ +#define PD_ITEM_UNFOCUSED 0x55ff55ffu +#define PD_ITEM_DISABLED 0x006f00afu +#define PD_FOCUS_INNER 0xffffffffu +#define PD_FOCUS_OUTER 0x004400ffu +#define PD_EXP_UNFOCUSED 0xd05a50ffu /* D472 EXPERIMENTAL rows (red), same weight as unfocused green */ +#define PD_EXP_FOCUSED 0xff8a80ffu +static const u8 PAL_TITLE_L[3] = { 0, 191, 0 }; /* border1 */ +static const u8 PAL_TITLE_M[3] = { 0, 80, 0 }; /* titlebg */ +static const u8 PAL_TITLE_R[3] = { 0, 255, 0 }; /* border2 */ + +static u8 lerpU8(int a, int b, int num, int den) { - 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 (u8)(a + (b - a) * num / den); +} + +static Gfx *fillRectC(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, u32 rgba) +{ + return fillRect(gdl, x0, y0, x1, y1, (u8)(rgba >> 24), (u8)(rgba >> 16), (u8)(rgba >> 8), (u8)rgba); +} + +/* Horizontal 3-stop gradient (PD title strip), drawn as thin slices. */ +static Gfx *fillGradH3(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, + const u8 *c0, const u8 *cm, const u8 *c1, u8 a) +{ + s32 w = x1 - x0; + if (w <= 0) return gdl; + s32 n = (w + 5) / 6; + if (n > 64) n = 64; + for (s32 k = 0; k < n; k++) { + s32 sx0 = x0 + w * k / n, sx1 = x0 + w * (k + 1) / n; + int t = (int)(2 * k + 1), d = (int)(2 * n); /* slice centre, 0..1 */ + u8 r, g, b; + if (2 * t < d) { + r = lerpU8(c0[0], cm[0], 2 * t, d); g = lerpU8(c0[1], cm[1], 2 * t, d); b = lerpU8(c0[2], cm[2], 2 * t, d); + } else { + r = lerpU8(cm[0], c1[0], 2 * t - d, d); g = lerpU8(cm[1], c1[1], 2 * t - d, d); b = lerpU8(cm[2], c1[2], 2 * t - d, d); } + gdl = fillRect(gdl, sx0, y0, sx1, y1, r, g, b, a); } - return drawBody(gdl, x, y, out, colour); + return gdl; +} + +/* PD menugfxDrawShimmer: a 40-unit-wide soft streak sweeping along a bar edge. + * Wall-clock driven (render-rate independent); clipped to [x0, x1). */ +static Gfx *drawShimmer(Gfx *gdl, s32 x0, s32 x1, s32 y0, s32 y1, int periodMs, int phaseMs) +{ + s32 span = (x1 - x0) + 80; + s32 head = (s32)(((sysGetMicroseconds() / 1000 + (uint64_t)phaseMs) % (uint64_t)periodMs) * (uint64_t)span / (uint64_t)periodMs) - 40; + for (int k = 0; k < 10; k++) { + s32 sx0 = x0 + head + k * 4, sx1 = sx0 + 4; + int d = k < 5 ? k : 9 - k; /* triangle 0..4..0 */ + if (sx1 <= x0 || sx0 >= x1) continue; + if (sx0 < x0) sx0 = x0; + if (sx1 > x1) sx1 = x1; + gdl = fillRect(gdl, sx0, y0, sx1, y1, 200, 255, 200, (u8)(24 + d * 22)); + } + return gdl; +} + +/* PD focused-item colour: the unfocused green pulses toward white + * (menuGetSinOscFrac(40): 40-tick period = 2/3 s). Wall-clock cosine. */ +static u32 pulseInk(void) +{ + int ph = (int)((sysGetMicroseconds() / 1000) % 667); /* 0..666 ms */ + int w = ph < 333 ? ph : 667 - ph; /* 0..333..0 */ + int t = w * 1000 / 333; /* 0..1000 */ + t = t * t * (3000 - 2 * t) / 1000000; /* smoothstep */ + u8 r = lerpU8(0x55, 0xff, t, 1000), g = lerpU8(0xff, 0xff, t, 1000), b = lerpU8(0x55, 0xff, t, 1000); + return ((u32)r << 24) | ((u32)g << 16) | ((u32)b << 8) | 0xffu; +} + +/* "KEY BINDINGS" -> "Key Bindings"; digits/punctuation/parentheses untouched. */ +static void titleCase(const char *in, char *out, int n) +{ + int i = 0, startWord = 1, lower = 0; + for (const char *q = in; *q; q++) lower |= (*q >= 'a' && *q <= 'z'); + if (lower) { snprintf(out, n, "%s", in); return; } /* already mixed case ("PC Options", "Quit to desktop") */ + for (; in[i] && i < n - 1; i++) { + char c = in[i]; + if (c >= 'A' && c <= 'Z') { if (!startWord) c = (char)(c - 'A' + 'a'); } + else if (c >= 'a' && c <= 'z') { if (startWord) c = (char)(c - 'a' + 'A'); } + startWord = (c == ' ' || c == '('); + out[i] = c; + } + out[i] = 0; +} + +/* PD textRenderProjected focus glow: the label drawn once per axis neighbour in + * the dark outline colour under the real text. */ +static Gfx *drawBodyOutlined(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour) +{ + gdl = drawBody(gdl, x - 1, y, str, PD_FOCUS_OUTER); + gdl = drawBody(gdl, x + 1, y, str, PD_FOCUS_OUTER); + gdl = drawBody(gdl, x, y - 1, str, PD_FOCUS_OUTER); + gdl = drawBody(gdl, x, y + 1, str, PD_FOCUS_OUTER); + return drawBody(gdl, x, y, str, colour); +} + +/* PD checkbox (menugfxDrawCheckbox): a square at the row's right edge, outlined + * in the item colour, filled when on. */ +static Gfx *drawCheckbox(Gfx *gdl, s32 xr, s32 rowY, int on, u32 ink) +{ + s32 x0 = xr - 9, y0 = rowY + 2, x1 = xr, y1 = rowY + 11; + gdl = fillRectC(gdl, x0, y0, x1, y0 + 1, ink); + gdl = fillRectC(gdl, x0, y1 - 1, x1, y1, ink); + gdl = fillRectC(gdl, x0, y0 + 1, x0 + 1, y1 - 1, ink); + gdl = fillRectC(gdl, x1 - 1, y0 + 1, x1, y1 - 1, ink); + if (on) gdl = fillRectC(gdl, x0 + 2, y0 + 2, x1 - 2, y1 - 2, ink); + return gdl; +} + +/* D520: antialiased overlay shapes. The overlay lays out on a 320-wide canvas + * scaled to the window (ovScale), so rect-built diagonals step in whole canvas + * pixels that grow with the window; PD draws these as triangles that MSAA + * smooths. Here each edge pixel gets its coverage as alpha instead. One + * render-state setup per shape, then 2 commands per piece (DL budget). */ +static Gfx *aaBegin(Gfx *gdl) +{ + gDPSetRenderMode(gdl++, G_RM_XLU_SURF, G_RM_XLU_SURF2); + gDPSetCombineMode(gdl++, G_CC_PRIMITIVE, G_CC_PRIMITIVE); + return gdl; +} + +static Gfx *aaPiece(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, u32 rgba, double cov) +{ + int a = (int)((rgba & 0xff) * cov + 0.5); + if (a < 6 || x1 <= x0 || y1 <= y0) return gdl; + gDPSetPrimColor(gdl++, 0, 0, (u8)(rgba >> 24), (u8)(rgba >> 16), (u8)(rgba >> 8), (u8)(a > 255 ? 255 : a)); + gDPFillRectangle(gdl++, x0, y0, x1, y1); + return gdl; +} + +/* Downward triangle: top edge `w` canvas px wide centred on cx at y0, apex + * at y0 + rows. Per-pixel area coverage from 4 sub-rows. */ +static Gfx *drawTriDownAA(Gfx *gdl, double cx, s32 y0, s32 rows, double w, u32 ink) +{ + gdl = aaBegin(gdl); + for (s32 r = 0; r < rows; r++) { + double cov[16] = {0}; + s32 px0 = (s32)(cx - w / 2) - 1; + for (int s = 0; s < 4; s++) { + double half = w / 2 * (1.0 - (r + (s + 0.5) / 4.0) / rows); + double l = cx - half, rr = cx + half; + for (int k = 0; k < 16; k++) { + double a = px0 + k, b = a + 1; + double o = (rr < b ? rr : b) - (l > a ? l : a); + if (o > 0) cov[k] += o / 4.0; + } + } + for (int k = 0; k < 16; k++) + gdl = aaPiece(gdl, px0 + k, y0 + r, px0 + k + 1, y0 + r + 1, ink, cov[k]); + } + return gdl; +} + +/* PD slider (menugfxRenderSlider): a ramp-triangle track rising to the right, + * the passed part bright, plus a small triangle marker under the track. + * D520: solid body as runs of equal whole height, plus a coverage-alpha top + * pixel per column, so the ramp is smooth at any window size. */ +static Gfx *drawWedge(Gfx *gdl, s32 x0, s32 x1, s32 baseY, double f, u32 ink) +{ + const s32 H = 8; + s32 span = x1 - x0; + if (span < 8) return gdl; + s32 mx = x0 + (s32)(span * f); + gdl = aaBegin(gdl); + s32 runX = x0, runH = -1; + u32 runC = 0; + for (s32 x = x0; x <= x1; x++) { + double h = 1.0 + (H - 1) * ((x - x0) + 0.5) / span; + s32 hi = (s32)h; + u32 c = x < mx ? ink : PD_ITEM_DISABLED; + if (x == x1 || hi != runH || c != runC) { /* flush the solid run */ + if (runH > 0) gdl = aaPiece(gdl, runX, baseY - runH, x, baseY, runC, 1.0); + runX = x; runH = hi; runC = c; + } + if (x < x1) gdl = aaPiece(gdl, x, baseY - hi - 1, x + 1, baseY - hi, c, h - hi); + } + return drawTriDownAA(gdl, mx + 0.5, baseY + 1, 3, 6.0, PD_FOCUS_INNER); } static Gfx *overlayEmitLocked(void); @@ -2493,13 +3120,14 @@ static Gfx *overlayEmitLocked(void) return NULL; /* nothing appended -> golden dumps byte-identical */ } /* D213: FPS-only mini DL (top-right), panel closed. */ - const s32 fw = viGetX(); - const s32 fh = viGetY(); + const s32 fw = ovW(); + const s32 fh = ovH(); + gfx_set_overlay_scale(ovScale()); /* D510 */ Gfx *fgdl = s_buf; gDPPipeSync(fgdl++); gDPSetCycleType(fgdl++, G_CYC_1CYCLE); gDPSetTexturePersp(fgdl++, G_TP_NONE); - gDPSetScissor(fgdl++, G_SC_NON_INTERLACE, 0, 0, fw, fh); + gDPSetScissor(fgdl++, G_SC_NON_INTERLACE, 0, 0, viGetX(), viGetY()); fgdl = microcode_constructor(fgdl); fgdl = drawTextR(fgdl, fw - 6, 6, s_fpsText, 0x40ff60ff); gDPPipeSync(fgdl++); @@ -2509,217 +3137,294 @@ static Gfx *overlayEmitLocked(void) overlayUpdateVisible(); overlayUpdateScroll(); + if (s_scrollSnap) { + s_scrollF = s_scroll; + s_scrollSnap = 0; + } else { /* PD SMOOTHSCROLLABLE: ease toward the target row */ + double d = (double)s_scroll - s_scrollF; + s_scrollF = (d > -0.02 && d < 0.02) ? (double)s_scroll : s_scrollF + d * 0.3; + } - const s32 W = viGetX(); - const s32 H = viGetY(); + const s32 W = ovW(); + const s32 H = ovH(); + gfx_set_overlay_scale(ovScale()); /* D510 */ struct OvLayout o = overlayLayout(); - int pLast = s_scroll + o.maxRows - 1; + const int first = s_section >= 0 ? 1 : 0; + int p0 = (int)s_scrollF; + if (p0 < first) p0 = first; + int pLast = p0 + o.maxRows; /* one extra row while gliding; the scissor trims it */ if (pLast >= s_visN) pLast = s_visN - 1; const s32 bx0 = o.barX0, bx1 = o.barX1; + /* Focus cue row: the hovered row while the mouse drives, else the selection. */ + const int cueP = (s_mouseActive && s_hover >= 0 && s_hover < s_visN) ? s_hover : s_sel; Gfx *gdl = s_buf; gDPPipeSync(gdl++); gDPSetCycleType(gdl++, G_CYC_1CYCLE); gDPSetTexturePersp(gdl++, G_TP_NONE); - gDPSetScissor(gdl++, G_SC_NON_INTERLACE, 0, 0, W, H); + gDPSetScissor(gdl++, G_SC_NON_INTERLACE, 0, 0, viGetX(), viGetY()); - /* 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); - /* D335b/D472: the dim stays full-window; the card + text are drawn under - * the centre aspect mode so native widescreen shows an undistorted, - * centred 4:3 panel (no emission at <= 4:3: byte-identical there). */ + /* Menu PD parity (R1/R4): PD dialog anatomy in PD's own green palette -- a + * content-sized centred box, gradient title bar (2 units wider each side) + * with shimmer streaks and a shadowed yellow title, flat ~50 % body, 1 px + * borders. No projected walls / grow-in / background blur (logged gaps). */ + gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 40); /* light dim only; the frozen game shows through the body */ + /* D335b/D472: the card + text are drawn under the centre aspect mode so + * native widescreen shows an undistorted, centred 4:3 panel (no emission at + * <= 4:3: byte-identical there). */ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER); - 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); - /* Selection is the brighter text in pass 2; sliders and the reticle - * colour's tiny preview swatch are the only controls with a fill. */ - for (int p = s_scroll; p <= pLast; p++) { + { + gdl = fillGradH3(gdl, o.left - 2, o.top, o.right + 2, o.titleB, + PAL_TITLE_L, PAL_TITLE_M, PAL_TITLE_R, 127); + gdl = drawShimmer(gdl, o.left - 2, o.right + 2, o.top, o.top + 1, 2400, 0); + gdl = drawShimmer(gdl, o.left - 2, o.right + 2, o.titleB - 1, o.titleB, 2400, 1200); + gdl = fillRectC(gdl, o.left, o.titleB, o.right, o.titleB + 1, PD_DIALOG_BORDER1); /* line under the title bar */ + gdl = fillRectC(gdl, o.left, o.titleB + 1, o.right, o.bottom, PD_BODY); + gdl = fillRectC(gdl, o.left, o.titleB + 1, o.left + 1, o.bottom, PD_DIALOG_BORDER1); + gdl = fillRectC(gdl, o.right - 1, o.titleB + 1, o.right, o.bottom, PD_DIALOG_BORDER2); + gdl = fillGradH3(gdl, o.left, o.bottom - 1, o.right, o.bottom, PAL_TITLE_L, PAL_TITLE_L, PAL_TITLE_R, 127); + } + + /* Hint texts (computed here so the hint panel fills before any text draws). */ + const char *help1 = NULL, *help2 = NULL; + if (optionsBindingCaptureActive() && s_padCapMode) { + help1 = "Press pad button Hold {B} = bind {B}"; + help2 = "Tap {B}/Esc cancel Hold {BACK} clear"; + } else if (optionsBindingCaptureActive()) { + help1 = "Press key/mouse 1-5"; + help2 = "{B}/Esc cancel Del clear"; + } else if (!s_mouseActive) { + if (s_section < 0) { + help1 = "{A}/Enter select"; + help2 = "{B}/F10 close"; + } else if (!strcmp(rows[s_section].key, "__HdrController")) { + help1 = "{A} bind Left/Right slot {Y} clear"; + help2 = "{B}/Esc back F10 close"; + } else if (!strcmp(rows[s_section].key, "__HdrBindings")) { + help1 = "Keys and mouse only Left/Right slot"; + help2 = "Enter bind {B}/Esc back"; + } else { + help1 = "{A} select Left/Right change"; + help2 = "{B}/Esc back F10 close"; + } + } + /* D507: description of the focused row (any input source), first line under + * the box; the key hints, when shown, move down one line to make room. */ + const char *rowHelp = (s_section >= 0 && s_sel >= 0 && s_sel < s_visN && !optionsBindingCaptureActive()) + ? optionsRowHelp(s_visIdx[s_sel]) : NULL; + { /* D519: the hint text sits in its own panel (box-body style) under the card, so it never + * draws bare over the game's own bar (file select's Copy/Erase) */ + int hintLines = (rowHelp ? 1 : 0) + (help1 ? 2 : 0); + if (hintLines) { + s32 py1 = o.hintY + hintLines * 12 + 2; + gdl = fillRectC(gdl, o.left, o.bottom + 1, o.right, py1, PD_BODY); + gdl = fillRectC(gdl, o.left, o.bottom + 1, o.left + 1, py1, PD_DIALOG_BORDER1); + gdl = fillRectC(gdl, o.right - 1, o.bottom + 1, o.right, py1, PD_DIALOG_BORDER1); + gdl = fillRectC(gdl, o.left, py1 - 1, o.right, py1, PD_DIALOG_BORDER1); + } + } + /* Rows only draw while their whole band is inside the body, so the eased + * scroll never paints over the title bar or border (the scissor does not + * clip the 2D rects here). */ + const s32 bodyTop = o.contentY, bodyBot = o.contentY + o.maxRows * OV_LINE; + + /* ---- pass 1: row fills (separators, checkboxes, wedge sliders, swatch) ---- */ + for (int p = p0; p <= pLast; p++) { const struct Row *r = &rows[s_visIdx[p]]; - s32 rowY = o.contentY + (p - s_scroll) * OV_LINE; + s32 rowY = o.contentY + (s32)lround((p - s_scrollF) * OV_LINE); + if (rowY < bodyTop || rowY + OV_LINE > bodyBot) continue; + int isCue = (p == cueP); + int expRow = strstr(r->label, "EXPERIMENTAL") != NULL; + u32 fillInk = !r->found ? PD_ITEM_DISABLED : expRow ? PD_EXP_UNFOCUSED : + isCue ? pulseInk() : PD_ITEM_UNFOCUSED; + if (isSepRow(r)) { /* PD menuitemSeparatorRender: a 1 px line */ + gdl = fillRectC(gdl, o.left + 8, rowY + OV_LINE / 2, o.right - 8, rowY + OV_LINE / 2 + 1, PD_ITEM_DISABLED); + continue; + } + if (s_section < 0) continue; /* hub rows are text only */ if (strcmp(r->key, "Video.CrosshairColor") == 0 && r->found) { extern void portCrosshairPreview(s32 *, s32 *, s32 *); char val[48]; s32 red, green, blue; valueText(s_visIdx[p], val, sizeof(val)); portCrosshairPreview(&red, &green, &blue); - s32 x = o.valueR - bodyWidth(val) - 17; - gdl = fillRect(gdl, x, rowY, x + 10, rowY + 10, - red, green, blue, 255); + s32 x = o.valueR - bodyWidth(val) - 14; + gdl = fillRect(gdl, x, rowY + 3, x + 8, rowY + 11, red, green, blue, 255); } - if (r->kind == ROW_SLIDER && r->found) { - 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); + if (r->kind == ROW_SLIDER && r->found && rowHi(r) > rowLo(r)) + gdl = drawWedge(gdl, bx0, bx1, rowY + 8, rowFractionAt(r, rowGet(r)), fillInk); + else if (rowIsDropdown(r)) { /* PD dropdown tick: a small down triangle before the value */ + char val[48]; + valueText(s_visIdx[p], val, sizeof(val)); + s32 tx = o.valueR - bodyWidth(val) - 10; + gdl = drawTriDownAA(gdl, tx + 2.5, rowY + 6, 3, 6.0, fillInk); /* D520 */ + } else if (overlayRowIsCheckbox(r) && r->found) { + char val[16]; + valueText(s_visIdx[p], val, sizeof(val)); + gdl = drawCheckbox(gdl, o.valueR, rowY, !strncmp(val, "On", 2) || !strncmp(val, "ON", 2), fillInk); } } /* ---- pass 2: text ---- */ gdl = microcode_constructor(gdl); - 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) { - const struct Row *hdr = &rows[s_section]; - /* The pinned Back row is a real selection (s_visIdx[0]), not an - * always-bright title decoration. Give it the same selected/dimmed - * ink as content rows so Up from the first setting has feedback. */ - gdl = drawBody(gdl, o.left + 12, o.sectionY, "< BACK", - s_sel == 0 ? 0xa0ffa0ff : 0x66ca77ff); - s32 titleX = o.left + 80; - gdl = drawBody(gdl, titleX, o.sectionY, hdr->label, 0x829e91ff); - if (hdr->saveScoped) { + for (int p = p0; p <= pLast; p++) { + const struct Row *r = &rows[s_visIdx[p]]; + s32 rowY = o.contentY + (s32)lround((p - s_scrollF) * OV_LINE) + OV_TEXT_DY; + if (rowY - OV_TEXT_DY < bodyTop || rowY - OV_TEXT_DY + OV_LINE > bodyBot) continue; + if (isSepRow(r)) continue; + int isCue = (p == cueP); + int expRow = strstr(r->label, "EXPERIMENTAL") != NULL; /* D472: red ink */ + u32 ink = !r->found ? PD_ITEM_DISABLED : + expRow ? (isCue ? PD_EXP_FOCUSED : PD_EXP_UNFOCUSED) : + isCue ? pulseInk() : PD_ITEM_UNFOCUSED; + u32 valueInk = !r->found ? PD_ITEM_DISABLED : expRow ? PD_EXP_UNFOCUSED : PD_ITEM_UNFOCUSED; /* PD: values stay unfocused */ + char label[64]; + if (s_section < 0) titleCase(r->label, label, sizeof(label)); + else snprintf(label, sizeof(label), "%s", r->label); + char val[48]; + if (s_section < 0) { /* PD hub: normal-size rows, no value column */ + gdl = isCue ? drawBodyOutlined(gdl, o.labelX, rowY, label, ink) + : drawBody(gdl, o.labelX, rowY, label, ink); + continue; + } + valueText(s_visIdx[p], val, sizeof(val)); + int checkbox = overlayRowIsCheckbox(r); + s32 valueW = (r->kind == ROW_SLIDER || checkbox) ? 0 : bodyWidth(val); + s32 labelEnd = r->kind == ROW_SLIDER ? bx0 - 4 : + checkbox ? o.valueR - 14 : o.valueR - valueW - 8; + if (strcmp(r->key, "Video.CrosshairColor") == 0) labelEnd -= 14; + { + char fit[80]; + snprintf(fit, sizeof(fit), "%s", label); + if (bodyWidth(fit) > labelEnd - o.labelX) { /* ellipsize */ + int n = (int)strlen(fit); + while (n > 1) { + fit[--n] = 0; + if (n + 2 < (int)sizeof(fit)) { fit[n] = '.'; fit[n + 1] = '.'; fit[n + 2] = 0; } + if (bodyWidth(fit) <= labelEnd - o.labelX) break; + fit[n] = 0; + } + } + gdl = isCue ? drawBodyOutlined(gdl, o.labelX, rowY, fit, ink) + : drawBody(gdl, o.labelX, rowY, fit, ink); + } + /* The mixed-scope Game page tags per-profile rows with a subdued word. */ + 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)", PD_ITEM_DISABLED); + } + if (!r->found) { + gdl = drawBodyR(gdl, o.valueR, rowY, "(n/a)", PD_ITEM_DISABLED); + continue; + } + if (r->restart) { + s32 tagX = o.labelX + bodyWidth(r->label) + 4; + if (tagX + bodyWidth("(restart)") < labelEnd - 2) + gdl = drawBody(gdl, tagX, rowY, "(restart)", PD_ITEM_DISABLED); + } + if (!checkbox && val[0]) /* sliders: PD shows the value text right of the wedge */ + gdl = drawBodyR(gdl, o.valueR, rowY, val, + isArmRow(r) && !strncmp(val, "Confirm", 7) ? PD_TITLE_TEXT : valueInk); + } + + { /* late pass (PD): the open dropdown's popup list over everything */ + s32 dx0, dy0, dx1, dy1; int vis; + if (ddGeom(&dx0, &dy0, &dx1, &dy1, &vis)) { + gdl = fillRectC(gdl, dx0, dy0, dx1, dy1, 0x002800eeu); + gdl = fillRectC(gdl, dx0, dy0, dx1, dy0 + 1, PD_DIALOG_BORDER2); + gdl = fillRectC(gdl, dx0, dy1 - 1, dx1, dy1, PD_DIALOG_BORDER2); + gdl = fillRectC(gdl, dx0, dy0, dx0 + 1, dy1, PD_DIALOG_BORDER2); + gdl = fillRectC(gdl, dx1 - 1, dy0, dx1, dy1, PD_DIALOG_BORDER2); + for (int k = 0; k < vis; k++) { + int it = s_ddTop + k; + if (it == s_ddCur) /* marks the current value */ + gdl = fillRectC(gdl, dx0 + 4, dy0 + 1 + k * OV_LINE + 5, dx0 + 8, dy0 + 1 + k * OV_LINE + 9, PD_ITEM_UNFOCUSED); + } + gdl = microcode_constructor(gdl); + for (int k = 0; k < vis; k++) { + int it = s_ddTop + k; + s32 ty = dy0 + 1 + k * OV_LINE + OV_TEXT_DY; + gdl = it == s_ddSel ? drawBodyOutlined(gdl, dx0 + 11, ty, s_ddLab[it], pulseInk()) + : drawBody(gdl, dx0 + 11, ty, s_ddLab[it], PD_ITEM_UNFOCUSED); + } + if (s_ddN > vis) + gdl = drawBodyR(gdl, dx1 - 3, dy1 - 12, s_ddTop + vis < s_ddN ? "v" : "^", PD_TITLE_TEXT); + } + } + { + char raw[64], title[64]; + if (s_section >= 0) optionsRowTitle(s_section, raw, sizeof(raw)); + else snprintf(raw, sizeof(raw), "PC Options"); + titleCase(raw, title, sizeof(title)); + gdl = drawBody(gdl, o.left + 9, o.top + 2, title, 0x000000ff); /* PD: title shadow pass */ + gdl = drawBody(gdl, o.left + 8, o.top + 1, title, PD_TITLE_TEXT); + if (s_section >= 0 && rows[s_section].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); + gdl = drawBody(gdl, o.left + 8 + bodyWidth(title) + 6, o.top + 1, note, PD_ITEM_UNFOCUSED); } } + if (s_visN - first > o.maxRows) + gdl = drawBodyR(gdl, o.right - 8, o.top + 1, + s_scroll > first ? "^ v" : "v", PD_TITLE_TEXT); + + if (s_section < 0 && configUnknownKeyCount() > 0) { /* D472: non-blocking ini warning */ + char w[64]; + snprintf(w, sizeof(w), "%d unknown ini key%s - see log", configUnknownKeyCount(), + configUnknownKeyCount() == 1 ? "" : "s"); + gdl = drawBodyR(gdl, o.right - 8, o.top + 1, w, 0xe0b050ff); + } - for (int p = s_scroll; p <= pLast; p++) { - const struct Row *r = &rows[s_visIdx[p]]; - s32 rowY = o.contentY + (p - s_scroll) * OV_LINE; - int expRow = strstr(r->label, "EXPERIMENTAL") != NULL; /* D472: red ink */ - u32 ink = !r->found ? 0x498053ff : - expRow ? (p == s_sel ? 0xff8a80ff : 0xd05a50ff) : - p == s_sel ? 0xa0ffa0ff : 0x66ca77ff; - char val[48]; - if (s_section < 0) { - gdl = drawBody(gdl, o.labelX, rowY, r->label, ink); - continue; - } - valueText(s_visIdx[p], val, sizeof(val)); - s32 valueW = bodyWidth(val); - s32 labelEnd = r->kind == ROW_SLIDER ? bx0 : o.valueR - valueW - 7; - if (strcmp(r->key, "Video.CrosshairColor") == 0) labelEnd -= 17; - if (r->restart) labelEnd = bx0 - 6; - gdl = drawBodyFit(gdl, o.labelX, rowY, labelEnd - o.labelX, - r->label, ink); - /* GAMEPLAY and HUD mix profile and global values. Their profile - * toggles have space for a 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 = drawBodyR(gdl, o.valueR, rowY, "(n/a)", 0x498053ff); - continue; - } - if (r->restart) { - gdl = drawBody(gdl, bx0, rowY, val, ink); - gdl = drawBodyR(gdl, o.valueR, rowY, "(restart)", 0x498053ff); - } else { - u32 valueInk = (strncmp(val, "ON", 2) == 0 || - (isArmRow(r) && strcmp(val, "Confirm") == 0)) - ? (expRow ? 0xff8a80ff : 0xa0ffa0ff) : ink; - gdl = drawBodyR(gdl, o.valueR, rowY, val, valueInk); + /* Hint line under the box (R4): only while the last input came from a pad or + * the keyboard (or a capture is waiting for one); it keeps D471's per-family + * button names. Two short lines, centred. */ + s32 hintDy = 0; + if (rowHelp) { + char hb[64]; + s32 av = o.right - o.left - 8; + snprintf(hb, sizeof(hb), "%s", rowHelp); + for (int n = (int)strlen(hb); n > 3 && bodyWidth(hb) > av; ) { + hb[--n] = 0; + if (n >= 2) { hb[n - 1] = '.'; hb[n - 2] = '.'; } } + s32 hw = bodyWidth(hb), hx = (o.left + o.right) / 2 - hw / 2; + gdl = drawBody(gdl, hx + 1, o.hintY + 1, hb, 0x000000ff); + gdl = drawBody(gdl, hx, o.hintY, hb, PD_ITEM_UNFOCUSED); + hintDy = 12; } - /* Keep the help inside the card at 320x240. The old single long line - * started after CONTROLS: and ran under / beyond the right edge. Reserve - * two lines in overlayLayout so the footer never covers the last row. */ - const char *help1, *help2; - if (optionsBindingCaptureActive() && s_padCapMode) { - help1 = "PRESS PAD BUTTON HOLD {B}=BIND {B}"; - help2 = "TAP {B}/ESC CANCEL HOLD {BACK} CLEAR"; - } else if (optionsBindingCaptureActive()) { - help1 = "PRESS KEY/MOUSE 1-5"; - help2 = "{B}/ESC CANCEL DEL CLEAR"; - } else if (s_section < 0) { - help1 = "{A}/ENTER SELECT"; - help2 = "{B}/F10 CLOSE"; - } else if (!strcmp(rows[s_section].key, "__HdrController")) { - help1 = "{A} BIND LEFT/RIGHT SLOT {Y} CLEAR"; - help2 = "{B}/ESC BACK F10 CLOSE"; - } else if (!strcmp(rows[s_section].key, "__HdrMoveKeys") || - !strcmp(rows[s_section].key, "__HdrActionKeys")) { - help1 = "KEY/MOUSE ONLY LEFT/RIGHT SLOT"; - help2 = "ENTER BIND {B}/ESC BACK"; - } else { - help1 = "{A} SELECT LEFT/RIGHT CHANGE"; - help2 = "{B}/ESC BACK F10 CLOSE"; - } - s32 helpX = o.left + 16 + metaWidth("CONTROLS:"); - s32 helpRight = o.right - 10; - static int footerProbe = -1; - if (footerProbe < 0) footerProbe = getenv("GE_FOOTERPROBE") != NULL; - if (footerProbe) { - static int reported; - if (!reported++) { - static const char *const lines[][2] = { - { "PRESS KEY/MOUSE 1-5", "ESC CANCEL DEL CLEAR" }, - { "A/ENTER SELECT", "B/F10 CLOSE" }, - { "KEY/MOUSE ONLY LEFT/RIGHT SLOT", "ENTER BIND B/ESC BACK" }, - { "A SELECT LEFT/RIGHT CHANGE", "B/ESC BACK F10 CLOSE" }, - }; - for (size_t k = 0; k < sizeof(lines) / sizeof(lines[0]); k++) { - s32 a = bodyWidth(lines[k][0]), b = bodyWidth(lines[k][1]); - s32 avail1 = helpRight - helpX; - s32 avail2 = helpRight - (o.left + 16); - sysLogPrintf(a > avail1 || b > avail2 ? LOG_ERROR : LOG_INFO, - "footerprobe: variant %d widths %d/%d available %d/%d %s", - (int)k, a, b, avail1, avail2, - a > avail1 || b > avail2 ? "OVERFLOW" : "FIT"); - } - } - } - gdl = drawMeta(gdl, o.left + 11, o.footerY, "CONTROLS:", 0xa6baaaff); - { /* D471: pad button names for the menu pad's family (Xbox = old text) */ + if (help1) { static char h1buf[80], h2buf[80]; - inputPadHelpFmt(h1buf, sizeof(h1buf), help1); + inputPadHelpFmt(h1buf, sizeof(h1buf), help1); /* D471: pad names for the menu pad's family */ inputPadHelpFmt(h2buf, sizeof(h2buf), help2); - /* Long PlayStation names must not run past the card: fall back to - * the Xbox letters for a line that would overflow. */ { - /* Too wide: first compact "LEFT/RIGHT" to "L/R", then fall back - * to the Xbox letters, so a line never runs past the card. */ + /* Too wide for the card: first compact "Left/Right" to "L/R", then fall + * back to the Xbox letters, so a line never runs past the box. */ const char *tm[2] = { help1, help2 }; char *bf[2] = { h1buf, h2buf }; - s32 av[2] = { helpRight - helpX, helpRight - (o.left + 16) }; + s32 av = o.right - o.left - 8; for (int L = 0; L < 2; L++) { - if (bodyWidth(bf[L]) <= av[L]) continue; + if (bodyWidth(bf[L]) <= av) continue; char compact[80]; size_t o2 = 0; for (const char *p = tm[L]; *p && o2 + 1 < sizeof(compact); ) { - if (!strncmp(p, "LEFT/RIGHT", 10)) { memcpy(compact + o2, "L/R", 3); o2 += 3; p += 10; } + if (!strncmp(p, "Left/Right", 10)) { memcpy(compact + o2, "L/R", 3); o2 += 3; p += 10; } else compact[o2++] = *p++; } compact[o2] = 0; inputPadHelpFmt(bf[L], 80, compact); - if (bodyWidth(bf[L]) > av[L]) inputPadHelpFmtFam(bf[L], 80, tm[L], 0); + if (bodyWidth(bf[L]) > av) inputPadHelpFmtFam(bf[L], 80, tm[L], 0); } } - help1 = h1buf; help2 = h2buf; - } - gdl = drawBody(gdl, helpX, o.footerY, help1, 0x829e91ff); - gdl = drawBody(gdl, o.left + 16, o.footerY + OV_FOOTER_LINE, - help2, 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); - - if (s_section < 0 && configUnknownKeyCount() > 0) { /* D472: non-blocking ini warning */ - char w[64]; - snprintf(w, sizeof(w), "%d UNKNOWN INI KEY%s - SEE LOG", configUnknownKeyCount(), - configUnknownKeyCount() == 1 ? "" : "S"); - gdl = drawBodyR(gdl, o.right - 28, o.top + 8, w, 0xe0b050ff); + s32 cx = (o.left + o.right) / 2; + s32 y1 = o.hintY + hintDy, y2 = o.hintY + hintDy + 12; + s32 w1 = bodyWidth(h1buf), w2 = bodyWidth(h2buf); + gdl = drawBody(gdl, cx - w1 / 2 + 1, y1 + 1, h1buf, 0x000000ff); + gdl = drawBody(gdl, cx - w1 / 2, y1, h1buf, PD_ITEM_UNFOCUSED); + gdl = drawBody(gdl, cx - w2 / 2 + 1, y2 + 1, h2buf, 0x000000ff); + gdl = drawBody(gdl, cx - w2 / 2, y2, h2buf, PD_ITEM_UNFOCUSED); } PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE); gDPPipeSync(gdl++); @@ -2761,8 +3466,75 @@ int optionsRowIsShown(int i) { struct Row *r = rowAt(i); return r && r->found && !(r->hidePtr && *r->hidePtr) && - (!r->showPtr || *r->showPtr == r->shownValue) && - (r->kind != ROW_BOND_FILE || current_menu == MENU_PC_OPTIONS); + !isSepRow(r) && !isBackRow(r) && /* F10-only furniture */ + (!r->showPtr || *r->showPtr == r->shownValue) && rowSchemeOk(r) && + (r->kind != ROW_BOND_FILE || current_menu == MENU_FILE_SELECT); /* front-end profile chooser */ +} + +/* D507: one-line descriptions of the less obvious rows (key -> text), shown for + * the focused row. Keep each <= ~44 characters: the front-end page has one line + * of paper under its rows. Rows with self-explanatory labels have no entry. */ +static const struct { const char *key, *help; } kRowHelp[] = { + { "__DisplayMode", "Modern defaults, or the original N64 look" }, + { "Video.AspectMode", "Fill the window, or force a fixed shape" }, + { "Video.FullscreenMode", "Borderless is seamless; exclusive can run faster" }, + { "__CenterWindow", "Moves the window to the middle of the screen" }, + { "Video.CrosshairAlpha", "How see-through the crosshair is" }, + { "Video.CrosshairHealthColor", "Crosshair shifts from green to red as health drops" }, + { "Video.NativeWidescreen", "Wider view, not a stretched picture" }, + { "Video.MSAA", "Smooths jagged edges (applies on restart)" }, + { "Video.VSync", "Syncs frames to your display, no tearing" }, + { "Video.FpsCap", "Limits the game's frame rate" }, + { "Video.TextureFilter", "How textures are smoothed up close" }, + { "Video.Anisotropy", "Sharper textures seen at a steep angle" }, + { "Video.WidescreenAuto", "Widens FOV when stretched (not Native)" }, + { "Video.SafeAreaCrop", "Hides the N64 TV border at the screen edge" }, + { "Video.DrawDistance", "How far away the world is drawn" }, + { "Video.LodDistance", "How far away detailed models are used" }, + { "Game.ScreenShakeIntensity", "Strength of the camera shake from blasts" }, + { "Audio.MasterVolume", "Overall volume, on top of Music and FX" }, + { "Input.MouseSensitivity", "Mouse turning speed" }, + { "Input.MouseYScale", "Mouse look speed up and down" }, + { "Input.MouseInvertY", "Moving the mouse up looks down" }, + { "Input.AimMode", "N64 moves the crosshair; Centered locks it" }, + { "Input.AimRange", "How far the N64-style crosshair travels" }, + { "Input.MenuPointerMode", "Direct follows the mouse; Velocity drifts" }, + { "Input.MouseSmoothing", "Averages mouse motion; higher is smoother" }, + { "Input.MouseRawInput", "Bypasses OS mouse acceleration" }, + { "Input.ControlScheme", "Ext fixes the style; Original uses the watch's 1.1-1.4" }, + { "Bond.Control", "The game's own style for this player, saved with the Bond file; same as the watch" }, + { "Input.PadPreset", "Button layout; Custom lets you rebind" }, + { "Input.PadLookInvertY", "Pushing the stick up looks down" }, + { "Input.PadDeadzoneL", "Ignores small stick movement near center" }, + { "Input.PadDeadzoneR", "Ignores small stick movement near center" }, + { "Input.PadLookSensX", "Right stick turning speed" }, + { "Input.PadLookSensY", "Right stick up/down speed" }, + { "Input.PadLookSmooth", "Eases right stick turning; 0% = off" }, + { "Input.PadSouthpaw", "Swaps the movement and look sticks" }, + { "Input.PadTriggerPct", "How far a trigger must pull to count" }, + { "Input.RumbleScale", "Strength of controller vibration" }, + { "Bond.Look", "Reverse: pushing up looks down" }, + { "Bond.AimControl", "Hold the aim button, or tap to toggle" }, + { "Bond.AutoAim", "Snaps the crosshair to nearby targets" }, + { "Bond.LookAhead", "Tilts the view up or down on slopes" }, + { "Bond.Sight", "Shows the aiming sight while aiming" }, + { "Bond.Ammo", "Shows the ammo counter on screen" }, + { "Input.CrouchMode", "Hold to crouch, or tap to toggle" }, + { "Video.FovScale", "Horizontal field of view" }, + { "Game.AIWideView", "On: guards react across the full wide view" }, + { "Game.NoHitFlash", "Turns off the red flash when you are hit" }, + { "Game.HudScale", "Scales ammo, pickup text and dialogue" }, + { "Game.SkipIntro", "Skips the intro; may skip failure dossier" }, + { "Game.AllUnlocked", "Unlocks levels, cheats and weapons" }, +}; + +const char *optionsRowHelp(int i) +{ + struct Row *r = rowAt(i); + if (!r) return NULL; + for (size_t k = 0; k < sizeof(kRowHelp) / sizeof(kRowHelp[0]); k++) + if (!strcmp(kRowHelp[k].key, r->key)) return kRowHelp[k].help; + return NULL; } const char *optionsRowLabel(int i) @@ -2771,6 +3543,62 @@ const char *optionsRowLabel(int i) return r ? r->label : ""; } +/* Page title: the label, except Controller which carries the seat chosen on + * the Select player page ("PLAYER 2 CONTROLLER"). */ +void optionsRowTitle(int i, char *out, int n) +{ + struct Row *r = rowAt(i); + if (!r || n <= 0) { if (n > 0) out[0] = 0; return; } + if (!strcmp(r->key, "__HdrController")) + snprintf(out, n, "PLAYER %d %s", s_padSeat + 1, r->label); + else if (!strcmp(r->key, "__HdrSelPlayerC")) + snprintf(out, n, "SELECT PLAYER"); + else + snprintf(out, n, "%s", r->label); +} + +/* Page-kind queries for the front screen's help lines / scope tags (it must not + * match on header labels: the hub label CONTROLLER now names two pages). */ +int optionsRowIsBindingsPage(int hdr) +{ + struct Row *r = rowAt(hdr); + return r && !strcmp(r->key, "__HdrBindings"); +} + +int optionsRowIsPadPage(int hdr) +{ + struct Row *r = rowAt(hdr); + return r && !strcmp(r->key, "__HdrController"); +} + +int optionsRowSectionMixedScope(int hdr) +{ + overlayInit(); + return sectionIsMixedScope(hdr); +} + +/* Called by a UI right before it opens the child page of link row i: a + * "Player N" row on a Select player page picks the seat the next page edits. */ +void optionsRowLinkOpened(int i) +{ + struct Row *r = rowAt(i); + int seat = r ? selPlayerSeat(r) : -1; + if (seat >= 0) padSetSeat(seat); /* D518 */ +} + +/* Seat (0-3) of a "Player N" row on a Select player page, else -1. */ +int optionsRowSeat(int i) +{ + struct Row *r = rowAt(i); + return r ? selPlayerSeat(r) : -1; +} + +/* Seat that the Select player page should preselect. */ +int optionsPadSeat(void) +{ + return s_padSeat; +} + int optionsRowIsSlider(int i) { struct Row *r = rowAt(i); @@ -2883,7 +3711,7 @@ void optionsRowAdjust(int i, int dir) /* second file's bytes unchanged (scope isolation). */ /* ------------------------------------------------------------------------ */ -#define PROBE_MAX_SECTIONS 8 /* reset-owning sections including HUD and bind leaves */ +#define PROBE_MAX_SECTIONS 12 /* reset-owning sections including HUD and bind leaves */ static int probeSectionIdxs[PROBE_MAX_SECTIONS]; static int probeSectionN = 0; static int probeResSelBefore = -2; @@ -2927,6 +3755,7 @@ static int probeVerifySection(int iReset) struct Row *r = &rows[j]; if (r->kind == ROW_ACTION) continue; if (strcmp(r->key, "__Resolution") == 0) continue; /* documented exclusion */ + if (strcmp(r->key, "Bond.Control") == 0) continue; /* D516 */ int field = watchSettingsFieldForKey(r->key); if (field >= 0) { int want = watchSettingsBlankValue((enum WatchSettingField)field); @@ -3047,8 +3876,8 @@ void optionsResetProbeDispatchStage(void) optionsRowActivateReset(probeSectionIdxs[s]); /* confirm */ } int q = watchSettingsQueueCount(); - if (q < 6) { - sysLogPrintf(LOG_WARNING, "wsresetprobe: stage dispatch: queue depth %d, want >= 6 (2 GAMEPLAY + 2 HUD + 2 AUDIO watch rows)", q); + if (q < 8) { + sysLogPrintf(LOG_WARNING, "wsresetprobe: stage dispatch: queue depth %d, want >= 8 (6 GAME + 2 AUDIO watch rows)", q); } else { sysLogPrintf(LOG_INFO, "wsresetprobe: stage dispatch: %d command(s) queued (game thread drains next tick)", q); } diff --git a/port/src/pccg.c b/port/src/pccg.c index 7ae501fa..bb2ba2fb 100644 --- a/port/src/pccg.c +++ b/port/src/pccg.c @@ -128,7 +128,10 @@ int pccgLoadSidecars(uintptr_t base) if (!f) return 0; s_base = base; - ok = fsRead(f, (void *)s_base, (int32_t)s_total) == (int32_t)s_total; + /* The region was reserved from the size seen at sizing time; refuse a + * file whose size has since changed. */ + ok = (uint32_t)fsSize(f) == s_total && + fsRead(f, (void *)s_base, (int32_t)s_total) == (int32_t)s_total; fsClose(f); if (!ok) { sysLogPrintf(LOG_ERROR, "pccg: short read of %s", s_binPath); diff --git a/port/src/pcmodels.c b/port/src/pcmodels.c index 045e4a16..1dd85254 100644 --- a/port/src/pcmodels.c +++ b/port/src/pcmodels.c @@ -136,7 +136,10 @@ int pcmodelsLoadSidecars(uintptr_t cartBase, uint32_t romSize) if (!f) return 0; s_base = cartBase + romSize; - ok = fsRead(f, (void *)s_base, (int32_t)s_total) == (int32_t)s_total; + /* The region was reserved from the size seen at sizing time; refuse a + * file whose size has since changed. */ + ok = (uint32_t)fsSize(f) == s_total && + fsRead(f, (void *)s_base, (int32_t)s_total) == (int32_t)s_total; fsClose(f); if (!ok) { sysLogPrintf(LOG_ERROR, "pcmodels: short read of %s", s_binPath); diff --git a/port/src/romconvert.c b/port/src/romconvert.c index 72b7fd17..16ec9550 100644 --- a/port/src/romconvert.c +++ b/port/src/romconvert.c @@ -72,14 +72,63 @@ static int rcSidecarsPresent(const char *region) } #if defined(PLATFORM_WINDOWS) +/* Append raw text to cmd; returns 0 if it does not fit. */ +static int rcAppendRaw(char *cmd, size_t cap, size_t *n, const char *s) +{ + size_t l = strlen(s); + if (*n + l + 1 > cap) + return 0; + memcpy(cmd + *n, s, l + 1); + *n += l; + return 1; +} + +/* Append arg quoted per the CommandLineToArgvW rules: backslashes directly + * before a quote are doubled, embedded quotes are escaped, and trailing + * backslashes are doubled before the closing quote. */ +static int rcQuoteArg(char *cmd, size_t cap, size_t *n, const char *arg) +{ + size_t i = *n, bs = 0; + if (i + 1 >= cap) + return 0; + cmd[i++] = '"'; + for (; *arg; arg++) { + if (*arg == '\\') { + bs++; + if (i + 1 >= cap) return 0; + cmd[i++] = '\\'; + continue; + } + if (*arg == '"') { + if (i + bs + 2 >= cap) return 0; + while (bs--) cmd[i++] = '\\'; /* double the run */ + cmd[i++] = '\\'; + cmd[i++] = '"'; + } else { + if (i + 1 >= cap) return 0; + cmd[i++] = *arg; + } + bs = 0; + } + if (i + bs + 2 > cap) return 0; + while (bs--) cmd[i++] = '\\'; + cmd[i++] = '"'; + cmd[i] = 0; + *n = i; + return 1; +} + /* Spawn `exePath --rom "" --out ""` and wait for it. * Returns the exit code, or -1 if it could not be started. */ static int rcRunConverter(const char *exePath, const char *rom, const char *out) { char cmd[4096]; - int n = snprintf(cmd, sizeof(cmd), "\"%s\" --rom \"%s\" --out \"%s\"", - exePath, rom, out); - if (n < 0 || (size_t)n >= sizeof(cmd)) + size_t n = 0; + if (!rcQuoteArg(cmd, sizeof(cmd), &n, exePath) || + !rcAppendRaw(cmd, sizeof(cmd), &n, " --rom ") || + !rcQuoteArg(cmd, sizeof(cmd), &n, rom) || + !rcAppendRaw(cmd, sizeof(cmd), &n, " --out ") || + !rcQuoteArg(cmd, sizeof(cmd), &n, out)) return -1; /* paths too long to build a command line */ int wlen = MultiByteToWideChar(CP_UTF8, 0, cmd, -1, NULL, 0); diff --git a/port/src/video.c b/port/src/video.c index 56447e69..e88fce2f 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -31,7 +31,6 @@ #include "video.h" #include "input.h" #include "optionsoverlay.h" -#include "frontoptions.h" #include "audio.h" #include "../fast3d/gfx_api.h" @@ -74,8 +73,9 @@ static int cfgLodDistance = 250; /* % of authored geometry LOD distance; 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 cfgAspectMode = 0; /* D447: 0 = Window (fill the window), 1 = Original (pillar/letterbox to the console aspect: 4:3, or 16:9 while the game's Ratio option is 16:9) */ +static int cfgAspectMode = 0; /* D447: 0 = Window (fill the window), 1 = Original (pillar/letterbox to the console aspect: 4:3, or 16:9 while the game's Ratio option is 16:9). D508: 2 = 16:9, 3 = 21:9 (forced) */ static int cfgFullscreen = 0; /* 0 = windowed, 1 = borderless fullscreen */ +static int cfgFullscreenMode = 0; /* D511: fullscreen flavour, 0 = borderless desktop, 1 = exclusive */ static int cfgDeckPresetApplied = 0; /* D283: 1 once the Steam Deck preset has been considered */ static int cfgLowEndConsidered = 0; /* D482: 1 once the low-end GPU defaults have been considered */ @@ -116,6 +116,9 @@ static int cfgCrosshairColor = 0; /* 0 = authored sprite; 1..7 = presets; 8 = static int cfgCrosshairRed = 255, cfgCrosshairGreen = 255, cfgCrosshairBlue = 255; static int cfgCrosshairSize = 100; /* 100% retains the original 32x32 drawing */ static int cfgCrosshairStyle = 0; /* 0 = original; 1 = unused beta asset */ +static int cfgCrosshairAlpha = 100; /* D511: % of the original sprite alpha (0x6E); 100 = untouched */ +static int cfgCrosshairHealth = 0; /* D511: 1 = tint follows health (PD ramp); 0 = Crosshair color rules */ +extern float portCrosshairHealthRatio(void); /* input.c: health + armour, 0..2 */ int portCrosshairStyle(void) { return cfgCrosshairStyle; } float portCrosshairScale(void) { return cfgCrosshairSize / 100.0f; } @@ -133,14 +136,43 @@ float portCrosshairScale(void) { return cfgCrosshairSize / 100.0f; } * skips this rewrite, keeping the original game's DL byte-identical. */ void portCrosshairApplyTintCombine(Gfx *envCommand) { - if (cfgCrosshairColor == 0 || !envCommand) return; + if (!envCommand) return; + if (cfgCrosshairAlpha != 100) { + /* D511: the env colour command carries the sprite alpha (0x6E in the + * call site); scale just that byte, keep the tint. Original (100) skips this. */ + s32 r, g, b; + portCrosshairTint(&r, &g, &b); + gDPSetEnvColor(envCommand, r, g, b, (0x6E * cfgCrosshairAlpha + 50) / 100); + } + if (cfgCrosshairColor == 0 && !cfgCrosshairHealth) return; gDPSetCombineLERP(envCommand + 1, ENVIRONMENT, 0, TEXEL0_ALPHA, 0, TEXEL0, 0, ENVIRONMENT, 0, ENVIRONMENT, 0, TEXEL0_ALPHA, 0, TEXEL0, 0, ENVIRONMENT, 0); } +/* D511: PD's health ramp (pd_port src/game/sight.c sightGetCrosshairHealthColor, + * "on green" variant; MIT). ratio = health + armour, 0..2 (GE: both 0..1). */ +static void crosshairHealthTint(float ratio, s32 *r, s32 *g, s32 *b) +{ + if (ratio < 0.0f) ratio = 0.0f; + if (ratio > 2.0f) ratio = 2.0f; + int red, green, blue; + if (ratio < 0.2f) { red = 255; green = 0; blue = 0; } + else if (ratio < 0.6f) { red = 255; green = (int)(255.0f * ((ratio - 0.2f) / 0.4f)); blue = 0; } + else if (ratio < 1.0f) { red = (int)(255.0f * ((ratio - 0.6f) / 0.4f)); green = 255; blue = 0; } + else { red = 0; green = 255; blue = (int)(255.0f * (ratio - 1.0f)); } + *r = red; *g = green; *b = blue; +} + void portCrosshairTint(s32 *r, s32 *g, s32 *b) { + /* D511: health colour wins over Crosshair color (PD: SIGHT_COLOUR uses the + * health ramp instead of the chosen colour). Only reached from gunDrawSight + * (live player) and the preview (which passes through here too: full health). */ + if (cfgCrosshairHealth) { + crosshairHealthTint(portCrosshairHealthRatio(), r, g, b); + return; + } static const unsigned char kTints[8][3] = { { 0xFF, 0xFF, 0xFF }, /* Original red sprite (identity multiplier) */ { 0x40, 0xFF, 0x40 }, /* Green */ @@ -168,7 +200,7 @@ void portCrosshairTint(s32 *r, s32 *g, s32 *b) * representative red swatch for it rather than misleadingly showing white. */ void portCrosshairPreview(s32 *r, s32 *g, s32 *b) { - if (cfgCrosshairColor == 0) { + if (cfgCrosshairColor == 0 && !cfgCrosshairHealth) { *r = 255; *g = 40; *b = 40; } else { portCrosshairTint(r, g, b); @@ -332,12 +364,16 @@ f32 portFovHorizDegrees(s32 pct) * playtest found a straight 1:1 FovScale coupling still faded guards in * noticeably close on Dam, so the auto coupling is 2x FovScale, not 1x). * An explicit Video.DrawDistance != 100 overrides that coupling outright. - * Clamped to <=4.0x -- pushing the far plane much further out risks - * far-field z-fighting against the level's original near-plane precision; - * raised again (M-121 live playtest: 2x FovScale still showed a "blue - * glow" on far Dam tunnel geometry, so auto coupling is now 4x FovScale) - * to give headroom (max portFovScale is 1.5 at Video.FovScale's registered - * ceiling of 150, so 4x tops out at 6.0x -- ceiling raised to match). + * Clamp history: an explicit Video.DrawDistance goes up to 800 (8.0x cap, + * raised 2026-10-04 at the maintainer's request). The auto-FOV coupling is + * 4x FovScale (M-121 live playtest: 2x FovScale still showed a "blue glow" + * on far Dam tunnel geometry); max portFovScale is 1.5 at Video.FovScale's + * registered ceiling of 150, so the coupling still tops out at 6.0x. + * The multiplier scales Visibility.FarFog (bgfog.c), so it moves the far + * clip plane as well as the fog ramp, while the near plane stays fixed. + * Pushing the far plane out risks far-field z-fighting against the level's + * original depth precision; 8x halves the precision margin compared with + * 4x. A by-eye check at 600-800 is owed (ROADMAP section 3). * 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 @@ -357,7 +393,7 @@ f32 portDrawDistanceMultiplier(void) } else { return 1.0f; } - if (mult > 6.0f) { mult = 6.0f; } + if (mult > 8.0f) { mult = 8.0f; } if (mult < 1.0f) { mult = 1.0f; } return mult; } @@ -373,7 +409,7 @@ f32 portDrawDistanceMultiplier(void) * 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%) -- + * to a [0.125, 4.0] distance multiplier (== effective LodDistance 25-800%) -- * far outside that band either does nothing visible (LOD never triggers) or * thrashes every frame. Identity (1.0f) at Video.LodDistance=100 * with auto-FOV off. */ @@ -388,7 +424,7 @@ f32 portLodDistanceMultiplier(void) return 1.0f; } if (pct < 25.0f) { pct = 25.0f; } - if (pct > 400.0f) { pct = 400.0f; } + if (pct > 800.0f) { pct = 800.0f; } return 100.0f / pct; } @@ -485,6 +521,8 @@ PD_CONSTRUCTOR static void videoConfigInit(void) configRegisterInt("Video.CrosshairBlue", &cfgCrosshairBlue, 0, 255); configRegisterInt("Video.CrosshairSize", &cfgCrosshairSize, 50, 200); configRegisterInt("Video.CrosshairStyle", &cfgCrosshairStyle, 0, 1); + configRegisterInt("Video.CrosshairAlpha", &cfgCrosshairAlpha, 0, 100); /* D511 */ + configRegisterInt("Video.CrosshairHealthColor", &cfgCrosshairHealth, 0, 1); /* D511 */ configRegisterInt("Game.AllUnlocked", &portAllUnlocked, 0, 1); configRegisterInt("Video.VSync", &cfgVSync, 0, 1); configRegisterInt("Video.FpsCap", &cfgFpsCap, 0, 1000); @@ -497,14 +535,15 @@ PD_CONSTRUCTOR static void videoConfigInit(void) configRegisterInt("Video.WidescreenAuto", &cfgWidescreenAuto, 0, 1); configRegisterInt("Video.NativeWidescreen", &cfgNativeWidescreen, 0, 1); /* D334 */ configRegisterInt("Game.HudScale", &cfgHudScale, 75, 150); /* D226: capped at 150 (user: little benefit above) */ - configRegisterInt("Video.DrawDistance", &cfgDrawDistance, 100, 400); + configRegisterInt("Video.DrawDistance", &cfgDrawDistance, 100, 800); configRegisterInt("Video.DrawDistanceAutoFov", &cfgDrawDistanceAutoFov, 0, 1); - configRegisterInt("Video.LodDistance", &cfgLodDistance, 25, 400); + configRegisterInt("Video.LodDistance", &cfgLodDistance, 25, 800); configRegisterInt("Video.LodDistanceAutoFov", &cfgLodDistanceAutoFov, 0, 1); configRegisterInt("Video.Anisotropy", &cfgAniso, 1, 16); configRegisterInt("Video.SafeAreaCrop", &cfgSafeAreaCrop, 0, 1); - configRegisterInt("Video.AspectMode", &cfgAspectMode, 0, 1); /* D447 */ + configRegisterInt("Video.AspectMode", &cfgAspectMode, 0, 3); /* D447; D508 forced ratios 2..3 */ configRegisterInt("Video.Fullscreen", &cfgFullscreen, 0, 1); + configRegisterInt("Video.FullscreenMode", &cfgFullscreenMode, 0, 1); /* D511: 0 borderless, 1 exclusive */ configRegisterInt("Window.Width", &cfgWinW, 0, 16384); configRegisterInt("Window.Height", &cfgWinH, 0, 16384); configRegisterInt("Window.X", &cfgWinX, -1, 16384); @@ -803,7 +842,7 @@ void videoRequestLiveConfigForKey(const char *key) * SDL_SetWindowFullscreen pump the Win32 message loop and must run on the * window's creating thread. The overlay posts a request here; the host-thread * event pump drains it in videoDrainWindowRequests(). */ -static volatile int winReqKind = 0; /* 0 none, 1 resize, 2 fullscreen */ +static volatile int winReqKind = 0; /* 0 none, 1 resize, 2 fullscreen, 3 centre (D511), 4 fullscreen mode (D511) */ static volatile int winReqA = 0, winReqB = 0; void videoRequestWindowSize(int w, int h) @@ -816,6 +855,16 @@ void videoRequestFullscreen(int on) winReqA = on ? 1 : 0; winReqKind = 2; } +void videoRequestFullscreenMode(int exclusive) +{ + winReqA = exclusive ? 1 : 0; winReqKind = 4; +} + +void videoRequestCenterWindow(void) +{ + winReqKind = 3; +} + void videoGetOutputRectFrac(double *x0, double *y0, double *x1, double *y1) { int32_t rx = 0, ry = 0, rw = 0, rh = 0; @@ -888,6 +937,26 @@ static void videoDrainWindowRequests(void) gfx_sdl_update_cached_size(); cfgFullscreen = on ? 1 : 0; sysLogPrintf(LOG_INFO, "video: fullscreen %s", on ? "on" : "off"); + } else if (kind == 3) { /* D511: Center window (windowed only) */ + uint32_t ww = 0, hh = 0; int32_t cx = 0, cy = 0, px = 0, py = 0; + if (wmAPI->get_fullscreen_state && wmAPI->get_fullscreen_state()) return; + if ((wmAPI->get_maximized_state && wmAPI->get_maximized_state()) || cfgWinMax) { /* set_dimensions on a maximized window is undefined across platforms */ + sysLogPrintf(LOG_INFO, "video: center window ignored (window is maximized)"); + return; + } + if (!wmAPI->get_dimensions || !wmAPI->get_centered_positions || !wmAPI->set_dimensions) return; + wmAPI->get_dimensions(&ww, &hh, &cx, &cy); + wmAPI->get_centered_positions((int32_t)ww, (int32_t)hh, &px, &py); + wmAPI->set_dimensions(ww, hh, px, py); + cfgWinX = px; cfgWinY = py; + sysLogPrintf(LOG_INFO, "video: center window %ux%u: (%d,%d) -> (%d,%d)", ww, hh, cx, cy, px, py); + } else if (kind == 4) { /* D511: borderless vs exclusive; re-enters fullscreen when already on */ + int ex = winReqA; + if (wmAPI->set_fullscreen_exclusive) wmAPI->set_fullscreen_exclusive(ex != 0); + gfx_sdl_update_cached_size(); + cfgFullscreenMode = ex ? 1 : 0; + sysLogPrintf(LOG_INFO, "video: fullscreen mode %s (fullscreen now %d)", ex ? "exclusive" : "borderless", + wmAPI->get_fullscreen_state ? (int)wmAPI->get_fullscreen_state() : -1); } } @@ -940,7 +1009,7 @@ int videoInit(void) .x = havePos ? cfgWinX : 100, .y = havePos ? cfgWinY : 100, .fullscreen = cfgFullscreen != 0, - .fullscreen_is_exclusive = false, + .fullscreen_is_exclusive = cfgFullscreenMode != 0, .maximized = cfgWinMax != 0, .centered = !havePos, .allow_hidpi = false, @@ -1085,8 +1154,14 @@ void videoDestroy(void) extern int get_screen_ratio(void); static float videoOutputAspect(void) { - if (cfgAspectMode != 1) return 0.0f; - return get_screen_ratio() == 1 /* SCREEN_RATIO_16_9 */ ? (16.0f / 9.0f) : (4.0f / 3.0f); + switch (cfgAspectMode) { + case 1: /* Original: the console's own shape */ + return get_screen_ratio() == 1 /* SCREEN_RATIO_16_9 */ ? (16.0f / 9.0f) : (4.0f / 3.0f); + case 2: return 16.0f / 9.0f; /* D508: forced ratios; with Native widescreen the world + projects at this aspect (it is the output rect's own) */ + case 3: return 21.0f / 9.0f; /* also stands in for 2.35:1 cinema */ + default: return 0.0f; /* Window: fill the window */ + } } void videoStartFrame(void) @@ -1174,11 +1249,8 @@ void videoPumpEvents(void) } else if (ev.key.keysym.sym == SDLK_F12 && !ev.key.repeat) { screenshotReq = 1; } else if (ev.key.keysym.sym == SDLK_F10 && !ev.key.repeat) { - /* F10: port-layer options overlay -- not on the PC options - * screen (D343: one options UI at a time). */ - if (optionsOverlayIsOpen() || !frontOptionsBlocksOverlay()) { - optionsOverlayToggle(); - } + /* F10: port-layer options overlay (the one PC options UI). */ + optionsOverlayToggle(); } else if (ev.key.keysym.sym == SDLK_ESCAPE && !ev.key.repeat) { /* Overlay open: ESC backs out of a category, then closes (swallowed). Otherwise * WI-1: in click-to-lock mode ESC frees the captured cursor diff --git a/port/src/watchsettings.c b/port/src/watchsettings.c index 75087515..49aae10f 100644 --- a/port/src/watchsettings.c +++ b/port/src/watchsettings.c @@ -8,6 +8,7 @@ #include "file.h" #include "file2.h" #include "front.h" +#include "language.h" /* j_text_trigger */ #include "options.h" #include "player.h" #include "system.h" @@ -50,15 +51,31 @@ static int wsStageWasActive = 0; static const char *const keys[WATCH_SETTING_COUNT] = { "Bond.Music", "Bond.FX", "Bond.Look", "Bond.AutoAim", - "Bond.AimControl", "Bond.Sight", "Bond.LookAhead", "Bond.Ammo" + "Bond.AimControl", "Bond.Sight", "Bond.LookAhead", "Bond.Ammo", "Bond.Control" }; static const u16 bits[WATCH_SETTING_COUNT] = { 0, 0, OPTION_INVERTLOOK, OPTION_AUTOAIM, OPTION_AIMCONTROL, - OPTION_SIGHTONSCREEN, OPTION_LOOKAHEAD, OPTION_DISPLAYAMMO + OPTION_SIGHTONSCREEN, OPTION_LOOKAHEAD, OPTION_DISPLAYAMMO, OPTION_CONTROLTYPE }; -/* Explicit chooser is separate from selected_folder_num (which frontoptions - * assigns FOLDER1 merely to draw its dossier background). -1 means none +/* D516: WATCH_SETTING_CONTROL is a 3-bit value (options bits 8-10), not a + * flag. The seat it addresses is chosen by the F10 Controller page. It is + * stage-only: the style lives on the live player (the front end has none). */ +static SDL_atomic_t controlSeat; +void watchSettingsSetControlSeat(int seat) +{ + SDL_AtomicSet(&controlSeat, seat < 0 ? 0 : (seat > 3 ? 3 : seat)); +} +/* The seat's live player, or NULL. Solo: g_CurrentPlayer (seat 0 only). + * Split-screen: player N owns seat N (D416). Reads only; never swaps + * g_CurrentPlayer (the poll thread is not the game thread). */ +static struct player *controlPlayer(int seat) +{ + if (getPlayerCount() > 1) return (seat >= 0 && seat < 4 && seat < getPlayerCount()) ? g_playerPointers[seat] : NULL; + return seat == 0 ? g_CurrentPlayer : NULL; +} + +/* Explicit chooser is separate from selected_folder_num (the folder file select is viewing). -1 means none * (unresolved; frontFolder() lazily defaults it). D352: guarded by uiLock; * D354: also read/written by F10 front-end contexts (input thread), which * is why the lock exists. */ @@ -69,7 +86,7 @@ static SDL_SpinLock uiLock; /* 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; }; +struct Command { int folder, field, value, commit, seat; }; /* seat: D516, WATCH_SETTING_CONTROL only */ #define CMD_CAP 128 static struct Command cmds[CMD_CAP]; static int head, count; @@ -194,6 +211,7 @@ int watchSettingsBlankValue(enum WatchSettingField field) if ((unsigned)field >= WATCH_SETTING_COUNT) return 0; if (field == WATCH_SETTING_MUSIC) return ((int)kBlankSave.music_vol << 7) | (kBlankSave.music_vol >> 1); if (field == WATCH_SETTING_FX) return ((int)kBlankSave.sfx_vol << 7) | (kBlankSave.sfx_vol >> 1); + if (field == WATCH_SETTING_CONTROL) return (kBlankSave.options & OPTION_CONTROLTYPE) >> 8; return (kBlankSave.options & bits[field]) ? 1 : 0; } @@ -234,7 +252,7 @@ static int frontFolder(void) int watchSettingsAvailable(void) { if (!stageActive()) { - /* Front end: MENU_PC_OPTIONS, or F10 over file select / title. + /* Front end: F10 over file select / title. * D354: F10 front contexts were mis-routed through the stage path * (and returned "unavailable"), disabling every Bond row. */ return validSave(frontFolder()) != NULL; @@ -250,12 +268,21 @@ static int savedValue(const save_data *save, enum WatchSettingField field) { if (field == WATCH_SETTING_MUSIC) return ((int)save->music_vol << 7) | (save->music_vol >> 1); if (field == WATCH_SETTING_FX) return ((int)save->sfx_vol << 7) | (save->sfx_vol >> 1); + if (field == WATCH_SETTING_CONTROL) return (save->options & OPTION_CONTROLTYPE) >> 8; return (save->options & bits[field]) ? 1 : 0; } int watchSettingsRead(enum WatchSettingField field) { if ((unsigned)field >= WATCH_SETTING_COUNT) return 0; + if (field == WATCH_SETTING_CONTROL) { /* D516: snapshot only; -1 = no live player */ + int v = -1; + SDL_AtomicLock(&lock); + if (stageActive() && snapFolder >= FOLDER1 && snapFolder < MAX_FOLDER_COUNT) + v = snapshot[field]; + SDL_AtomicUnlock(&lock); + return v; + } if (!stageActive()) { int f = frontFolder(), stagedVal = -1; SDL_AtomicLock(&uiLock); @@ -285,6 +312,9 @@ static int watchSettingsPersistField(int folder, enum WatchSettingField field) sysLogPrintf(LOG_WARNING, "watchsettings: persist field %d skipped: no current player", field); return 0; } + /* D516: the style is saved to the Bond file in solo only (file2.c + * fileLoadSettingsForFolder forces 1.1 for 2+ players, as on the N64). */ + if (field == WATCH_SETTING_CONTROL && getPlayerCount() != 1) return 1; save_data *save = validSave(folder); if (!save) { sysLogPrintf(LOG_WARNING, "watchsettings: persist field %d in Bond file %d: no valid save (CRC?)", field, folder + 1); @@ -318,6 +348,7 @@ static int watchSettingsPersistField(int folder, enum WatchSettingField field) * (which also applies the chosen file's audio volume to the front end). */ static void saveFrontField(int folder, enum WatchSettingField field, int value) { + if (field == WATCH_SETTING_CONTROL) return; /* D516: stage only */ save_data *save = validSave(folder); if (!save) { sysLogPrintf(LOG_WARNING, "watchsettings: save field %d in Bond file %d: no valid save (CRC?)", field, folder + 1); @@ -347,6 +378,7 @@ static void saveFrontField(int folder, enum WatchSettingField field, int value) void watchSettingsSet(enum WatchSettingField field, int value, int commit) { if ((unsigned)field >= WATCH_SETTING_COUNT || !watchSettingsAvailable()) return; + if (field == WATCH_SETTING_CONTROL && !stageActive()) return; /* D516: no live player */ if (!stageActive()) { int f = frontFolder(); SDL_AtomicLock(&uiLock); @@ -392,7 +424,7 @@ void watchSettingsSet(enum WatchSettingField field, int value, int commit) * setters fire once per frame instead of once per detent. */ for (int i = 0; i < count; i++) { struct Command *c = &cmds[(head + i) % CMD_CAP]; - if (c->field == (int)field) { + if (c->field == (int)field && (field != WATCH_SETTING_CONTROL || c->seat == SDL_AtomicGet(&controlSeat))) { c->value = value; c->commit |= commit; snapshot[field] = value; @@ -400,7 +432,7 @@ void watchSettingsSet(enum WatchSettingField field, int value, int commit) return; } } - struct Command cmd = { snapFolder, field, value, commit }; + struct Command cmd = { snapFolder, field, value, commit, SDL_AtomicGet(&controlSeat) }; if (count < CMD_CAP) { cmds[(head + count++) % CMD_CAP] = cmd; snapshot[field] = value; /* immediate UI feedback for held adjustments */ @@ -441,10 +473,35 @@ static int liveValue(enum WatchSettingField field) case WATCH_SETTING_SIGHT: return cur_player_get_sight_onscreen_control(); case WATCH_SETTING_LOOKAHEAD: return cur_player_get_lookahead(); case WATCH_SETTING_AMMO: return cur_player_get_ammo_onscreen_setting(); + case WATCH_SETTING_CONTROL: { /* D516: -1 when the seat has no live player */ + struct player *p = controlPlayer(SDL_AtomicGet(&controlSeat)); + return p ? (int)p->cur_player_control_type_0 : -1; + } default: return 0; } } +static void applyControl(int seat, int value) +{ + /* D516: same fields cur_player_set_control_type (options.c) writes. Solo + * uses the setter itself (the watch's own path); split-screen writes the + * seat's player struct directly (no g_CurrentPlayer swap, D416). */ + if (value < 0) value = 0; + if (value > 7) value = 7; + if (getPlayerCount() == 1) { + if (seat == 0) cur_player_set_control_type(value); + return; + } + struct player *p = controlPlayer(seat); + if (!p) return; + p->cur_player_control_type_0 = value; + p->cur_player_control_type_1 = value; + p->cur_player_control_type_2 = (float)value; + p->neg_vspacing_for_control_type_entry = -((j_text_trigger ? 14 : 10) * value); + p->has_set_control_type_data = TRUE; + sysLogPrintf(LOG_INFO, "watchsettings: seat %d control style %d", seat, value); +} + static void applyValue(enum WatchSettingField field, int value) { if (value < 0) value = 0; @@ -608,6 +665,11 @@ void watchSettingsGameTick(void) if (c->folder != selected_folder_num || (unsigned)c->field >= WATCH_SETTING_COUNT || !validSave(c->folder)) continue; + if (c->field == WATCH_SETTING_CONTROL) { + applyControl(c->seat, c->value); + if (c->commit && c->seat == 0) watchSettingsPersistField(c->folder, WATCH_SETTING_CONTROL); + continue; + } applyValue((enum WatchSettingField)c->field, c->value); if (c->commit) watchSettingsPersistField(c->folder, (enum WatchSettingField)c->field); } diff --git a/src/bondconstants.h b/src/bondconstants.h index d8de7ebc..e4cbb93d 100644 --- a/src/bondconstants.h +++ b/src/bondconstants.h @@ -1865,9 +1865,6 @@ typedef enum MENU MENU_SWITCH_SCREENS, MENU_DISPLAY_CAST, MENU_SPECTRUM_EMU, -#ifdef PORT - MENU_PC_OPTIONS, /* D343: PC options screen (port/src/frontoptions.c) */ -#endif MENU_MAX } MENU; diff --git a/src/game/front.c b/src/game/front.c index 5d62fe48..d7add48e 100644 --- a/src/game/front.c +++ b/src/game/front.c @@ -958,7 +958,7 @@ Gfx *constructor_menu13_mpscenario(Gfx *DL); Gfx *constructor_menu14_mpteams(Gfx *DL); Gfx *constructor_menu15_cheat(Gfx *DL); #ifdef PORT -#include "frontoptions.h" /* D343: MENU_PC_OPTIONS lives in port/src/frontoptions.c */ +#include "frontoptions.h" /* optionsFileSelectLabel: file-select "PC Options" entry */ #include "envflag.h" #endif Gfx *constructor_menu16_nocontrollers(Gfx *DL); @@ -2788,7 +2788,7 @@ Gfx *constructor_menu05_fileselect(Gfx *DL) #ifdef PORT /* D343: "PC Options" at the right end of this bar; a click enters - * MENU_PC_OPTIONS. All logic lives in port/src/frontoptions.c. */ + * the F10 overlay over file select. Logic: port/src/frontoptions.c. */ DL = optionsFileSelectLabel(DL); #endif @@ -8682,9 +8682,6 @@ void menu_init(void) case MENU_NO_CONTROLLERS: update_menu16_nocontrollers(); break; case MENU_DISPLAY_CAST: update_menu18_displaycast(); break; case MENU_SPECTRUM_EMU: update_menu19_spectrum(); break; -#ifdef PORT - case MENU_PC_OPTIONS: frontOptionsMenuUpdate(); break; -#endif } if (menu_update > MENU_INVALID) @@ -8725,9 +8722,6 @@ void menu_init(void) case MENU_NO_CONTROLLERS: init_menu16_nocontroller(); break; case MENU_DISPLAY_CAST: init_menu18_displaycast(); break; case MENU_SPECTRUM_EMU: init_menu19_spectrum(); break; -#ifdef PORT - case MENU_PC_OPTIONS: frontOptionsMenuInit(); break; -#endif } } @@ -8757,9 +8751,6 @@ void menu_init(void) case MENU_NO_CONTROLLERS: interface_menu16_nocontrollers(); break; case MENU_DISPLAY_CAST: interface_menu18_displaycast(); break; case MENU_SPECTRUM_EMU: interface_menu19_spectrum(); break; -#ifdef PORT - case MENU_PC_OPTIONS: frontOptionsMenuInterface(); break; -#endif case MENU_RUN_STAGE: if (interface_menu0B_runstage()) { @@ -8879,11 +8870,6 @@ Gfx * menu_jump_constructor_handler(Gfx *DL) break; case MENU_SPECTRUM_EMU: DL = constructor_menu19_spectrum(DL); -#ifdef PORT - break; - case MENU_PC_OPTIONS: - DL = frontOptionsMenuDraw(DL); -#endif } #ifdef PORT diff --git a/src/game/model.c b/src/game/model.c index 9d64fcac..951d698f 100644 --- a/src/game/model.c +++ b/src/game/model.c @@ -3612,41 +3612,6 @@ void modelTickAnim(struct Model *model, s32 numticks, s32 update_chrstuff) speed = model->speed; -#ifdef PORT - /* D243 M-183: clamp playspeed to a sane range during scripted - * camera modes when it exceeds a threshold. M-186 correction: - * this is NOT a D100/D140 struct-punning/pointer-widening issue - * -- Model is accessed everywhere by named field, no raw-offset - * alias exists here. The actual mechanism (still not fully root- - * caused, see findings.md D243): modelSetAnimation2 (line ~2720, - * unmodified decomp) never resets unkb0/unkb4/unkac/animrate on - * a scripted animation restart; a restart landing while unkb0 is - * stale-nonzero divides by it using a mismatched unkac/animrate - * pair from the previous animation, producing huge playspeed - * spikes (~388-410, vs. a legitimate max of ~2.0). - * M-187: was mistakenly gated on a diagnostic env var, so it - * never fired for a real player. Fixed to gate on the camera-mode test - * alone (gameScriptedCameraActive()) so it's actually active by - * default. - * M-189 (2026-09-18) found and fixed the ACTUAL root cause (a - * stale hardcoded 32-bit sizeof(Model) literal in bondview2.c's - * model-carving buffer, user-verified live): this clamp no longer - * fires in practice. It is retained as defense-in-depth against - * any future out-of-range playspeed reaching a scripted camera. - * RULE-2-SIGNOFF (2026-09-19, release review): ungated - * behavior-modifying guard in src/game, approved for retention - * by the user as the stability-maximizing option for v0.3.0; - * candidate for removal post-release once all cutscenes are - * re-verified clean without it (findings.md D243 / M-192). - * Threshold: 10.0 is way above any legitimate playspeed (normal - * is ~1.0, max observed in gameplay is ~2.0). */ - extern int gameScriptedCameraActive(void); - if (gameScriptedCameraActive() && playspeed > 10.0f) - { - playspeed = 1.0f; - } -#endif - frame += playspeed * speed; if (model->anim2 != NULL) @@ -3678,31 +3643,6 @@ void modelTickAnim(struct Model *model, s32 numticks, s32 update_chrstuff) animlast = model->anim->unk04 - 1; endframe = model->endframe; -#ifdef PORT - /* D243 M-185: clamp corrupted endframe values during scripted - * camera modes, producing huge negative values like - * -604462909807314587353088.0 that cause the animation to loop - * indefinitely or behave unexpectedly. Clamp to a reasonable - * range (0-1000) to prevent this. - * M-186 correction: NOT a D100/D140 struct-punning/pointer- - * widening issue -- see the M-183 comment above for the - * corrected mechanism and for M-189's actual root-cause fix - * (stale 32-bit sizeof(Model) literal in bondview2.c); with that - * fix in place this clamp no longer fires in practice and is - * retained as defense-in-depth. RULE-2-SIGNOFF (2026-09-19, - * release review): approved for retention by the user for - * v0.3.0; candidate for removal post-release (findings.md D243 / - * M-192). - * M-187: was mistakenly gated on a diagnostic env var, so it - * never fired for a real player. Fixed to - * gate on the camera-mode test alone (gameScriptedCameraActive()). */ - extern int gameScriptedCameraActive(void); - if (gameScriptedCameraActive() && (endframe < 0.0f || endframe > 1000.0f)) - { - endframe = 100.0f; - } -#endif - if (endframe); if (speed >= 0.0f) diff --git a/tools_pc/bundle-linux.sh b/tools_pc/bundle-linux.sh index 53f665a9..5e58e71c 100755 --- a/tools_pc/bundle-linux.sh +++ b/tools_pc/bundle-linux.sh @@ -11,7 +11,7 @@ # goldeneye-pc-port--linux-x86_64.tar.gz + .tar.gz.sha256 # # The bundle contains ONLY: the engine executable, its SDL2 library, a README, -# license texts, and the prepare-assets/ tool. It contains NO ROM and NO game +# license texts, the prepare-assets/ tool and tools/eep_convert.py. It contains NO ROM and NO game # assets. SDL2 is bundled (copied next to the exe, whose rpath is set to # $ORIGIN) so the tarball runs as-is on any distro — including a sideloaded # Steam Deck — with nothing installed; zlib and libGL are expected from the @@ -96,6 +96,10 @@ cp LICENSE "$OUT/licenses/LICENSE-port-MIT.txt" SDL2_LIC="$(dpkg -L libsdl2-2.0-0 2>/dev/null | grep -m1 '/copyright$' || true)" [ -n "$SDL2_LIC" ] && [ -f "$SDL2_LIC" ] && cp "$SDL2_LIC" "$OUT/licenses/SDL2.txt" +# --- save converter (D492): emulator .eep <-> port .eep, stdlib-only ----- +mkdir -p "$OUT/tools" +cp tools_pc/eep_convert.py "$OUT/tools/" + # --- asset-prep tool (identical assembly to bundle-win.sh) ----------- PREP="$OUT/prepare-assets" mkdir -p "$PREP/vendor/scripts" "$PREP/vendor/assets/obseg" diff --git a/tools_pc/bundle-win.sh b/tools_pc/bundle-win.sh index 7a6efa5a..4ee2e709 100755 --- a/tools_pc/bundle-win.sh +++ b/tools_pc/bundle-win.sh @@ -85,6 +85,10 @@ for l in SDL2 zlib gcc-libs libwinpthread mingw-w64; do [ -d "$MINGW/share/licenses/$l" ] && cp -r "$MINGW/share/licenses/$l" "$OUT/licenses/$l" done +# --- save converter (D492): emulator .eep <-> port .eep, stdlib-only ----- +mkdir -p "$OUT/tools" +cp tools_pc/eep_convert.py "$OUT/tools/" + # --- asset-prep tool ------------------------------------------------- # The port needs two ROM-derived directories (data/pcmodels-/ and # data/pccg-/) that we cannot ship. prepare-assets.py regenerates diff --git a/tools_pc/dist/README.md.in b/tools_pc/dist/README.md.in index cfb49822..3ea723ad 100644 --- a/tools_pc/dist/README.md.in +++ b/tools_pc/dist/README.md.in @@ -86,6 +86,7 @@ Everything stays inside this folder; nothing is written anywhere else. | File | What it is | |---|---| | `data/ge007.eep` | Your game progress. Copy it somewhere safe (with the game closed) to back it up. | +| `tools/eep_convert.py` | Converts a save from an N64 emulator (1964, Project64, ...) to this port, or back: `python tools/eep_convert.py n64-to-pc my-save.eep data/ge007.eep`. A straight copy of an emulator save is wiped on first launch. Needs Python 3; back up `data/ge007.eep` first. | | `data/ge007.ini` | Your settings. | | `data/pcmodels-*/`, `data/pccg-*/`, `data/ge007.shaders` | Generated files. Safe to delete; they are made again. | | `ge007.crash.log` | Written in the folder you launched from if the game crashes. | diff --git a/tools_pc/eep_convert.py b/tools_pc/eep_convert.py new file mode 100644 index 00000000..e7c309f8 --- /dev/null +++ b/tools_pc/eep_convert.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""eep_convert.py — convert GoldenEye 007 `.eep` saves between N64 emulators +(1964, Project64, ...) and this PC port (D492). + +Why conversion is needed: the save layout is byte-identical on both sides +(2048 B; block 0 `smallSave`, five 96-byte `save_data` slots from block 4 — +see `src/game/file.h`), but this port stores a few multi-byte fields in +little-endian host order: per slot the `chksum1`/`chksum2` words (@0/@4) and +the u16 `options` (@12), plus block 0's checksum pair. Emulator saves store +them big-endian. The CRC (`fileGenerateCRC`, `src/game/crc.c`) hashes raw +bytes, so the checksum VALUE is endian-independent — but an unconverted save +fails validation and all five slots are wiped on first boot. + +Only regions that validate in the source convention are converted; a +failing region is left as-is so the game wipes it on either side, and a file +where nothing validates (wrong direction, already converted) is refused. + +Conversion per slot: byte-swap `options` (it lies INSIDE the slot CRC range +[8..96), so a plain word swap would still fail), then recompute +`chksum1/2` over [8..96) and store them in the target endianness. Block 0 +has no multi-byte field inside its CRC range, so its checksum pair is simply +re-stored in the other byte order. + +The CRC replica below is a faithful transcription of `fileGenerateCRC` + +`randomGetNextFrom` (`src/random.s`) and was verified against the binary in +the D492 investigation. + +Usage: + eep_convert.py n64-to-pc IN.eep OUT.eep # emulator save -> port save + eep_convert.py pc-to-n64 IN.eep OUT.eep # port save -> emulator save + eep_convert.py verify IN.eep [--as n64|pc] + Check every checksum without writing. Without --as, tries both + conventions and reports which one validates. + +Exit codes: 0 ok / validated, 1 validation failed (convert: nothing in the +source validates, so nothing is written), 2 usage or bad input. +IN may equal OUT (the file is read fully before it is written), but make a +backup first anyway. +""" +import argparse +import struct +import sys + +M64 = (1 << 64) - 1 + + +def randomGetNextFrom(seed): + """Port of src/random.s (verified against the binary, D492).""" + a3 = seed + a2 = (((a3 << 63) & M64)) >> 31 # dsll32 .1f then plain dsrl .1f + a1 = ((a3 << 31) & M64) >> 32 # dsll .1f then dsrl32 0 + a3 = ((a3 << 44) & M64) >> 32 # dsll32 .c then dsrl32 0 + a2 = (a2 | a1) ^ a3 + a3 = ((a2 >> 20) & 0xFFF) ^ a2 + return a3, a3 & 0xFFFFFFFF + + +def fileGenerateCRC(data): + """Port of src/game/crc.c fileGenerateCRC (verified against the binary).""" + poly = 0x8F809F473108B3C1 + c1 = c2 = 0 + shift = 0 + for b in data: + poly = (poly + (b << (shift & 0xF))) & M64 + poly, v = randomGetNextFrom(poly) + c1 ^= v + shift += 7 + for b in reversed(data): + poly = (poly + (b << (shift & 0xF))) & M64 + poly, v = randomGetNextFrom(poly) + c2 ^= v + shift += 3 + return c1, c2 + + +LEVELS = ["Dam", "Facility", "Runway", "Surface 1", "Bunker 1", "Silo", + "Frigate", "Surface 2", "Bunker 2", "Statue", "Archives", "Streets", + "Depot", "Train", "Jungle", "Control", "Caverns", "Cradle", "Aztec", + "Egypt"] + + +def decode_times(t): + """t: the slot's 76-byte times[] bitfield -> {(level, diff): time}.""" + out = {} + for d in range(3): # 007 mode has no per-level field + for l in range(20): + off = (d * 20 + l) * 10 + i = off >> 3 + r = 7 - (off & 7) + if r == 7: v = ((t[i] & 0xFF) << 2) | ((t[i + 1] & 0xC0) >> 6) + elif r == 5: v = ((t[i] & 0x3F) << 4) | ((t[i + 1] & 0xF0) >> 4) + elif r == 3: v = ((t[i] & 0x0F) << 6) | ((t[i + 1] & 0xFC) >> 2) + elif r == 1: v = ((t[i] & 0x03) << 8) | (t[i + 1] & 0xFF) + else: v = -1 + if v: + out[(l, d)] = v + return out + + +def read_eep(path): + try: + raw = open(path, "rb").read() + except OSError as e: + print(f"error: cannot read {path}: {e}", file=sys.stderr) + sys.exit(2) + if len(raw) != 2048: + print(f"error: {path} is {len(raw)} bytes; a ge007.eep is 2048", + file=sys.stderr) + sys.exit(2) + return bytearray(raw) + + +def verify(data, endian): + """Check block 0 + all five slots as stored in `endian` ('>' or '<'). + Returns (all_ok, [per-region 'OK'/'FAIL' lines]).""" + lines = [] + ok = True + # CRC ranges are slot-relative: block 0 covers unk[0..24) at [8..32), + # each slot covers [8..96) of its 96 bytes. + for name, off, cfrom, cto in (("block0", 0, 8, 32), + *[(f"slot {i}", 32 + i * 96, 8, 96) + for i in range(5)]): + stored = struct.unpack(endian + "II", bytes(data[off:off + 8])) + calc = fileGenerateCRC(bytes(data[off + cfrom:off + cto])) + good = stored == calc + ok &= good + lines.append(f" {name}: stored={stored[0]:08x},{stored[1]:08x} " + f"calc={calc[0]:08x},{calc[1]:08x} {'OK' if good else 'FAIL'}") + return ok, lines + + +def summary(data): + """One line per non-empty slot (decoded before any conversion).""" + for i in range(5): + off = 32 + i * 96 + s = bytes(data[off:off + 96]) + flags = s[8] + done = decode_times(s[18:94]) + folder = (flags >> 5) & 7 + if done or folder: + lvls = sorted({l for l, d in done}) + print(f"slot {i}: folder={folder} bond={(flags >> 1) & 3} " + f"completed {len(done)} (level,diff) entries; levels: " + f"{[LEVELS[l] for l in lvls]}") + + +def region_ok(data, off, cfrom, cto, endian): + stored = struct.unpack(endian + "II", bytes(data[off:off + 8])) + return stored == fileGenerateCRC(bytes(data[off + cfrom:off + cto])) + + +def convert(data, src_endian, dst_endian): + """Byte-swap the multi-byte fields and re-stamp the checksum pairs of + every region that validates in the SOURCE convention. A region that fails + in the source is left untouched, so it also fails in the target and the + game wipes it exactly as it would have on the original side (re-stamping + it would turn garbage into a 'valid' slot). Returns the list of + (name, converted) pairs.""" + done = [] + # Block 0: no multi-byte field inside its CRC range -> plain word swap. + ok = region_ok(data, 0, 8, 32, src_endian) + if ok: + st = struct.unpack(src_endian + "II", bytes(data[0:8])) + data[0:8] = struct.pack(dst_endian + "II", *st) + done.append(("block0", ok)) + # Slots: options (u16 @12) is INSIDE the CRC range [8..96): swap it, + # then recompute chksum1/2 over the new bytes and store in dst order. + for i in range(5): + off = 32 + i * 96 + ok = region_ok(data, off, 8, 96, src_endian) + if ok: + w = struct.unpack_from(src_endian + "H", data, off + 12)[0] + struct.pack_into(dst_endian + "H", data, off + 12, w) + c1, c2 = fileGenerateCRC(bytes(data[off + 8:off + 96])) + struct.pack_into(dst_endian + "II", data, off, c1, c2) + done.append((f"slot {i}", ok)) + return done + + +def main(): + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + def add_convert(name, help_): + sp = sub.add_parser(name, help=help_) + sp.add_argument("INFILE", metavar="IN.eep") + sp.add_argument("OUTFILE", metavar="OUT.eep") + + add_convert("n64-to-pc", "convert an emulator (1964/Project64) save to the port format") + add_convert("pc-to-n64", "convert a port save back to the emulator format") + + sp = sub.add_parser("verify", help="check every checksum without writing") + sp.add_argument("INFILE", metavar="IN.eep") + sp.add_argument("--as", dest="endian", choices=("n64", "pc"), + help="which convention to check (default: try both)") + + a = p.parse_args() + data = read_eep(a.INFILE) + summary(data) + + if a.cmd == "verify": + results = {} + for label, endian in (("n64", ">"), ("pc", "<")): + if a.endian and a.endian != label: + continue + ok, lines = verify(data, endian) + results[label] = ok + print(f"as {label} ({'big' if label == 'n64' else 'little'}-endian words):") + for ln in lines: + print(ln) + if a.endian: + sys.exit(0 if results[a.endian] else 1) + n = sum(results.values()) + if n == 2: + print("result: valid under BOTH conventions") + sys.exit(0) + if n == 1: + good = next(k for k, v in results.items() if v) + bad = next(k for k, v in results.items() if not v) + cmd = "n64-to-pc" if good == "n64" else "pc-to-n64" + dest = "the port" if good == "n64" else "an emulator" + print(f"result: valid as {good}; NOT valid as {bad} — to use it " + f"with {dest}: eep_convert.py {cmd} IN.eep OUT.eep") + sys.exit(0) + print("result: INVALID under both conventions (corrupt or foreign file)") + sys.exit(1) + + # convert commands + src, dst = (">", "<") if a.cmd == "n64-to-pc" else ("<", ">") + src_label = "n64" if src == ">" else "pc" + done = convert(data, src, dst) + if not any(ok for _, ok in done): + # Nothing validates as the claimed source: wrong direction, an + # already-converted file, or not a GoldenEye save. Write nothing. + print(f"error: {a.INFILE} does not validate as {"an" if src_label == "n64" else "a"} {src_label} save; " + f"nothing written. Run 'eep_convert.py verify {a.INFILE}' to " + f"see which format it is.", file=sys.stderr) + sys.exit(1) + for name, ok in done: + if not ok: + print(f" {name}: invalid in the source, left as-is " + f"(the game will wipe it, as it would have before)") + with open(a.OUTFILE, "wb") as f: + f.write(data) + n = sum(ok_ for _, ok_ in done) + print(f"wrote {a.OUTFILE} ({n}/6 regions converted)") + sys.exit(0) + + +if __name__ == "__main__": + main()