diff --git a/README.md b/README.md index 69f2fa49..9c51c3fb 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ items, and which ones are waiting on a decision, lives in one place: - **PAL and JP ROM support**. NTSC-U is the only supported region today. - The remaining small accuracy differences (audio timbre, a few cosmetic rendering details). -- **macOS/ARM builds** and controller-button rebinding. +- **macOS/ARM builds**. - **Ongoing security/fidelity review**: a review pass after each release to keep the [security policy](.github/SECURITY.md) and the known-issues list current, moving the mechanical parts (dependency/secret scanning) toward automation. @@ -356,9 +356,11 @@ click crouch). The watch gadget cycle selects the next owned gadget in the N64 inventory. The controller layout matches Rare's Xbox 1.1 (Jinx) button roles (user-tested on a physical pad; D394), and the remaining Xbox schemes (1.2 Christmas, 1.3 Frost, 1.4 Elektra) are planned as selectable presets -and not yet available. Keyboard → Bindings edits **keyboard/mouse only**; a -controller can navigate those pages and use B to go back, but -controller-button rebinding is not supported yet. +and not yet available. Keyboard → Bindings edits **keyboard/mouse only**. +Controller buttons are rebindable per controller under *Input → Controller* +(F10 overlay or the front-end PC Options): pick *Custom* and bind each +action by pressing a pad button (tap B or Back to cancel, hold Back to +clear, hold B to bind B). Menus always keep A/X accept and B/Y cancel. Mouse sensitivity, Y-inversion and the aim/turn split are tunable in the F10 overlay (*Keyboard → Sensitivity*) or the `[Input]` section of diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 579c8041..84a69b06 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -112,7 +112,7 @@ below). | Steam Deck: a first launch in Desktop Mode skips the Deck preset | Settings | **Fixed on `release/v0.4.1`** (real-Deck check owed); delete this row when it ships. Until then: first launch in Game Mode, or delete `ge007.ini` and relaunch | D283, §4 | | ~30 fps on very low-end GPUs (e.g. Intel HD 400) | Performance | Set `Video.LowEndMode=1` in `ge007.ini` | D339, #92, §4 | | No macOS or ARM builds | Platform | — | #88, #95, #101, §4 | -| Controller buttons can't be rebound in-game (keyboard/mouse can) | Missing feature | — | #109, §5a | +| Controller buttons can't be rebound in-game (keyboard/mouse can) | Missing feature | **Fixed on `release/v0.4.1`** (D469, F10 → Input → Controller); delete this row when it ships | D469, #109, §5a | --- @@ -133,7 +133,7 @@ below). | Auto-fire gate at unstable 40–55 fps | partial | D427 | Exact at a steady 60 or 30 fps. At fluctuating rates it can flip between 1× and 2×, because `field_88C` is never rescaled. | | Third-person Bond offset residual | partial | D173, D292 | Mostly fixed. A small drift remains about 1 time in 10. | | Statue corner flicker at max FOV | verify | D294 | Room-pool cap raised 2.0 -> 2.5 (2026-09-30; Statue needs about 2.17x). It is unproven: the exhaustion log never fired at spawn. **Owed:** strafe fast past the Statue corners at max FOV and max draw distance. | -| Gameplay visibility vs modern view options (FOV, ultrawide) | verify | D466, D468, D334, D222; maintainer's local policy note | **Policy approved 2026-10-01 and implemented:** AI awareness may see what an N64 player could have seen in some configuration the cartridge supports. D466: the AI uses the authored draw distance (the player can still shoot, use and photograph whatever is drawn). Widescreen up to 16:9 is faithful (the cartridge's own Ratio 16:9 mode). **D468 (merged 2026-10-01):** under ultrawide or `Video.FovScale` > 100 the AI's on-screen view is clamped to the 16:9 / game-FOV view; option `Game.AIWideView` (F10 Gameplay, default off) gives the AI the full view. **Owed:** by-eye check on an ultrawide window or at a high FOV. Known D466 residual: chrs in the extended band take the visible animation / WAYMODE_MAGIC path. | +| Gameplay visibility vs modern view options (FOV, ultrawide) | verify | D466, D468, D334, D222; maintainer's local policy note | **Policy approved 2026-10-01 and implemented:** AI awareness may see what an N64 player could have seen in some configuration the cartridge supports. D466: the AI uses the authored draw distance (the player can still shoot, use and photograph whatever is drawn). Widescreen up to 16:9 is faithful (the cartridge's own Ratio 16:9 mode). **D468 (merged 2026-10-01):** under ultrawide or `Video.FovScale` > 100 the AI's on-screen view is clamped to the 16:9 / game-FOV view; option `Game.AIWideView` (F10 Gameplay, default off) gives the AI the full view. **Owed:** by-eye check on an ultrawide window or at a high FOV. D466 follow-up (2026-10-01, maintainer decision): movement/locomotion LOD follows the render, only awareness uses the N64 distance. | | Golden Gun plays the empty click with each shot | open | #130 | Outside report: the out-of-ammo sound plays alongside the firing sound, unlike 1964. Check by ear together with the D433 auto-fire A/B (maintainer-owned). | | A mine in Control's mainframe room didn't destroy anything | open | #126 | Outside report; the reporter is unsure. Sanity-check on 1964 first (the objects may be indestructible there). | | 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. | @@ -200,15 +200,15 @@ Background research lives in the maintainer's local notes (gitignored): | "Original N64" preset: pillarbox at the original aspect | verify | D440, D447 | Landed 2026-09-30 (D447): `Video.AspectMode` (Window/Original), set by the Original N64 preset. Exact 4:3, or 16:9 while the game's watch Ratio is 16:9. Letterboxes in taller windows. `Crop overscan` no longer eats the game's Wide/Cinema letterbox. Measured: 160 px bars at 1280x720. Window-mode frame diff is within the run-to-run noise floor (dmean 0.93 vs base-vs-base 0.85). **Owed:** compare 4:3 and 16:9 against 1964; Wide/Cinema with crop on; menu mouse alignment in Original mode; MSAA 4/8/16, fullscreen toggle, Linux build. F10 full-window is deferred to an F10 polish pass. Known: with crop off, the D246 one-unit edge line shows at the rect edges. | | `All unlocked` rework: RAM override instead of save patching | verify | 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. **Owed:** every level at every difficulty with a cheat on; Magnum/Laser/Golden Gun rows; MP characters; OFF restores real progress. | | Settings units and values cleanup | verify | 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). **Owed:** the new values in both settings UIs. | -| Restart button in the settings menus | verify | 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. | +| 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. | | Front-end PC Options: Previous/Next paging for mouse users | verify | D444 | **Landed 2026-09-30 (D444):** Previous/Next page buttons; hover no longer snaps the wheel selection. **Owed:** a mouse pass through Input and Graphics. | | 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. **#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 rebinding + modern controller presets | open | PR #109, D394 | QoL (maintainer, 2026-09-30), one item: rebindable controller buttons, plus presets taken from the modern Xbox Series X release's control scheme (the D394 "Jinx" 1.1 layout, with a 1.2/1.3 selector). The remaining D394 gap is the **gadget category**: the camera, the GoldenEye key copier, bombs, and other devices planted on things. Also pad-initiated binding capture. Keyboard/mouse rebinding has shipped. Review #109 under the feature design-review policy first. | +| Gamepad rebinding + modern controller presets | verify | D469 (supersedes PR #109), D394 | **Landed 2026-10-01 (D469):** data-driven pad action table; `Input.PadPreset` (0 = Jinx 1.1, default, proven identical to the old mapping over 20,971,520 synthetic cases; 3 = Custom); per-seat `Input.Pad[N].` bindings; F10 / front-end "Controller..." page with pad capture (B/Back cancel, hold Back / Y clear); menu lockout guard; credited to italoarruda (#109). **Maintainer live test 2026-10-01:** bind, cancel, clear, 2P per-seat tables, hot-plug and relaunch persistence pass; the Restart row (D443) works. **Still owed:** more play with the default preset ("plays as before") and longer controller-only use. **Not done:** Jinx 1.2/1.3 tables (D394 has no reference tables; presets 1/2 hidden), a per-seat preset, a warning when two actions share a button. | | Per-pad tuning (device index, hot-plug seats) | verify | D448, D450 (#122) | Pad seats keyed by SDL instance id, reserved in a match; hot-plug events reach the input layer. Merged with split-screen 2026-10-01. **Owed:** unplug and replug a pad mid-match. | -| Full-gamepad menu navigation audit, Xbox/PS button glyphs | open | — | Menus are only partly audited for pad-only use. There are no glyphs yet. | +| Full-gamepad menu navigation audit, Xbox/PS button glyphs | verify | D471; local note `PAD-MENU-AUDIT.md` | **Audit done 2026-10-01:** a controller-only player is never trapped in the PC menus. **Glyphs: not wanted** (maintainer); instead D471 shows family-specific button **names** (PlayStation/Nintendo) in the help lines and binding column, Xbox text unchanged, plus player-index LEDs (PD-port parity). **Audit G1-G3 fixed by D472** (owner-pad input). **Owed:** a real PS/Nintendo pad check. | | 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. | -| F10 overlay polish | open | D335b, #90 | Pillarbox the overlay in widescreen; colour experimental rows red; warn on unknown `ge007.ini` keys (promised in #90; today they are only logged). | -| Master volume, audio output device select | open | — | | +| F10 overlay polish | verify | D472, D335b, #90 | **Landed 2026-10-01 (D472):** overlay pillarboxed to the centred 4:3 card in widescreen (mouse mapped through the same region); EXPERIMENTAL rows in red; unknown `ge007.ini` keys shown as a non-blocking "N UNKNOWN INI KEY(S) - SEE LOG" in the title bar (#90's promise); the overlay is driven by the pad that opened it (audit G1-G3). **Owed:** 2-pad ownership live check and mouse hit-testing at 16:9/21:9 (checklist in D472). | +| Master volume, audio output device select | verify | D470 | **Landed 2026-10-01 (D470):** `Audio.MasterVolume` (0-100, default 100 = byte-identical output; gain applied after the game's music/FX volumes, live) and `Audio.Device` (default = system default; switch on the audio producer thread; missing device or removal falls back to default). Rows at the top of F10 / front-end AUDIO. **Owed:** listening checklist in the D470 finding (master 100 unchanged, slider live, device switch, persistence, unplug, bogus name). | | 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. | ### 5b. GE+ / Dab's-mod gameplay options (opt-in; touch game code) @@ -263,7 +263,7 @@ and a stage loader. | Scripted-playthrough harness | open | `GE_INPUTSCRIPT` | Input script + `GE_PCDUMP` + crash-log check, for unattended regression runs. The tool exists; the harness doesn't. | | Golden baselines | open | `tools_pc/verify.sh`, D117 | bunker1 has been noisy since M-84, so the per-patch gate is unreliable. Re-base it, extend to all 21 levels × both platforms, and finish `GE_DETERM`. | | 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. **Left:** dead registry rows (D116, D51, D56, D60-D63, D69*, D71LOG, D85DUMP, D86-D88, D90, D96, D104, D154, D178); closed-finding TEMPs (`GE_D306C`, `GE_DYNTEXHASH_OFF`, `GE_FORCEALARM`, `GE_D204_OLD`, `GE_D252`, `GE_D243X2/X4`, `GE_D318T`, `GE_RSEED_LV`, `GE_D235_*` once D235 closes); the now-inert sky tile-capture/shift machinery in `sky.c` (`skyPortCaptureTile`, `skyPortPickShift`, `GE_D245_FIXEDSHIFT`); and the M-183/M-185 clamps, which need a full cutscene re-check first. | +| 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. **Left:** dead registry rows (D116, D51, D56, D60-D63, D69*, D71LOG, D85DUMP, D86-D88, D90, D96, D104, D154, D178); closed-finding TEMPs (`GE_D243X2/X4` in `chr.c`'s cutscene path, `GE_D235_*` once D235 closes; **removed 2026-10-01:** `GE_D306C`, `GE_D318T` (batch 2); `GE_DYNTEXHASH_OFF`, `GE_FORCEALARM`, `GE_D204_OLD`, `GE_D252`, `GE_RSEED_LV` were already gone); the now-inert sky tile-capture/shift machinery in `sky.c` (`skyPortCaptureTile`, `skyPortPickShift`, `GE_D245_FIXEDSHIFT`); 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. | | Docs site: Web 1.0 phase 2 | decision | — | Phase 1 shipped. Phase 2 (centred serif page) is plan-only. | diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md index 1d639f5b..a6b4a428 100644 --- a/docs/dev/GE-ENV-PROBES.md +++ b/docs/dev/GE-ENV-PROBES.md @@ -33,6 +33,7 @@ Two classes: | `GE_D204` | `port/src/audio.c` (`audioSetNextBuffer`) | **Audio-health monitor (D204).** One line per 5 s of wall clock: `rt=` produced-audio/wall ratio (1.000 healthy; below 1 = the 30 Hz audio retrace is starving and the device is padding with silence), `q=` SDL queue depth / `queueLimit` (pinning at the limit with `drop=` climbing = overproduction, the "eventually no audio" state), `drop=` blocks discarded, `max=` largest block vs its `g_MaxFrameSize*4` allocation. **Deliberately cheap (~30 clock reads/s) — safe to leave on for a full playtest, unlike `GE_MIXERTRACE`.** The instrument of record for D202. | **live** | | `GE_D204_OLD` | `port/src/audio.c` (`d204OldMode`) | Restores pre-D204 behaviour (`osAiGetLength()` reports the whole SDL queue depth; `queueLimit` back to 8192) so before/after can be A/B'd in **one binary**, without build-to-build variance. Temporary — remove when D204 closes. | **dead** (D204 closed) — removed 2026-09-30 (ROADMAP §7 strip pass, batch 1, branch `chore/scaffolding-removal-1`); tombstone — the generator flags it GONE | | `GE_AUDIOTRACE` | `src/snd.c` (`sndPlaySfx`, `sndDeactivate`), `src/game/propobj.c` (door sounds), `src/libultra/audio/load.c` (`alLoadParam`) | Per-`sndPlaySfx` resolution trace to `audiotrace.log`: `soundIndex` → `ALSound*`/keyMap/wavetable/base/len/book, the returned `ALSoundState*`, every `sndDeactivate`, and wavetable bind events. M-65 additions: `[VOICE+]`/`[VOICE-]` pairing every voice acquire/release with sound/state/flags (NB: written from two threads to one unbuffered FILE* — occasional torn lines, parse defensively), `[SLOTWRITE]` door slot ownership, `[RETRIGGER-POST]`, `[WAVELOOP]` (per-sound `ALADPCMloop` presence), `[DOORSND]` every door sound state change incl. an `ORPHAN-both-slots-busy` verdict. M-66b addition: `[EXPIRE]` — posted whenever the port-side ownerless-infinite-loop expiration is scheduled (sound/state/delay/fade); this line is **permanent** (part of the D202 option-C fix), unlike the rest of the probe set which is removable on close. M-67/M-69 additions: `[WIRE] t=µs filter=… <- table=…` (`src/libultra/audio/load.c`, every wavetable→decoder bind, µs-timestamped) and `[VOICEMAP]` (`src/libultra/audio/synthesizer.c`, full PVoice→decoder/resampler/env-state address map at synth init — this is how offline voicedump slot addresses tie to WIRE filter addresses). M-68 additions: `[EVT] t=µs type=N state=P` + `[STOP-EVT] … deltaUs=N` (event types per `src/snd.h`: 1=PLAY, 2=STOP, 8=VOL, 64=DECAY, 128=END). M-71 additions: `[VOL] t=µs state=… rawVol=… playing=…` (`src/snd.c`, per-VOL-event value before the slot-volume law) and `[DISTVOL] pos=(…) player=(…) dist=… vol=…` (`src/game/propobj.c`, the distance-attenuation funnel `sub_GAME_7F053894` — this is how an SFX's final volume is computed from hit position). NB: lines from the multiple file handles interleave — occasional torn/corrupt lines, parse defensively. `dumppos=` gives the exact byte offset into `audiodump.raw` (quantised to the 2880-byte audio block). Low cost. | **live** (D202/D205) | +| `GE_AUDIOGAINLOG=1` | `port/src/audio.c` (`audioSetNextBuffer`, cached `GE_ENVFLAG`) | D470: once per second logs `D470 gain: master=N peak=P`, the max abs sample of the final (post master-volume) buffer. Cheap; shows the gain effect headlessly. | **live** | | `GE_PULLTRACE` | `src/libultra/audio/load.c` (`alAdpcmPull`) | `[PASTEND]` assertion: fires if a voice's `f->sample` exceeds its wave's total samples while `nOver == 0` (i.e. an unstopped voice keeps decoding past the end instead of zero-filling). Rate-limited to 40 lines. M-65: 0 hits — exhausted voices go silent correctly. | **live** (D202) | | `GE_DMEMWIPE` | `port/src/mixer.c` (`aClearBufferImpl`) | Zeroes the whole software DMEM at the frame boundary (detected as `alMainBusPull`'s clear of `AL_MAIN_L_OUT`, addr 1088). Tests whether audio state survives across frames in DMEM. M-65: no-op on output — ruled out. | **live** (D202) | | `GE_NOWET` | `port/src/mixer.c` (`aSetVolumeImpl`) | Mutes the reverb send (`wetamt = 0`), isolating the reverb delay lines as a possible source of persistent signal. M-65: drone unchanged — ruled out (NB `aPoleFilterImpl` is still an unimplemented no-op, D199). | **live** (D202) | @@ -74,7 +75,7 @@ Two classes: | `GE_D245_OLDVTX=1` / `GE_D245_OLDTEX=1` | `src/game/sky.c` (`skyPortRenderPoly`) / `port/fast3d/gfx_pc.cpp` (CI8 import + tri-path dims) | **A/B** (M-201): restore the pre-fix s16 sky/water vertex path / the pre-fix 32x43, 16-wide water texture geometry. | **dead** (D245 fixed) — removed 2026-09-30 (ROADMAP §7 strip pass, branch `chore/strip-temp-probes`); tombstone — the generator flags it GONE. The pre-fix s16 sky/water vertex path (`skyPortRenderPoly` tail + `skyPortEmitTileShift`) went with it; the float path is now unconditional | | `GE_DYNTEXHASH_OFF=1` | `port/fast3d/gfx_pc.cpp` (`import_texture` cache key) | **A/B** (M-201, D341): disable the content-hashed cache key for dyn-pool textures (reverts to the stale address-only key). | **dead** (D341 closed) — removed 2026-09-30 (ROADMAP §7 strip pass, batch 1, branch `chore/scaffolding-removal-1`); tombstone — the generator flags it GONE | | `GE_D207S` | `src/game/gunfire.c` (player shot-sound block, `#ifdef PORT`) | **TEMP** (M-201): one line per player shot-sound decision: `D207S t= hand item rate -> H1|H2|SKIP` (SKIP = both per-hand sound handles still held). Read-only, uncapped, cheap; use instead of `GE_AUDIOTRACE` for live captures (that probe does file I/O on the audio path). | **dead** (D207/D241 closed) — removed 2026-09-30 (ROADMAP §7 strip pass, branch `chore/strip-temp-probes`); tombstone — the generator flags it GONE | -| `GE_D306C="f0 f1 [wmax]"` | `port/fast3d/gfx_pc.cpp` (after `zf_note_emit`) | **TEMP** (M-201): depth-state census of every emitted triangle in frames f0..f1 (GL depth mode, geometry_mode, other_mode_l, tile-0 texture, NDC centroid, z/w range, min w); optional `wmax` keeps near geometry only. Cap 60000 lines. | **TEMP** (D306 closed; remove post-release) | +| `GE_D306C="f0 f1 [wmax]"` | `port/fast3d/gfx_pc.cpp` (after `zf_note_emit`) | **TEMP** (M-201): depth-state census of every emitted triangle in frames f0..f1 (GL depth mode, geometry_mode, other_mode_l, tile-0 texture, NDC centroid, z/w range, min w); optional `wmax` keeps near geometry only. Cap 60000 lines. | dead — fully removed 2026-10-01 (ROADMAP §7 scaffolding strip, batch 2; kept here as a tombstone) | | `GE_INTROCAM=` | `src/game/bondview_r.c` (`#ifdef PORT`, intro camera pick) | **TEMP/test-only**: force the level intro's first camera index (Dam truck shot = 5). The PRNG draw is still consumed. Logs `D306: intro camera index` when set (or with `GE_D306C`). | **TEMP** (useful repro tool) | | `GE_STARTWEAPON=` | `src/game/bondview_r.c` (`#ifdef PORT`, after the start-inventory setup) | **TEMP/test-only** (M-201, D303): adds the item + 400 rounds of its ammo and makes it the starting right-hand weapon (M16 = 13). Pair with `GE_INPUTSCRIPT` `ZHOLD`. | **TEMP** (useful repro tool) | | `GE_D235` | `src/boss.c` (per-stage `while (!done)` block, after `memaReset`; `#ifdef PORT`) | D235 evidence probe: logs how many GL-texture-cache entries the per-stage `videoResetTextureCache()` call purges at each level load. A cold boot into a level should log 0; a death/restart (or any transition from a level that uploaded textures) logs >0 — direct evidence of the stale-entry mechanism. The clear itself is **not** gated on this var (it's the fix, see below); the var only controls the log line. | **live** (D235) | @@ -89,8 +90,10 @@ Two classes: | `GE_PDMOUSEAIM` | `port/src/input.c:487` (`pdMouseAimEnabled`) | Force on/off the PD-style mouse-aim model (D194 lineage); unset = default (on in N64 aim mode unless legacy GEPD aim is selected). | **live** | | `GE_QUITFRAME` | `port/src/video.c:966` | D344 harness clean self-quit: exits after frame `` completes and swaps (replaces `timeout` kills; the host never exits under an in-flight frame). Cached. | **live** (harness) | +| `GE_PADMAPTEST` | `port/src/input.c` (`inputInit` -> `padMapSelfTest`) | D469: boot-time self-test of the gamepad action table. Proves preset 0 == the legacy hard-wired mapping (20,971,520 synthetic cases), logs the per-source mask table, a Custom remap, per-seat independence, the Start guard and the binding API; restores config afterwards (nothing persisted); the game then continues. Cached (`GE_ENVFLAG`). | **live** (harness) | | `GE_D466LOG=` | `port/src/drawdistgameplay.c` (`portD466FrameLog`, called from `bgDetermineVisibleRooms`) | D466 dev probe: every `` calls (per player view) logs `D466 f=.. active= ext= auth= subset= hext= hauth= chrOn= chrGp= seen= hgame=` to stderr: extended vs authored room-set counts + hashes, ONSCREEN chrs vs gameplay-on-screen chrs, `HAS_BEEN_ON_SCREEN` count, and a chr-state hash. Cached. | **live** (D466 dev probe) | | `GE_D468_SCALE=<0.05..1>` | `port/src/drawdistgameplay.c` (`portD468UpdateClamp`) | D468 dev falsifier: multiplies the AI-view-clamp fractions (and so turns the clamp on even at 16:9 / FOV 100), so a headless `GE_D466LOG` run can show the authored room set and gameplay on-screen chrs shrinking. Cached; out-of-range values ignored. | **live** (D468 dev probe) | +| `GE_PADFAMILY=xbox\|ps\|nintendo` | `port/src/input.c` (`inputPadFamily`) | D471 dev override: force the controller family used for button names in the PC menus (headless screenshots without the hardware). Cached. | **live** (D471 dev knob) | | `GE_D466_FORCE=1` | `port/src/drawdistgameplay.c` (`portD466Active`) | D466: run the authored-distance split even at DrawDistance 100% (identity test: authored set must equal extended). Cached. | **live** (D466 test hook) | | `GE_D466_OFF=1` | `port/src/drawdistgameplay.c` (`portD466Active`) | D466 kill switch: disable the gameplay/render split (every gameplay reader falls back to the extended-range state, i.e. the pre-fix #125 behaviour). For A/B only. Cached. | **live** (D466 kill switch) | | `GE_CRASHTEST` | `port/src/crash.c` (`crashTestMaybe`, called at the end of `crashInit`) | Deliberate-crash hook for verifying the crash handler: `GE_CRASHTEST=` recurses N frames deep (capped at 64) right after the handler is installed and faults with a null write. `ge007.crash.log` should then show `#00`..`#N-1` in `crashTestRecurse`, then `SDL_main`, CRT and system frames; each frame line carries `va=0x…` for `addr2line -f -p -e build-pc/ge007.x86_64.exe `. Unset/0 = no effect. | **live** (harness) | @@ -146,7 +149,7 @@ Two classes: | `GE_D318R` | `port/src/random.c:69,147` (`#ifdef PORT`) | PRNG golden probe (D284/D318): logs the first 4000 gameplay-PRNG draws with tick + caller return address, and every `randomSeed` set — verifies the bit-exact port of `random.s` and which call sites consume the stream. Inert unset. | **live** (D284 verification) | | `GE_RSEED` | `port/src/random.c:134` (`#ifdef PORT`) | Test-only boot-seed override: `GE_RSEED=` pins `g_randomSeed` at first seed instead of the wall-clock-derived value — for determinism and N64-parity tests. Inert unset. | **live, test-only** | | `GE_RSEED_LV` | `port/src/d318watchdog.c:232` (`#ifdef PORT`) | One-shot level-start seed log: logs `g_randomSeed` at the first tick after level start (optionally armed for a specific tick via `@`) — ties a saved/loaded game's PRNG state to a level for D318-class repro. Inert unset. | **dead** (D318 closed) — removed 2026-09-30 (ROADMAP §7 strip pass, batch 1, branch `chore/scaffolding-removal-1`); tombstone — the generator flags it GONE | -| `GE_D318T` | `port/src/d318watchdog.c:270` (`#ifdef PORT`) | D318 watchdog timeline probe: per-tick log of the Facility ai_22 softlock signature window (c78 list/offset/attack state, c67 health) while the watchdog watches. Inert unset. | **live** (D318 verification) | +| `GE_D318T` | `port/src/d318watchdog.c:270` (`#ifdef PORT`) | D318 watchdog timeline probe: per-tick log of the Facility ai_22 softlock signature window (c78 list/offset/attack state, c67 health) while the watchdog watches. Inert unset. | dead — fully removed 2026-10-01 (ROADMAP §7 scaffolding strip, batch 2; kept here as a tombstone) | | `GE_D322` | `port/src/audio.c` (`audioSetNextBuffer`), `src/snd.c` (`sndD322PoolSummary`) | D322 pool-telemetry probe: one line per 5 s of wall clock with the three pool layers a long session can exhaust — `sfx=count/max` (8-voice SFX soft cap), `evtq=n/64` (event queue occupancy; full = silent post drops, M-71), `pv free/lame/alloc` (24 physical synth voices shared by SFX + music; `-1`s before sndInit), plus the D204 pipeline fields. Cheap (~30 reads/s), campaign-safe; read together with `GE_D204=1`. | **live** (D322 capture) | | `GE_D318W` | `port/src/d318watchdog.c` (`d318WatchdogTick`, `#ifdef PORT`) | **The D318/D320 pin detector, ON by default, DETECT-AND-LOG ONLY since 2026-09-30.** Watches the Facility ai_22 / ai_19 and Control ai_9 hold-pose pin signatures (findings D318/D320) and logs `D318W:` lines (first detection, confirmation after 600 ticks, periodic reminders while held). It used to re-seed the attack via `sub_GAME_7F025560`; that recovery was removed because D329 fixed the cause, so a hit now means a D329 regression. `GE_D318W=0` silences it. | **live** (D329 regression detector) | | `GE_D294` | `src/boss.c:498` (`#ifdef PORT`) | One line per stage reset: stage number + current MA malloc value (D294 pool investigation). | **live** (D294) | diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index ee513605..d558d038 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -328,4 +328,8 @@ D465,FIXED,#127 Dam/Caverns/Complex water slid side to side:,"FIXED 2026-10-01 ( D466,FIXED,#125 Xenia's boss fight on Jungle triggers early:,"FIXED 2026-10-01 (fix/d466-drawdist-gameplay, local); RULE-2-SIGNOFF (port-feature leak fix; design-review approval); verified headless (room sets, on-screen counts), player-facing shot/autoaim/use/p…" D467,CLOSED,#130 Golden Gun empty click with each shot:,CLOSED: faithful by code + log (audited 2026-10-01; by-ear check of held-trigger behaviour vs 1964 is maintainer-owned) D468,FIXED,AI view clamp: under ultrawide (> 16:9) or Video.FovScale > 100 the AI's on-screen tests used the wider rendered frustum; they now keep the cartridge-widest vi…,"FIXED 2026-10-01 (feat/d468-ai-view-clamp, local); RULE-2-SIGNOFF (policy approved by the maintainer 2026-10-01); verified headless (clamp on/off switching + falsifier)." +D469,FIXED,Gamepad presets + per-seat controller rebinding (ROADMAP 5a; supersedes outside PR #109):,"FIXED 2026-10-01 (merged into release/v0.4.1, local); preset 0 == old mapping proven (20,971,520 cases); live pad test owed (maintainer); Jinx 1.2/1.3 undefined (gap)" +D470,FIXED,Audio master volume + output-device selection (ROADMAP 5a):,FIXED (live listening test owed) +D471,FIXED,Controller-family button names (no glyphs) in the PC menus + player-index LEDs:,FIXED 2026-10-01 (local); headless captures for Xbox/PS/Nintendo; live PS/Nintendo pad check owed +D472,FIXED,"F10 overlay polish: multi-pad ownership (audit G1-G3), widescreen pillarbox (D335b), red EXPERIMENTAL rows, unknown-ini-key warning.","FIXED 2026-10-01 (merged; port-only, no src/game)." 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 624164fe..34ed0f75 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -736,6 +736,10 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D466 | **#125 Xenia's boss fight on Jungle triggers early: `Video.DrawDistance` (default 250%) leaked into GAMEPLAY visibility (AI "on screen" / "seen" / target-in-sight / out-of-view spawn all read state computed from the extended far clip)** - full `## D466` entry at file tail | FIXED 2026-10-01 (`fix/d466-drawdist-gameplay`, local); RULE-2-SIGNOFF (port-feature leak fix; design-review approval); verified headless (room sets, on-screen counts), player-facing shot/autoaim/use/photo tests follow the render (maintainer decision); MAINTAINER-VERIFIED 2026-10-01: Jungle/Xenia at DD 400 correct, Statue spot check fine | | D467 | **#130 Golden Gun empty click with each shot: code audit finds the path faithful (2026-10-01)**: the dry-fire click only plays when the trigger is still held ~14 ticks after the shot; no simultaneous play at 60 fps. Open: by-ear/input-length check. | CLOSED: faithful by code + log (audited 2026-10-01; by-ear check of held-trigger behaviour vs 1964 is maintainer-owned) | | D468 | **AI view clamp: under ultrawide (> 16:9) or `Video.FovScale` > 100 the AI's on-screen tests used the wider rendered frustum; they now keep the cartridge-widest view (its own Ratio 16:9 mode at the game's FOV), as a centred sub-box of the screen; `Game.AIWideView=1` opts out** — full `## D468` entry at file tail | FIXED 2026-10-01 (`feat/d468-ai-view-clamp`, local); RULE-2-SIGNOFF (policy approved by the maintainer 2026-10-01); verified headless (clamp on/off switching + falsifier). | +| D469 | **Gamepad presets + per-seat controller rebinding (ROADMAP 5a; supersedes outside PR #109): data-driven pad action table, `Input.PadPreset` (Jinx 1.1 default / Custom), `Input.Pad[N].`, Input -> Controller page in F10 and front-end PC Options** — full `## D469` entry at file tail | FIXED 2026-10-01 (merged into `release/v0.4.1`, local); preset 0 == old mapping proven (20,971,520 cases); live pad test owed (maintainer); Jinx 1.2/1.3 undefined (gap) | +| D470 | **Audio master volume + output-device selection (ROADMAP 5a):** `Audio.MasterVolume` (0..100, scratch-copy gain on the final mix, default 100 = byte-identical) and `Audio.Device` (name, default system device; live reopen on the audio-producer thread, fallback on missing/removed device). | FIXED (live listening test owed) | +| D471 | **Controller-family button names (no glyphs) in the PC menus + player-index LEDs: PlayStation/Nintendo pads see their own button names in the F10/front-end help lines and the pad-binding column; Xbox/Steam Deck/unknown keep the old text exactly** — full `## D471` entry at file tail | FIXED 2026-10-01 (local); headless captures for Xbox/PS/Nintendo; live PS/Nintendo pad check owed | +| D472 | **F10 overlay polish: multi-pad ownership (audit G1-G3), widescreen pillarbox (D335b), red EXPERIMENTAL rows, unknown-ini-key warning.** — full `## D472` entry at file tail | FIXED 2026-10-01 (merged; port-only, no src/game). Headless-verified pillarbox/red rows/warning at 1280x720; multi-pad ownership by code reading only, live check 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. | @@ -15893,7 +15897,7 @@ Under PORT `struct image_entry` puts `dataoffset:24` in bits 0-23 (D67), so byte **Status:** FIXED (port-only; matrix passed); user live accept owed (play a mission ON, then OFF). -**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. +**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). ## 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. @@ -16263,6 +16267,8 @@ Whichever loop runs `SDL_PollEvent` first gets the event, and the render loop us **Confidence:** high that the listed readers are the gameplay consumers of the extended distance and that the authored sets equal what DD 100 computes (room hash match 694/700, the 6 others explained by phase); medium that nothing else gameplay-relevant keys off `headSwitchVisible` (see the known remaining difference); not confirmed against the actual Xenia fight. + +**2026-10-01 follow-up: locomotion follows the render (maintainer decision "A").** Design review of the residual found that D466 had also routed the game's **locomotion level-of-detail** checks to the authored distance: `chraction.c` 3935 (goto-pad setup), 9334 / 9485 (gopos / patrol leave WAYMODE_MAGIC when on screen) and the `lastvisible60` / `act_gopos.unk9c` timestamps (their only readers are the magic-mode entry checks, `chraction.c:9310` and the patrol twin). With those authored but `chrTick`'s movement branches on the extended (render) flag, a guard visible only in the extended band would drop into magic mode after the timer and then run the N64's one-frame "visible magic" transition branch every tick (position snapped to the model offset, no walk animation): sliding/frozen far guards, a state the N64 never produced. These readers are presentation (how an unseen character is moved cheaply), not awareness, so they are reverted to the original code (render flag). Awareness stays authored: AI on-screen commands (`chrai.c`), sight range (`chrCheckTargetInSight`), spawn-out-of-view / pad room checks, `CHRFLAG_HAS_BEEN_ON_SCREEN`. Cost: a far guard the player can see walks its route like a visible guard (the N64's behaviour when the player stands closer) instead of the magic approximation. Jungle DD 250 headless after the change: still 8 drawn vs 3 AI-visible at frame 600-800, `seen` unchanged, rc=0. ## D457 - 64-bit ABI retypes part 2 (ABI/layout exception, 2026-09-30) **Branches.** `fix/abi-bg-model-slots` = 4 commits (a)-(d) + this entry; `fix/abi-bg-slots-ab` = (a)+(b) only (the safe subset). No merge, no push. Census driver: `gcc -fsyntax-only -Wint-conversion -Wint-to-pointer-cast -Wpointer-to-int-cast` over compile_commands.json with the build's flags, unique file:line:col count. @@ -16313,3 +16319,69 @@ Whichever loop runs `SDL_PollEvent` first gets the event, and the render loop us **Bucket:** faithful (no `src/game` change, no Rule-2 request needed). **Probes left on the branch (TEMP, `GE_D433LOG`, cached getenv):** `D433P` macro + SHOT/FIRESND/DRYCLICK/STATS hooks in `src/game/gunfire.c`. Strip before merge. **Confidence:** high for the code path and the log; medium for "the reporter's trigger-hold explains it" (unmeasured). + +## D469 + +**Status: DONE (port-only; live pad test owed).** Branch `feat/pad-presets-rebind` (cut from `release/v0.4.1`). No `src/game` edits. + +**What.** The hard-wired pad block in `port/src/input.c` (triggers, face buttons, shoulders, stick clicks, Start, D-pad) is now a pure function `padMapPure()` over a per-seat action table. Actions (Jinx 1.1 defaults): `Fire=righttrigger`, `Aim=lefttrigger,leftshoulder`, `Use=a`, `Reload=x`, `Crouch=leftstick,rightstick`, `NextWeapon=y`, `PrevWeapon=none` (new; same A+Z backward idiom as the mouse wheel), `Gadget=b`, `Start=start`. Sticks/look, D-pad, Back (opens F10), Southpaw, trigger threshold and the menu accept/cancel (A,X -> native A; B,Y -> native B outside a playable stage) are NOT rebindable (lockout guard). Start is forced back to `start` if a Custom table unbinds it. + +**Config keys.** `Input.PadPreset` 0 = Jinx 1.1 (default), 1/2 = Jinx 1.2/1.3 (reserved; resolve to 1.1; the UI offers only 0 and 3), 3 = Custom. Seat 0: `Input.Pad.`; seats 1..3: `Input.Pad2/3/4.` (`Fire Aim Use Reload Crouch NextWeapon PrevWeapon Gadget Start`). Value = up to two SDL names, comma separated (`a,x`; `none` = empty slot; positional, `none,x` keeps slot 2). Names: `a b x y leftshoulder rightshoulder leftstick rightstick start dpup dpdown dpleft dpright lefttrigger righttrigger`. A non-Custom preset ignores the strings. Seat = `pads[]` index (D448), not the controller slot: with keyboard/mouse as P1 the pad on seat 0 drives slot 1 (`padSeatForSlot`). Gating: analog trigger sources of Fire/Aim act in any state; every digital source acts only while a stage is playable (so LB aim does not fire in menus, exactly as before). + +**Threading.** The poll thread (`inputComputePad`) hashes preset (+ all Custom strings) per call; on change it rebuilds the table into the spare of two buffers and publishes via `SDL_AtomicSetPtr`. No strtok; `g_CurrentPlayer` untouched (D419); getenv cached. + +**UI.** Input -> `Controller...` page (shared row table, so both F10 and front-end PC Options get it): Layout preset (Jinx 1.1 / Custom), Controller seat (Pad 1-4, Custom only), one row per action with Primary/Secondary slots (D-pad left/right switches slot), Reset. Capture: A/X/Enter/click starts a modal; nothing binds until all buttons are released (arming); then the first pressed source binds. Cancel: Esc, tap B or tap Back (pad 0 or the edited pad). Clear: Delete, hold Back 0.6 s, or Y on the row (no modal). Hold B 0.6 s binds B itself. The modal flag blocks F10/Back/Start closing mid-capture; input is swallowed until buttons release afterwards. + +**Equivalence evidence (preset 0 == old mapping).** `GE_PADMAPTEST=1` runs `padMapPure(preset-0 table)` against `padMapLegacy()`, a verbatim transcription of the pre-D469 block (release/v0.4.1 `input.c`, trigger/face section) over every raw source combination (2^15) x previous A/B/X/Y (16) x ctx {facePlayable, slotNative, southpaw} (8) x incoming masks {0, G, R, G|R, 0xFFFF} (5): `20971520 cases compared, 0 mismatches` (button mask, crouch, gadget edge). It also logs the per-source table (e.g. `a menu -> 8000`, `y playable -> 8000`, `b playable -> gadget=1`, `leftshoulder playable -> 0010`, `leftshoulder menu -> 0000`, `righttrigger -> 2000`, `leftstick playable -> crouch=1`), and checks: Custom `Fire=a` moves fire to A and frees RT; seat 1 stays independent; seat 2 `Use=y`; seat 3 Start guard; preset 0 ignores `Input.Pad.*`; the binding API round trip (set/clear slot positions). Run on a menu boot and on `-level_09` (1500 frames): PASS both. One non-equivalence, outside preset-0 gameplay: the overlay path now also records A/X press state for slot 0 (padActPrev), previously only B/Y, which only matters for pads-own-P1 slot 0 after closing F10 with A/X held. + +**Verification run.** build ntsc-final clean (4 cycles incl. one syntax fix); launches: menu boot + GE_PADMAPTEST (rc=0, no crash log), overlay `GE_OPTIONSOVERLAY_SECTION=__HdrController` with `PadPreset=3` + `GE_PCDUMP` frame (page renders: preset, seat, 9 action rows, reset), `-level_09` 1500 frames rc=0. `GE_OPTIONTREEPROBE` reports FAIL but the older `build-pc/ge007.base.exe` fails identically (row-cap check; pre-existing, not from D469). ini/eep backed up and restored around every run. + +**Not done / gaps.** (1) Jinx 1.2 / 1.3 (and 1.4): D394 is only a reconstructed stub that does not record those tables, so presets 1/2 are reserved slots that behave as 1.1 and are hidden from the selector; supply the Xbox control table to fill them (a data-only change). (2) Per-seat preset (preset is global; only Custom strings are per seat). (3) Two actions bound to the same source both fire (no conflict warning). (4) `start` is not abbreviated in the value column. (5) Pad capture reads pad `seat` only; editing seat N needs that pad connected to bind with it (Esc/B cancel from pad 0 or keyboard always work). + +**Maintainer live-pad checklist.** (a) Fresh ini, no edits: Jinx 1.1 plays exactly as before (RT fire, LT/LB aim, A use, X reload, B gadget, Y next weapon, stick clicks crouch, Start pause; menus A/X accept, B/Y cancel). (b) F10 (Back) -> Input -> Controller...: Layout preset right -> Custom; seat/action rows appear. (c) Select Fire, press A: "Press button..." (the A press does not bind itself); press LB -> Primary: LB; fire with LB in a level, RT no longer fires. (d) Capture then tap B: cancelled, row unchanged, page does not close. Capture then hold B ~1 s: binds B. (e) Capture then tap Back: cancel; hold Back ~1 s: slot cleared. Esc and Delete work too. (f) Y on a row clears the slot without a modal. Start unbound -> still pauses (guard). (g) Reset to defaults restores Jinx 1.1 strings on all seats. (h) Controller-only: do (b)-(f) without touching the keyboard; never trapped. (i) 2P split-screen with two pads: set seat Pad 2 to a different Fire; confirm P1 and P2 differ; Pad 2 Y/B in menus still accept/cancel. With keyboard/mouse as P1 (one pad, MPMode Auto) the single pad uses the seat 0 table. (j) Unplug/replug a pad mid-match: bindings stay with the seat. (k) PrevWeapon (unbound by default): bind it and confirm it steps weapons backwards (untested live; reuses the wheel-back A+Z idiom). (l) Front-end PC Options -> Input -> Controller: same page; A starts capture, hold Back clears. (m) Quit and relaunch: the ini keeps `PadPreset` and the `Input.Pad*.` strings. + +**Credit.** The two-names-per-action SDL string format, the menu lockout guard idea and release-before-capture arming follow outside PR #109 (italoarruda); the commits carry `Co-authored-by: italoarruda`. + +**Confidence.** High: preset 0 byte-equivalence (exhaustive over the mapping function), build, boot/level/overlay smoke, page render. Medium: pad-driven capture timing and the front-end page behaviour (no physical pad here). Low: PrevWeapon feel. + + +**Maintainer live test (2026-10-01, release `eeec4bd8`, two Xbox Series X pads, menu launch):** (a) plays as before: needs more testing; (b) Custom bind (Fire to LB): works; (c) B tap cancel / hold B binds B: worked; (d) Back tap cancel / hold Back clear, Y clear: worked; (e) controller-only, never trapped: no issues so far (more paces wanted); (f) 2P per-seat tables: good; (g) unplug/replug mid-match keeps bindings, both pads with Custom bindings: good; (h) relaunch persistence: tested fine (Custom bindings survive a relaunch). The log shows Custom binds per seat (`Input.Pad.Fire seat 1 = righttrigger`, `seat 2 = a`), 15 Custom table rebuilds, seat detach/reattach, a 2P stage, and a clean `Restart game` quit (D443 Restart row exercised live, no BSOD). No crash log. +## D470 -- Audio master volume + output-device selection (ROADMAP 5a) + +**Keys.** `Audio.MasterVolume` (int 0..100, default 100) and `Audio.Device` (string, default empty = system default device). Both in `port/src/audio.c`; rows "Master volume" / "Output device" at the top of the AUDIO section (shared by the F10 overlay and front-end PC Options; whole %). + +**Gain.** Applied in `audioSetNextBuffer` to a static scratch copy of the mixed block (`s*gain/100`, gain <= 100 so no clip is possible), after the game's own Music/FX volumes. At 100 the original buffer pointer is queued untouched (byte-identical). Live: the int is read per block. + +**Device.** SDL audio here is queue-based (`SDL_QueueAudio`), all queue/size calls happen on the audio-producer thread. UI/hot-plug only set atomics (`s_devReopenReq`, `s_devRemovedId`); the close/reopen runs at the top of `audioSetNextBuffer` on that same thread, so no queue call races the close (a few ms of queued audio is dropped on a switch). Named device that fails to open -> `LOG_WARNING` + default device. `SDL_AUDIODEVICEREMOVED` (pumped in video.c) -> warning, close, reopen default, config name cleared. Startup logs every enumerated output (`audioInit: output device N: "..."`). Default config makes the same `SDL_OpenAudioDevice(NULL, ...)` call as before. + +**Probe.** `GE_AUDIOGAINLOG=1`: once per second, max |sample| of the final queued buffer (`D470 gain: master=N peak=P`). + +**Verification (Windows, -level_09, GE_QUITFRAME=600, 640x480, MouseEnabled=0, driver directsound).** a) defaults: opens default device, rc=0, peaks 2717/8158/7459/11225/... b) `MasterVolume=50` + `Device=Speakers (Focusrite USB Audio)`: log "opened audio device by name", rc=0, peaks 1358/4576/4242/5218/... (first window exactly half of a; later windows ~0.5-0.56, timing-dependent mixes). c) `Device=NoSuchDevice`: `WARN audioInit: audio device "NoSuchDevice" unavailable (No such device.); falling back to the default device`, then default opened. In run c the process did NOT self-quit at GE_QUITFRAME (ran to the 120 s `timeout`, rc=124, ~7000 frames, no crash; no "quit requested" line); runs a and b quit normally with identical settings otherwise, so it is probably a harness/quit-path flake unrelated to audio, but it was not re-run (launch budget) -- confidence medium on that point. No crash log in any run. + +**Maintainer listening checklist.** (1) Master 100 sounds identical to before. (2) Slider 0/25/50/100 in F10 and in front-end PC Options changes loudness live, music+FX together; 0 is silent. (3) Output device row cycles Default + devices both directions; audio moves to the chosen device immediately, no crash, brief gap only. (4) Restart: chosen device and volume persist. (5) Unplug the active USB device mid-game: game keeps running and audio resumes on the default device. (6) Edit the ini to a bogus device name: game starts on default. (7) "Reset to defaults" in AUDIO restores 100 and Default (does not touch Music/FX per-file volumes beyond their existing reset). + + +**Lead re-check (2026-10-01):** run c (`Audio.Device = NoSuchDevice`, `-level_09`, `GE_QUITFRAME=600`) repeated once on the same build: fallback warning, default device opened, `quit requested (GE_QUITFRAME)` and a clean exit (rc=0, no crash log). The agent's earlier non-quitting run c did not reproduce; not an audio-path issue. + +## D471 — Controller-family button names (no glyphs) + player-index LEDs + +**Why.** The pad-menu audit (local note `docs/dev/notes/PAD-MENU-AUDIT.md`) found the PC menus fully pad-operable, but every prompt named buttons in Xbox terms. The maintainer wanted names only, no glyph art (GoldenEye has no glyph UI). For reference, the Perfect Dark PC port does not rename buttons at all (fixed `JOY1_A`… names, `pd_port/port/src/input.c:151`) and queries the controller type only for rumble. It does set the pad's player-index LED, which we adopt. + +**Design (port-only).** `port/src/input.c`: `padFamily[seat]` (0 Xbox default incl. Steam Deck/unknown/virtual, 1 PlayStation PS3/PS4/PS5, 2 Nintendo Switch Pro/Joy-Cons), from `SDL_GameControllerGetType` once when a pad is seated (guarded `SDL_VERSION_ATLEAST(2,0,12)`, PS5 at 2.0.14, Joy-Con enums at 2.24), moved with the pad on seat compaction, cleared on detach. Helpers: `inputPadFamily(seat)` (`GE_PADFAMILY=xbox|ps|nintendo` dev override, cached), `inputPadHelpFmt` / `inputPadHelpFmtFam` (expand `{A}{B}{X}{Y}{BACK}{START}` templates with the menu pad's (seat 0) names), `inputPadSourceFamilyName(seat, src)` (binding-column names, NULL = keep the existing Xbox text). Nintendo face letters stay A/B/X/Y (SDL's default `GAMECONTROLLER_USE_BUTTON_LABELS=1` reports buttons by printed label); only L/R/ZL/ZR/−/+ differ. Help templates: F10 overlay footer (`optionsoverlay.c`) and the front-end PC Options capture/help lines (`frontoptions.c`); a footer line that would overflow the card first compacts `LEFT/RIGHT` → `L/R`, then falls back to the Xbox letters. Player LEDs: `SDL_GameControllerSetPlayerIndex(pad, seat + (KbmP1 ? 1 : 0))` re-applied on every pad scan, `-1` before close. + +**Verification (headless, `GE_OPTIONSOVERLAY=1` on the Controller page, Custom preset, frame 200).** Default: help and binding column identical to before. `GE_PADFAMILY=ps`: R2/L2/Cross/Square/L3/Triangle/Circle/Options in the column; footer "CROSS BIND L/R SLOT TRIANGLE CLEAR" / "CIRCLE/ESC BACK F10 CLOSE", inside the card. `GE_PADFAMILY=nintendo`: ZR/ZL/Plus, A/B/X/Y unchanged. All rc=0, no crash. Not verified: a real PlayStation or Nintendo pad, and the LEDs (maintainer, if hardware is available). Front-end page text not captured (the same helper as the overlay). + +**Also from the audit, not done here:** F10 overlay ownership under 2+ pads (G1–G3: navigation reads pad 0 only). + + +## D472 -- F10 overlay polish: pad ownership, pillarbox, experimental colour, unknown-key warning + +**Ownership (PAD-MENU-AUDIT G1-G3).** `input.c`: `padSelectPrev` is now per pad; `s_ovOwner` records the pad whose Back opened F10 (reset to -1 whenever the overlay is closed); `inputOverlayOwnerPad()` returns the opener while attached, else the lowest attached pad (keyboard-opened, or the owner detached), else 0. Only the owner's Back closes it from the poll path (non-owner Back is edge-tracked and ignored while open). The overlay tick still runs from slot 0's poll, but every `inputPadButton(0, ..)`/`inputPadAxis(0, ..)` in `optionsoverlay.c` (nav, A/X, B, Start, Y-clear, D469 capture fallbacks) now reads the owner. A non-zero owner's own slot is returned neutral while open (as pad 0 always was). `inputPadHelpFmt` uses the owner's family while the overlay is open (front-end page still seat 0). Solo single pad: owner is always pad 0 = unchanged. D343 respected (`frontOptionsBlocksOverlay`). No `g_CurrentPlayer` access (D419). + +**Pillarbox (D335b).** The dim stays full-window; card + text are wrapped in `PORT_HUD_ASPECT(CENTER)` ... `NONE` (hudaspect.h, no emission at <= 4:3, so 4:3 is byte-identical). Mouse: `optionsoverlay.c` maps the window fraction through the same centred region (`vis = (4/3)/portNativeAspect()`, as the D335 front-end pointer) before the logical x, so hover/click/slider-drag/close-box hit-test the drawn layout. + +**Red experimental rows.** Rows whose label contains "EXPERIMENTAL" draw label (and ON value) in red ink (selected brighter). F10 only; the front-end PC Options page has its own draw and was left alone. + +**Unknown ini keys.** `config.c` counts keys that `applyKV` rejects during `configLoad` (`configUnknownKeyCount/First`); the F10 root page shows an amber "N UNKNOWN INI KEY(S) - SEE LOG" in the title line. Non-blocking, no behaviour change. Legacy migrated keys (e.g. old `Input.AimStyle`) also count, once, until the ini self-cleans on exit. + +**Verified (Windows, 1280x720, MouseEnabled=0, `GE_OPTIONSOVERLAY=1`, GE_PCDUMP frame 250, rc=0, no crash log):** root page pillarboxed with the warning (`scratch/f472/root.png`); Gameplay page with red Skip intro / All unlocked rows (`scratch/f472/gp250.png`). **Not verifiable headless:** multi-pad ownership. Maintainer live checks: with 2 pads, P2 Back opens F10, P2 navigates/adjusts/B-backs/Start-closes while P1 Back/Start does nothing; detach the owner mid-menu, the lowest remaining pad takes over; keyboard F10 + pad 1 still drives; solo single-pad unchanged; pillarboxed mouse hover/click lands on the drawn row at 16:9 and 21:9; PS pad in seat 2 shows PS names in the help line when it owns the overlay. diff --git a/docs/porting-notes.md b/docs/porting-notes.md index 6a502a50..ae159e2a 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -1828,3 +1828,9 @@ State-machine audits of a gun's fire/dry-fire path (GE: `gunTickHandState`, CLIC **A1 cross-tag (D455, 2026-09-30):** an exe-static address (array/global/function in `.data`/`.rodata`/`.text`) held in a `u32`/`s32` truncates on Windows (image base `0x140000000`; Linux 0x20000000 hides it). Binary tell: `mov r64,[rip+.refptr.X]` ... `mov r32,r32` before the pointer is used or passed. The address load may precede a `jmp` join, so an adjacent-instruction scan misses it; follow jumps. A census of the whole exe found only the D454 site; `_*Segment*` linker symbols are ROM constants (`.set` in `romassets_u.s`), not exe addresses, and are benign in `(u32)` casts. **`GE_MEMPREDZONE=1` (D464): catch allocator overruns deterministically.** Set it before any run to pad every `mempAlloc*` block with a 64-byte pattern trailer and check at level unload, every 60 frames and on orderly quit. A hit prints `MEMPREDZONE: HIT ... caller=0x`; resolve with `addr2line -f -C -i -e build-pc/ge007.x86_64.exe 0x` to the allocation site, then compare its size expression with `sizeof` of how the buffer is indexed (an N64 literal stride vs a widened struct is the D461/D462 class). Zero hits is only "no overrun reached the trailer"; whole-bank allocations are unpadded until shrunk. Unset = one cached branch per allocation. + +**Data-driven input mapping: factor the pure core, keep the old code as a test oracle (D469).** To refactor a hard-wired input block into a table without changing default behaviour, move the decision into a pure function over (raw source bits, bindings, previous-state, incoming mask) and keep a verbatim transcription of the old block next to it, used only by an env-gated boot self-test (`GE_PADMAPTEST`) that compares them exhaustively over the (small) input space. 2^15 source combinations x prev x ctx x incoming masks is ~21M cheap calls, ~1 s, and gives a byte-identity proof no live pad is needed for. Watch the side inputs the old code mutated implicitly (here the incoming `button` mask: the southpaw swap acted on keyboard fire too, so the pure function takes it as a parameter). Table edits arrive from other threads: the poll thread must rebuild into a spare buffer keyed by a cheap config signature and publish by atomic pointer, never parse (no `strtok`) or re-point shared state in place. + +**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()`. diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index e8ec9203..4aef52d4 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -2558,40 +2558,6 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo d75d_note_emit(videoGetFrameCount(), v_arr); // survived all rejection gates -> reaches GL d303_note_emit(videoGetFrameCount(), v_arr); // TEMP D303 zf_note_emit(videoGetFrameCount(), v_arr); // TEMP D306/D308 - /* TEMP D306 (M-201): depth-state census of emitted triangles in a frame - * window, GE_D306C="f0 f1". One line per tri: depth flags as sent to GL, - * geometry_mode, other_mode_l, tile-0 texture, NDC centroid, z/w range and - * min w (<=0 = crosses the eye plane). Cap 60000. */ - { - static int d306c = -1; static uint32_t d306f0 = 0, d306f1 = 0, d306n = 0; static float d306wmax = 1e30f; - if (d306c < 0) { - const char* e = getenv("GE_D306C"); - d306c = (e && sscanf(e, "%u %u %f", &d306f0, &d306f1, &d306wmax) >= 2) ? 1 : 0; - } - const uint32_t fc = videoGetFrameCount(); - if (d306c && fc >= d306f0 && fc <= d306f1 && d306n < 60000) { - float cx = 0, cy = 0, zmin = 1e9f, zmax = -1e9f, wmin = 1e9f; - for (int i = 0; i < 3; i++) { - const float w = v_arr[i]->w; - if (w < wmin) wmin = w; - if (w > 0.f) { - const float zn = v_arr[i]->z / w; - cx += v_arr[i]->x / w; cy += v_arr[i]->y / w; - if (zn < zmin) zmin = zn; - if (zn > zmax) zmax = zn; - } - } - cx /= 3.f; cy /= 3.f; - if (wmin > d306wmax) goto d306c_skip; /* optional 3rd arg: near geometry only */ - { - const void* t0 = rendering_state.textures[0] ? (const void*)rendering_state.textures[0]->first.texture_addr : nullptr; - d306n++; - fprintf(stderr, "D306C f=%u dm=%02x geom=%08x oml=%08x tex0=%p c=(%.3f,%.3f) z=[%.5f,%.5f] wmin=%.2f\n", - fc, rendering_state.depth_mode, rsp.geometry_mode, rdp.other_mode_l, t0, cx, cy, zmin, zmax, wmin); - } - } - d306c_skip:; - } d75d_note_emit_z(videoGetFrameCount(), v_arr, (used_textures[0] || used_textures[1]) ? 1 : 0, comb->used_textures[0] ? 1 : 0); for (int i = 0; i < 3; i++) { float z = v_arr[i]->z, w = v_arr[i]->w; diff --git a/port/include/audio.h b/port/include/audio.h index 5762b8c3..645ea458 100644 --- a/port/include/audio.h +++ b/port/include/audio.h @@ -31,6 +31,16 @@ u32 audioGetAiLengthBytes(void); /* Queue the next block of mixed samples (len is in bytes). */ void audioSetNextBuffer(const s16 *buf, u32 len); +/* D470: output-device selection. audioDeviceRefresh() re-enumerates and + * returns the count; audioDeviceName(i) is valid until the next refresh. + * audioDeviceRequest("") = system default. The switch itself is applied on + * the audio thread. audioNotifyDeviceRemoved() is for SDL_AUDIODEVICEREMOVED. */ +int audioDeviceRefresh(void); +const char *audioDeviceName(int i); +const char *audioDeviceCurrentName(void); +void audioDeviceRequest(const char *name); +void audioNotifyDeviceRemoved(u32 which); + #ifdef __cplusplus } #endif diff --git a/port/include/config.h b/port/include/config.h index 1157c1b1..7e6ddc74 100644 --- a/port/include/config.h +++ b/port/include/config.h @@ -17,6 +17,8 @@ extern "C" { /* Load the config file (no-op if it doesn't exist yet). */ void configLoad(void); +int configUnknownKeyCount(void); /* D472: unknown ini keys in the last load */ +const char *configUnknownKeyFirst(void); /* D472: first such dotted key */ /* Save the current values back to the config file. */ void configSave(void); diff --git a/port/include/input.h b/port/include/input.h index 57379343..8f297811 100644 --- a/port/include/input.h +++ b/port/include/input.h @@ -62,6 +62,7 @@ void inputSuspendForOverlay(void); * navigation). Unaffected by the overlay's pad-swallow, which happens at our * logic layer, not SDL's. No pad open -> 0. */ int inputPadButton(int idx, SDL_GameControllerButton b); +int inputOverlayOwnerPad(void); /* D472: pad driving the F10 overlay */ short inputPadAxis(int idx, SDL_GameControllerAxis a); /* Queue a mouse-wheel weapon-cycle input (one short A-button press). Sign is @@ -80,6 +81,22 @@ const char *inputBindingSlot(const char *key, int slot); 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].). + * Seat = pads[] index. Sources are indices into the SDL-name table + * (inputPadSourceName); -1 = none. */ +int inputPadActionCount(void); +const char *inputPadActionLabel(int act); +int inputPadActionForKey(const char *key); /* "Input.Pad.Fire" -> action, else -1 */ +int inputPadSourceCount(void); +const char *inputPadSourceName(int src); +int inputPadSeatPresent(int seat); +unsigned inputPadHeldSources(int seat); /* bit i = source i held */ +int inputPadPresetStep(int cur, int dir); +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 */ + /* D345(b): 1 while the 1:1 menu pointer owns cursor_h/v_pos (in a menu, * abs pointer available, mouse used within the re-assert window). Port * screens that teleport the crosshair should skip their snap when set. */ @@ -87,6 +104,10 @@ int inputMenuPointerLive(void); /* Re-enumerate gamepads after a hotplug (SDL_CONTROLLERDEVICEADDED/REMOVED). */ void inputRescanPads(void); +int inputPadFamily(int seat); /* D471: 0 Xbox, 1 PS, 2 Nintendo */ +void inputPadHelpFmt(char *out, size_t n, const char *tmpl); /* D471: {A}{B}{X}{Y}{BACK}{START} */ +void inputPadHelpFmtFam(char *out, size_t n, const char *tmpl, int fam); +const char *inputPadSourceFamilyName(int seat, const char *src); /* D471: NULL = keep Xbox text */ void inputRequestRescan(void); /* D450: from either SDL event pump; consumed by inputUpdate() */ /* D401: Rumble Pak -> real gamepad haptics. The osMotor* shims in diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h index 65eb77cd..ef3f8d7d 100644 --- a/port/include/optionsoverlay.h +++ b/port/include/optionsoverlay.h @@ -72,6 +72,7 @@ const char *optionsRowLabel(int i); int optionsRowIsSlider(int i); int optionsRowIsBind(int i); void optionsRowBeginBind(int i); +int optionsRowIsPadBind(int i); /* D469: gamepad-capture row (pad A may start it) */ int optionsRowIsBondChooser(int i); /* D353: the Bond-file chooser row (front options screen only) */ /* D356: 1 when this row's value lives in the selected save file (content diff --git a/port/src/audio.c b/port/src/audio.c index b1336947..1430f185 100644 --- a/port/src/audio.c +++ b/port/src/audio.c @@ -23,6 +23,21 @@ static SDL_AudioDeviceID dev = 0; +/* D470: port-level master volume (0..100, 100 = bit-identical passthrough) + * and output-device selection (empty = SDL's default device, the old + * behaviour). Both are plain config-backed values read where they are used. */ +static int masterVolume = 100; +static char deviceName[256] = ""; + +/* D470: a UI/hot-plug request to re-open the device. Set from any thread, + * consumed (and dev closed/reopened) only on the audio-producer thread at the + * top of audioSetNextBuffer(), the sole thread that queues to / drains `dev`, + * so no queue call can race the close. */ +static SDL_atomic_t s_devReopenReq; + +/* D470: SDL_AUDIODEVICEREMOVED (video.c's event pump) -> fall back to default. */ +static SDL_atomic_t s_devRemovedId; + /* src/audi.c: g_FrameSize + EXTRA_SAMPLES + 0x10, the exact sample count * info->data is allocated for (audi.c:388). Zero until amCreateAudioManager * has run. Used by the D204/F4 oversize guard below. */ @@ -72,6 +87,69 @@ static u32 lastBufferBytes = 0; static u32 dropCount = 0; static u32 oversizeCount = 0; +/* D470: open the configured device by name (NULL name = system default). + * A named device that cannot be opened logs a warning and falls back to the + * default, so a missing/removed interface never leaves the game silent. */ +static SDL_AudioDeviceID audioOpenConfigured(const char *name, SDL_AudioSpec *have) +{ + SDL_AudioSpec want = {0}; + want.freq = 22050; /* GE's OUTPUT_RATE (src/audi.c) */ + want.format = AUDIO_S16SYS; + want.channels = 2; + want.samples = (u16)bufferSize; + SDL_AudioDeviceID d = 0; + if (name && name[0]) { + d = SDL_OpenAudioDevice(name, 0, &want, have, 0); + if (d) { + sysLogPrintf(LOG_INFO, "audioInit: opened audio device by name: \"%s\"", name); + } else { + sysLogPrintf(LOG_WARNING, + "audioInit: audio device \"%s\" unavailable (%s); falling back to the default device", + name, SDL_GetError()); + } + } + if (!d) d = SDL_OpenAudioDevice(NULL, 0, &want, have, 0); + return d; +} + +/* D470: enumeration for the options UI. Names are copied (SDL's pointers are + * only valid until the next enumeration). */ +#define AUDIO_MAXDEV 16 +static char s_devNames[AUDIO_MAXDEV][128]; +static int s_devCount = 0; + +int audioDeviceRefresh(void) +{ + s_devCount = 0; + if (!SDL_WasInit(SDL_INIT_AUDIO)) return 0; + int n = SDL_GetNumAudioDevices(0); + for (int i = 0; i < n && s_devCount < AUDIO_MAXDEV; i++) { + const char *nm = SDL_GetAudioDeviceName(i, 0); + if (!nm) continue; + snprintf(s_devNames[s_devCount], sizeof(s_devNames[0]), "%s", nm); + s_devCount++; + } + return s_devCount; +} + +const char *audioDeviceName(int i) +{ + return (i >= 0 && i < s_devCount) ? s_devNames[i] : NULL; +} + +const char *audioDeviceCurrentName(void) { return deviceName; } + +void audioDeviceRequest(const char *name) +{ + snprintf(deviceName, sizeof(deviceName), "%s", name ? name : ""); + SDL_AtomicSet(&s_devReopenReq, 1); +} + +void audioNotifyDeviceRemoved(u32 which) +{ + SDL_AtomicSet(&s_devRemovedId, (int)which); +} + int audioInit(void) { #if defined(_WIN32) && defined(SDL_HINT_AUDIODRIVER) @@ -90,13 +168,13 @@ int audioInit(void) sysLogPrintf(LOG_ERROR, "audioInit: SDL_InitSubSystem: %s", SDL_GetError()); return -1; } - SDL_AudioSpec want = {0}; - want.freq = 22050; /* GE's OUTPUT_RATE (src/audi.c) */ - want.format = AUDIO_S16SYS; - want.channels = 2; - want.samples = (u16)bufferSize; + { /* D470: list the outputs so a name can be copied into Audio.Device */ + int nd = audioDeviceRefresh(); + for (int k = 0; k < nd; k++) + sysLogPrintf(LOG_INFO, "audioInit: output device %d: \"%s\"", k, audioDeviceName(k)); + } SDL_AudioSpec have; - dev = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); + dev = audioOpenConfigured(deviceName, &have); if (!dev) { sysLogPrintf(LOG_ERROR, "audioInit: SDL_OpenAudioDevice: %s", SDL_GetError()); return -1; @@ -176,8 +254,35 @@ u32 audioGetAiLengthBytes(void) return queued; } +/* D470: runs on the producer thread only (see s_devReopenReq). The queued + * audio of the old device is dropped (a few tens of ms at most). */ +static void audioServiceDeviceRequests(void) +{ + int removed = SDL_AtomicSet(&s_devRemovedId, 0); + if (removed && dev && (SDL_AudioDeviceID)removed == dev) { + sysLogPrintf(LOG_WARNING, + "audio: output device removed (id %d); falling back to the default device", removed); + SDL_CloseAudioDevice(dev); + dev = 0; + deviceName[0] = 0; /* the configured device is gone: default until re-picked */ + SDL_AtomicSet(&s_devReopenReq, 1); + } + if (SDL_AtomicSet(&s_devReopenReq, 0)) { + if (dev) { SDL_CloseAudioDevice(dev); dev = 0; } + SDL_AudioSpec have; + dev = audioOpenConfigured(deviceName, &have); + if (dev) { + SDL_PauseAudioDevice(dev, 0); + sysLogPrintf(LOG_INFO, "audio: device (re)opened at %d Hz", have.freq); + } else { + sysLogPrintf(LOG_ERROR, "audio: device reopen failed: %s", SDL_GetError()); + } + } +} + void audioSetNextBuffer(const s16 *buf, u32 len) { + audioServiceDeviceRequests(); if (!s_audioDumpChecked) { s_audioDumpChecked = 1; if (getenv("GE_AUDIODUMP")) { @@ -304,6 +409,38 @@ void audioSetNextBuffer(const s16 *buf, u32 len) } } + /* D470: master volume. Applied to a scratch copy (the game's buffer is + * never modified) AFTER the game's own music/FX volumes, so it only scales + * the final mix. gain <= 100 means |s*gain/100| <= |s|: no clipping is + * possible. 100 (default) passes `buf` through untouched, byte-identical. */ + { + static s16 gainBuf[8192 * 2]; + int mv = masterVolume; + if (mv < 0) mv = 0; + if (mv < 100 && buf && len && len <= sizeof(gainBuf)) { + u32 n = len / 2u; + for (u32 i = 0; i < n; i++) gainBuf[i] = (s16)(((s32)buf[i] * mv) / 100); + buf = gainBuf; + } + } + + /* D470 diag: GE_AUDIOGAINLOG=1 -> peak |sample| of the final (post-gain) + * buffer, max over each 1 s window. Cached env flag, cheap. */ + if (buf && len && GE_ENVFLAG("GE_AUDIOGAINLOG")) { + static u64 winStart = 0; static s32 winPeak = 0; + u64 now = sysGetMicroseconds(); + const s16 *sp = buf; + for (u32 i = 0; i < len / 2u; i++) { + s32 v = sp[i]; if (v < 0) v = -v; + if (v > winPeak) winPeak = v; + } + if (!winStart) winStart = now; + if (now - winStart >= 1000000ull) { + sysLogPrintf(LOG_NOTE, "D470 gain: master=%d peak=%d", masterVolume, (int)winPeak); + winStart = now; winPeak = 0; + } + } + if (dev && buf && len) { if (audioGetSamplesBuffered() < queueLimit) { SDL_QueueAudio(dev, buf, len); @@ -322,4 +459,6 @@ PD_CONSTRUCTOR static void audioConfigInit(void) { configRegisterInt("Audio.BufferSize", &bufferSize, 0, 1 * 1024 * 1024); configRegisterInt("Audio.QueueLimit", &queueLimit, 0, 1 * 1024 * 1024); + configRegisterInt("Audio.MasterVolume", &masterVolume, 0, 100); /* D470 */ + configRegisterString("Audio.Device", deviceName, sizeof(deviceName)); /* D470 */ } diff --git a/port/src/config.c b/port/src/config.c index 319e841e..c626b631 100644 --- a/port/src/config.c +++ b/port/src/config.c @@ -222,6 +222,10 @@ static char *trim(char *s) return s; } +/* D472: unknown ge007.ini keys seen by the last configLoad. */ +static int s_unknownKeys; +static char s_unknownFirst[48]; + static void applyKV(const char *dottedKey, const char *val) { for (int i = 0; i < numIntOpts; i++) { @@ -270,8 +274,15 @@ static void applyKV(const char *dottedKey, const char *val) } } sysLogPrintf(LOG_NOTE, "config: unknown key '%s' (ignored)", dottedKey); + if (s_unknownKeys++ == 0) { /* D472: surfaced once in the F10 overlay */ + strncpy(s_unknownFirst, dottedKey, sizeof(s_unknownFirst) - 1); + s_unknownFirst[sizeof(s_unknownFirst) - 1] = 0; + } } +int configUnknownKeyCount(void) { return s_unknownKeys; } +const char *configUnknownKeyFirst(void) { return s_unknownFirst; } + /* Preset plumbing (Original-N64 / port-defaults presets, D440; * the D283 Deck preset): set / read a registered numeric option by its * dotted key, with the same clamp a file load applies. Returns 1 if the key @@ -338,6 +349,8 @@ void configLoad(void) char line[512]; char section[64] = ""; + s_unknownKeys = 0; + s_unknownFirst[0] = 0; while (fgets(line, sizeof(line), f)) { char *p = trim(line); if (!*p || *p == '#' || *p == ';') diff --git a/port/src/d318watchdog.c b/port/src/d318watchdog.c index dfe50c5e..d470d180 100644 --- a/port/src/d318watchdog.c +++ b/port/src/d318watchdog.c @@ -144,7 +144,6 @@ extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList); * the AI-command header just for one bit constant. */ #define D320_TARGET_AIM_ONLY 0x0020 /* "Aim at target instead of firing" */ -#define D318_LIST_AI22 0x0417 /* Ourumov's execution list (ai_22) */ #define D318_OURUMOV_CHR 78 #define D318_TREVELYAN_CHR 67 #define D318_DEADLOCK_TICKS 600 /* 10 s at 60 Hz; escape takes < 2 s */ @@ -331,329 +330,4 @@ void d318WatchdogTick(void) } } -/* ------------------------------------------------------------------ */ -/* D318T: diagnostic timeline for the "Ourumov shoots Trevelyan the */ -/* moment his lines begin" report (2026-09-20). Env-gated, OFF by */ -/* default; enable with GE_D318T=1. Port-side read-only: logs state */ -/* transitions so a single playtest captures which combat trigger */ -/* (gas bit 0x04 vs Bond near-miss flag) derailed ai_22, and how many */ -/* ticks after the monologue started it landed. No game state is */ -/* touched. */ -/* */ -/* Background: the ONLY path by which c78 live-fires Trevelyan */ -/* (entityid 67) in this scene is section 0x2a -> off=307 */ -/* actor_fire_or_aim_at_target_update succeeding while the pre-aim */ -/* (ACT_ATTACK + AIM_ONLY|DONTTURN) is active. That requires a combat */ -/* trigger inside the monologue loop: gas bit 0x04 (off=75) or */ -/* chrIfNearMiss (off=87, CHRFLAG_NEAR_MISS -- set by Bond bullets */ -/* passing c78's bounds, cleared on c78's next tick). The freeze */ -/* (D318) and the immediate-fire are the two branches of the same */ -/* derailed state (random aim-variant endframe vs the hold frame H). */ -/* ------------------------------------------------------------------ */ - -void d318TimelineTick(void) -{ - static int s_on = -1; /* -1 uncached, 0 off, 1 on (GE_D318T=1) */ - static s32 s_obj = -1; /* last logged objectiveregisters1 */ - static s32 s_off = -1; /* last logged c78 aioffset */ - static s32 s_act = -1; /* last logged c78 actiontype */ - static s32 s_atk = -1; /* last logged c78 act_attack.attacktype */ - static s32 s_ent = -1; /* last logged c78 act_attack.entityid */ - static s32 s_nm = -1; /* last logged c78 NEAR_MISS flag state */ - static f32 s_dmg = -1.0f; - static bool s_dead = FALSE; - static s32 s_hb = 0; - static s32 s_det = 0; /* E1 determinism fingerprint counter */ - ChrRecord *c78; - ChrRecord *c67; - - if (s_on < 0) - { - const char *e = getenv("GE_D318T"); - - s_on = (e && e[0] == '1') ? 1 : 0; - } - if (!s_on) - { - return; - } - - /* Unified chr view: real guards live in g_ChrSlots[0..g_NumChrSlots) with - * model != NULL; the background script entities (chrnum 0xFE, list IDs - * >= 0x1000) live in g_ActiveChrs. Both are ticked by chrlvAllChrTick, - * so both must be scanned for pins / fingerprints. (D320, 2026-09-22: - * the detector originally walked g_ActiveChrs only and was blind to - * every real guard.) */ - static ChrRecord *s_all[96]; - static int s_nall = 0; - s32 i; - - s_nall = 0; - for (i = 0; i < g_NumChrSlots && s_nall < 96; i++) - { - if (g_ChrSlots[i].model != NULL) - { - s_all[s_nall++] = &g_ChrSlots[i]; - } - } - for (i = 0; i < g_ActiveChrsCount && s_nall < 96; i++) - { - s_all[s_nall++] = &g_ActiveChrs[i]; - } - - c78 = d318wFindChr(D318_OURUMOV_CHR); - c67 = d318wFindChr(D318_TREVELYAN_CHR); - - /* Objective bitfield transitions (the derail triggers live here: */ - /* 0x04 gas/combat, 0x20 surrender/monologue, 0x40 flee). */ - if (s_obj != objectiveregisters1) - { - osSyncPrintf("D318T: t=%d obj bits 0x%08x -> 0x%08x\n", - (int)g_GlobalTimer, (unsigned)s_obj, (unsigned)objectiveregisters1); - s_obj = objectiveregisters1; - } - - if (c78) - { - s32 nm = (c78->chrflags & CHRFLAG_NEAR_MISS) != 0; - - /* D318 anim-internals line: catches the kneel-attack route */ - /* (unk54==0 => sub_GAME_7F0256F0 "chrAttackKneel" re-inits) and the */ - /* hold/pin frame values, for the "bending down too far" report. */ - { - static s32 s_tom = -1; - static u32 s_u54 = 0xFFFFFFFFu; - static f32 s_endf = -1.0f; - - s32 tom = (s32)c78->act_attack.type_of_motion; - u32 u54 = c78->act_attack.unk54; - f32 endf = c78->model ? modelGetAnimEndFrame(c78->model) : -1.0f; - - if (s_tom != tom || s_u54 != u54 || s_endf != endf) - { - osSyncPrintf("D318T: t=%d c78 anim mot=%d unk54=%u endframe=%.2f frame1=%.2f (off=%d)\n", - (int)g_GlobalTimer, tom, (unsigned)u54, - (double)endf, - (double)(c78->model ? modelGetAnimFrame(c78->model) : 0.0f), - (int)c78->aioffset); - s_tom = tom; - s_u54 = u54; - s_endf = endf; - } - } - - if (s_off != (s32)c78->aioffset || s_act != (s32)c78->actiontype) - { - const char *phase = "?"; - - if (c78->aioffset >= 38 && c78->aioffset < 44) { phase = "pre-aim(off38)"; } - else if (c78->aioffset >= 44 && c78->aioffset < 69) { phase = "wait-loop(off44)"; } - else if (c78->aioffset >= 69 && c78->aioffset < 272){ phase = "MONOLOGUE(off69+)"; } - else if (c78->aioffset >= 272 && c78->aioffset < 295){ phase = "execution(0x13)"; } - else if (c78->aioffset >= 302 && c78->aioffset < 333){ phase = "COMBAT-0x2a"; } - - osSyncPrintf("D318T: t=%d c78 off=%d [%s] act=%d atk=0x%x ent=%d\n", - (int)g_GlobalTimer, (int)c78->aioffset, phase, - (int)c78->actiontype, (unsigned)c78->act_attack.attacktype, - (int)c78->act_attack.entityid); - s_off = (s32)c78->aioffset; - s_act = (s32)c78->actiontype; - } - if (s_atk != (s32)c78->act_attack.attacktype || s_ent != (s32)c78->act_attack.entityid) - { - osSyncPrintf("D318T: t=%d c78 ATTACK CHANGE atk=0x%x ent=%d (off=%d)\n", - (int)g_GlobalTimer, (unsigned)c78->act_attack.attacktype, - (int)c78->act_attack.entityid, (int)c78->aioffset); - s_atk = (s32)c78->act_attack.attacktype; - s_ent = (s32)c78->act_attack.entityid; - } - if (s_nm != nm) - { - osSyncPrintf("D318T: t=%d c78 NEAR_MISS flag %s (Bond bullet passed his bounds)\n", - (int)g_GlobalTimer, nm ? "SET" : "cleared"); - s_nm = nm; - } - } - - if (c67) - { - bool dead = chrIsDead(c67); - - if (s_dmg != c67->damage || s_dead != dead) - { - osSyncPrintf("D318T: t=%d c67(Trevelyan) dmg=%.2f/%.2f %s\n", - (int)g_GlobalTimer, (double)c67->damage, (double)c67->maxdamage, - dead ? "DEAD" : ""); - s_dmg = c67->damage; - s_dead = dead; - } - } - - /* Heartbeat while c78 is on his execution list, so gaps are visible. */ - if (c78 && c78->ailist) - { - bool g = FALSE; - - if (chraiGetAIListID(c78->ailist, &g) == D318_LIST_AI22 && (++s_hb >= 60)) - { - s_hb = 0; - osSyncPrintf("D318T: t=%d c78 hb off=%d act=%d atk=0x%x ent=%d obj=0x%08x\n", - (int)g_GlobalTimer, (int)c78->aioffset, (int)c78->actiontype, - (unsigned)c78->act_attack.attacktype, (int)c78->act_attack.entityid, - (unsigned)objectiveregisters1); - } - } - - /* E1 determinism fingerprint: one line per second over ALL active chrs */ - /* + the PRNG seed + objective bits. Two no-input runs that differ here */ - /* are non-deterministic in the port (foundational); identical logs */ - /* mean run-to-run variation is input-timing-driven. */ - if (++s_det >= 60) - { - s32 h = 0x811C9DC5; - - s_det = 0; - for (i = 0; i < s_nall; i++) - { - ChrRecord *c = s_all[i]; - union { f32 f; u32 u; } xf, yf, zf, df; - - xf.f = c->prop ? c->prop->pos.x : 0.0f; - yf.f = c->prop ? c->prop->pos.y : 0.0f; - zf.f = c->prop ? c->prop->pos.z : 0.0f; - df.f = c->damage; - h ^= c->chrnum; h = (h * 0x01000193) ^ 0x9E3779B9; - h ^= (s32)c->actiontype; h = (h * 0x01000193) ^ 0x9E3779B9; - h ^= (s32)c->aioffset; h = (h * 0x01000193) ^ 0x9E3779B9; - h ^= xf.u; h = (h * 0x01000193) ^ 0x9E3779B9; - h ^= yf.u; h = (h * 0x01000193) ^ 0x9E3779B9; - h ^= zf.u; h = (h * 0x01000193) ^ 0x9E3779B9; - h ^= df.u; h = (h * 0x01000193) ^ 0x9E3779B9; - } - osSyncPrintf("D318T: DET t=%d seed=%08x%08x obj=0x%08x h=%08x nchrs=%d\n", - (int)g_GlobalTimer, - (unsigned)(u32)(g_randomSeed >> 32), (unsigned)(u32)g_randomSeed, - (unsigned)objectiveregisters1, (unsigned)h, s_nall); - } - - /* D320: generic D318-class pin DETECTION for the sweep's flagged lists - * (Facility ai_19, Control ai_9, Depot ai_12 -- see findings D320). - * Detection only: it logs, it never intervenes; the recovery watchdog - * above stays hardcoded to ai_22 by design until a second list actually - * freezes in play (D320's explicit instruction). - * - * Signature: a chr in ACT_ATTACK whose attacktype is LIVE FIRE (no - * TARGET_AIM_ONLY bit -- it is supposed to be shooting) and whose - * modelGetAnimFrame is byte-stable for 600 consecutive ticks (10 s). A - * guard that should be firing but whose animation never advances is the - * exact D318 frozen shape on any list. The two legitimate long-stable - * states are excluded or self-clearing: monologue pre-aims keep the - * AIM_ONLY bit (excluded), and a working fire cycle re-inits through - * chrlvTickAttackCommon's "endframe <= frame" block, so its frame does - * not stay byte-stable for 10 s. One line at first detection, then one - * per further 600 stable ticks while it persists; a frozen run keeps - * printing, an escaped run stops after the single line (or none). */ - { - typedef struct { s16 chrnum; u32 framebits; s32 stable; s32 nextlog; } - D320Pin; - static D320Pin s_pins[8]; - static int s_npins = 0; - s32 i, j; - - for (i = 0; i < s_nall; i++) - { - ChrRecord *c = s_all[i]; - union { f32 f; u32 u; } fr; - D320Pin *p = NULL; - - if (c->actiontype != ACT_ATTACK || c->model == NULL) - { - continue; - } - if ((s32)c->act_attack.attacktype & D320_TARGET_AIM_ONLY) - { - continue; /* legitimate hold pose (monologue pre-aim) */ - } - - fr.f = modelGetAnimFrame(c->model); - for (j = 0; j < s_npins; j++) - { - if (s_pins[j].chrnum == c->chrnum) - { - p = &s_pins[j]; - break; - } - } - if (!p) - { - if (s_npins < 8) - { - p = &s_pins[s_npins++]; - } - else - { - continue; /* table full: drop the newest, keep tracking */ - } - p->chrnum = c->chrnum; - p->framebits = fr.u; - p->stable = 0; - p->nextlog = 600; - } - - if (p->framebits == fr.u) - { - p->stable++; - } - else - { - p->framebits = fr.u; - p->stable = 0; - p->nextlog = 600; - } - - if (p->stable >= p->nextlog) - { - bool g = FALSE; - s32 aid = c->ailist ? chraiGetAIListID(c->ailist, &g) : -1; - - osSyncPrintf("D320T: t=%d PIN? c%d aiid=0x%04x%s off=%d atk=0x%x ent=%d " - "mot=%d unk54=%u frame=%.2f stable=%d ticks (D318-class pin candidate)\n", - (int)g_GlobalTimer, (int)c->chrnum, - (unsigned)aid, g ? "G" : "", (int)c->aioffset, - (unsigned)c->act_attack.attacktype, - (int)c->act_attack.entityid, - (s32)c->act_attack.type_of_motion, - (unsigned)c->act_attack.unk54, - (double)fr.f, p->stable); - p->nextlog = p->stable + 600; - } - } - - /* Drop entries for chrs that left the tracked state so a later - * re-entry starts a fresh confirmation window. */ - for (i = 0; i < s_npins; i++) - { - bool alive = FALSE; - - for (j = 0; j < s_nall; j++) - { - ChrRecord *c = s_all[j]; - - if (c->chrnum == s_pins[i].chrnum && c->actiontype == ACT_ATTACK - && c->model != NULL - && !((s32)c->act_attack.attacktype & D320_TARGET_AIM_ONLY)) - { - alive = TRUE; - break; - } - } - if (!alive) - { - s_pins[i] = s_pins[--s_npins]; - i--; - } - } - } -} - #endif /* PORT */ diff --git a/port/src/frontoptions.c b/port/src/frontoptions.c index 0b5546c9..5b59613c 100644 --- a/port/src/frontoptions.c +++ b/port/src/frontoptions.c @@ -503,7 +503,8 @@ void frontOptionsMenuInterface(void) * 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 (ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] || + 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)) { @@ -782,10 +783,17 @@ Gfx *frontOptionsMenuDraw(Gfx *DL) } if (strcmp(optionsRowLabel(activeHeader()), "MOVEMENT") == 0 || strcmp(optionsRowLabel(activeHeader()), "ACTIONS") == 0) { - DL = ink(DL, ROW_X, rowY(s_rowN + 2), - optionsBindingCaptureActive() - ? "Press key/mouse B/ESC cancel DEL clear\n" - : "Keys/mouse only Enter: bind B: back\n", INK_DIM); + 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); } else if (s_rowTotal > ROWS_PER_PAGE) { /* D406b: bottom hint on every page of a multi-page section (11 * rows per page leaves the last line of the paper for it). */ diff --git a/port/src/input.c b/port/src/input.c index 39ba0d80..3cde3243 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -382,17 +382,311 @@ static struct player *slotPlayer(void) * and hung the game in bondinvCycleForward after a respawn). Match play * routes use/reload through the native B button instead, and reads/writes * the slot player's fields directly. */ -static int padBPrev[MAX_PADS]; /* B/Y edges; track through menus too */ -static int padYPrev[MAX_PADS]; -static int padAPrev[MAX_PADS]; /* D416: slots 1+ use/reload edges */ -static int padXPrev[MAX_PADS]; -static int padSelectPrev = 0; /* Select (BACK) edge: overlay toggle */ +static int padTriggerPct; /* defined (with its default) further down */ +static u32 padActPrev[MAX_PADS]; /* D469: per-slot previous action-held bitset (edges track through menus too) */ +static int padSelectPrev[MAX_PADS]; /* Select (BACK) edge per pad (D472: was one shared flag) */ +static int s_ovOwner = -1; /* D472: pad that opened the F10 overlay (-1 = keyboard/none) */ + +/* D472: the pad that drives the F10 overlay: the opener while it is attached, + * else the lowest attached pad (keyboard-opened, or the owner detached), else 0. */ +int inputOverlayOwnerPad(void) +{ + if (s_ovOwner >= 0 && s_ovOwner < MAX_PADS && pads[s_ovOwner]) return s_ovOwner; + for (int i = 0; i < MAX_PADS; i++) + if (pads[i]) return i; + return 0; +} + +/* ------------------------------------------------------------------------ + * D469: data-driven gamepad action table + presets + per-seat rebinding. + * + * Sources are SDL controller buttons plus the two analog triggers treated as + * digital (padTriggerPct). Each action is bound to up to two sources, written + * in the ini as SDL names joined by a comma ("a,x", "righttrigger", "none"). + * Input.PadPreset: 0 = Jinx 1.1 (the pre-D469 hard-wired mapping; default), + * 1/2 = Jinx 1.2/1.3 (NOT defined by D394: reserved, resolve to 1.1), + * 3 = Custom (the per-seat Input.Pad.* strings). Seat 0 keys are + * Input.Pad.; seats 1..3 are Input.Pad2/3/4.. Seat = pads[] + * index (D448), not the controller slot. + * + * Not rebindable (menu lockout guard): menu accept/cancel (A,X -> native A; + * B,Y -> native B outside a playable stage), D-pad -> N64 D-pad, sticks, + * Back (opens the overlay). Start is forced back to `start` if a Custom table + * leaves it unbound. Threading: the poll thread reads an immutable published + * table (atomic pointer) rebuilt into a spare buffer only when the config + * signature changes; no strtok. + * ------------------------------------------------------------------------ */ +enum { PS_A, PS_B, PS_X, PS_Y, PS_LB, PS_RB, PS_LS, PS_RS, PS_START, + PS_DUP, PS_DDOWN, PS_DLEFT, PS_DRIGHT, PS_LT, PS_RT, PS_COUNT }; +#define PSM(s) (1u << (s)) +#define PS_TRIGGERS (PSM(PS_LT) | PSM(PS_RT)) +static const struct { const char *name; int btn; } kPadSrc[PS_COUNT] = { + [PS_A] = { "a", SDL_CONTROLLER_BUTTON_A }, + [PS_B] = { "b", SDL_CONTROLLER_BUTTON_B }, + [PS_X] = { "x", SDL_CONTROLLER_BUTTON_X }, + [PS_Y] = { "y", SDL_CONTROLLER_BUTTON_Y }, + [PS_LB] = { "leftshoulder", SDL_CONTROLLER_BUTTON_LEFTSHOULDER }, + [PS_RB] = { "rightshoulder", SDL_CONTROLLER_BUTTON_RIGHTSHOULDER }, + [PS_LS] = { "leftstick", SDL_CONTROLLER_BUTTON_LEFTSTICK }, + [PS_RS] = { "rightstick", SDL_CONTROLLER_BUTTON_RIGHTSTICK }, + [PS_START] = { "start", SDL_CONTROLLER_BUTTON_START }, + [PS_DUP] = { "dpup", SDL_CONTROLLER_BUTTON_DPAD_UP }, + [PS_DDOWN] = { "dpdown", SDL_CONTROLLER_BUTTON_DPAD_DOWN }, + [PS_DLEFT] = { "dpleft", SDL_CONTROLLER_BUTTON_DPAD_LEFT }, + [PS_DRIGHT] = { "dpright", SDL_CONTROLLER_BUTTON_DPAD_RIGHT }, + [PS_LT] = { "lefttrigger", -1 }, + [PS_RT] = { "righttrigger", -1 }, +}; + +enum { PA_FIRE, PA_AIM, PA_USE, PA_RELOAD, PA_CROUCH, PA_NEXTWPN, PA_PREVWPN, + PA_GADGET, PA_START, PA_COUNT }; +/* Jinx 1.1 (D394) = def. Fire/Aim analog triggers act in any state; every + * digital source only acts while a stage is playable (facePlayable). */ +static const struct { const char *key; const char *label; const char *def; } kPadAct[PA_COUNT] = { + [PA_FIRE] = { "Fire", "Fire", "righttrigger" }, + [PA_AIM] = { "Aim", "Aim", "lefttrigger,leftshoulder" }, + [PA_USE] = { "Use", "Use / action", "a" }, + [PA_RELOAD] = { "Reload", "Reload", "x" }, + [PA_CROUCH] = { "Crouch", "Crouch", "leftstick,rightstick" }, + [PA_NEXTWPN] = { "NextWeapon", "Next weapon", "y" }, + [PA_PREVWPN] = { "PrevWeapon", "Previous weapon", "none" }, + [PA_GADGET] = { "Gadget", "Cycle gadget", "b" }, + [PA_START] = { "Start", "Start / pause", "start" }, +}; + +typedef struct { u32 mask[PA_COUNT]; } PadBinds; +typedef struct { PadBinds seat[MAX_PADS]; } PadTable; + +static int padPreset = 0; /* Input.PadPreset */ +static char padStr[MAX_PADS][PA_COUNT][32]; /* Input.Pad[N]. */ +static char padStrKey[MAX_PADS][PA_COUNT][40]; +static PadTable padTabBuf[2]; +static SDL_atomic_t padTabPub; /* published PadTable* */ +static unsigned padTabSig = 0; +static int padTabValid = 0; + +/* Parse "a,x" / "none" into a source mask (no strtok). *bad counts unknown names. */ +static u32 padParseMask(const char *s, int *bad) +{ + u32 m = 0; + while (s && *s) { + while (*s == ' ' || *s == ',') s++; + const char *e = s; + while (*e && *e != ',' && *e != ' ') e++; + size_t n = (size_t)(e - s); + if (n) { + int hit = 0; + for (int i = 0; i < PS_COUNT; i++) + if (strlen(kPadSrc[i].name) == n && !SDL_strncasecmp(s, kPadSrc[i].name, n)) { + m |= PSM(i); + hit = 1; + break; + } + if (!hit && !(n == 4 && !SDL_strncasecmp(s, "none", 4)) && bad) (*bad)++; + } + s = e; + } + return m; +} + +static void padBuildBinds(PadBinds *b, int seat, int custom, int *bad) +{ + for (int a = 0; a < PA_COUNT; a++) + b->mask[a] = padParseMask(custom ? padStr[seat][a] : kPadAct[a].def, bad); + if (!b->mask[PA_START]) b->mask[PA_START] = PSM(PS_START); /* lockout guard: keep a pause path */ +} + +static int padPresetCustom(void) { return padPreset >= 3; } + +/* Signature of everything the table depends on (preset only unless Custom). */ +static unsigned padComputeSig(void) +{ + unsigned h = 2166136261u ^ (unsigned)padPreset; + if (padPresetCustom()) + 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; + return h; +} + +/* Poll thread: rebuild into the spare buffer and publish atomically. */ +static const PadTable *padTabGet(void) +{ + static SDL_SpinLock rebuildLock; /* callers on several threads (poll, overlay, UI) */ + unsigned sig = padComputeSig(); + if (!padTabValid || sig != padTabSig) { + SDL_AtomicLock(&rebuildLock); + if (padTabValid && padComputeSig() == padTabSig) { /* another thread just rebuilt */ + SDL_AtomicUnlock(&rebuildLock); + return (const PadTable *)SDL_AtomicGetPtr((void **)&padTabPub); + } + 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); + SDL_AtomicSetPtr((void **)&padTabPub, spare); + padTabSig = sig; + padTabValid = 1; + sysLogPrintf(LOG_INFO, "input: pad table rebuilt (preset %d%s%s, %d unknown name(s))", + padPreset, padPresetCustom() ? " custom" : "", + padPreset == 1 || padPreset == 2 ? " [1.2/1.3 undefined -> 1.1]" : "", bad); + SDL_AtomicUnlock(&rebuildLock); + return spare; + } + return (const PadTable *)SDL_AtomicGetPtr((void **)&padTabPub); +} + +/* Current raw source state of a pad (digital buttons + triggers). */ +static u32 padReadRaw(SDL_GameController *pad) +{ + u32 raw = 0; + if (!pad) return 0; + for (int i = 0; i < PS_LT; i++) + if (SDL_GameControllerGetButton(pad, (SDL_GameControllerButton)kPadSrc[i].btn)) raw |= PSM(i); + int trigPt = padTriggerPct * 327; /* % of the 0..32767 trigger travel */ + if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > trigPt) raw |= PSM(PS_LT); + if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) > trigPt) raw |= PSM(PS_RT); + return raw; +} + +static u32 padActHeld(const PadBinds *b, u32 raw) +{ + u32 h = 0; + for (int a = 0; a < PA_COUNT; a++) + if (raw & b->mask[a]) h |= 1u << a; + return h; +} + +typedef struct { int facePlayable; int slotNative; int southpaw; } PadMapCtx; +typedef struct { unsigned button; int crouch; int gadgetEdge; u32 actHeld; } PadMapOut; + +/* The pure mapping: raw source bits + bindings + previous action state -> + * N64 button mask / crouch / gadget edge. buttonIn is whatever the keyboard + * / stick code already put in the mask (the southpaw swap acts on it exactly + * as the pre-D469 inline code did). No SDL, no game state: GE_PADMAPTEST. */ +static void padMapPure(const PadBinds *b, u32 raw, u32 actPrev, unsigned buttonIn, + const PadMapCtx *c, PadMapOut *o) +{ + unsigned button = buttonIn; + u32 held = padActHeld(b, raw); + if (raw & b->mask[PA_FIRE] & PS_TRIGGERS) button |= GE_CONT_G; + if (raw & b->mask[PA_AIM] & PS_TRIGGERS) button |= GE_CONT_R; + if (c->southpaw) { /* Wave A: swap fire/grenade after the raw trigger edges */ + unsigned t = button; + button = (t & ~(GE_CONT_G | GE_CONT_R)) + | ((t & GE_CONT_G) ? GE_CONT_R : 0) + | ((t & GE_CONT_R) ? GE_CONT_G : 0); + } + o->crouch = 0; + o->gadgetEdge = 0; + u32 edge = held & ~actPrev; + if (c->facePlayable) { + if (held & (1u << PA_CROUCH)) o->crouch = 1; + if (c->slotNative && (edge & ((1u << PA_USE) | (1u << PA_RELOAD)))) button |= GE_CONT_B; + if (edge & (1u << PA_GADGET)) o->gadgetEdge = 1; + if (edge & (1u << PA_NEXTWPN)) button |= GE_CONT_A; + if (edge & (1u << PA_PREVWPN)) button |= GE_CONT_A | GE_CONT_G; /* same backward-cycle idiom as the mouse wheel */ + if (raw & b->mask[PA_AIM] & ~PS_TRIGGERS) button |= GE_CONT_R; /* digital sources (LB aim): playable only, after the swap */ + if (raw & b->mask[PA_FIRE] & ~PS_TRIGGERS) button |= GE_CONT_G; + } else { + if (raw & (PSM(PS_A) | PSM(PS_X))) button |= GE_CONT_A; /* menu lockout guard: fixed native accept/cancel */ + if (raw & (PSM(PS_B) | PSM(PS_Y))) button |= GE_CONT_B; + } + if (held & (1u << PA_START)) button |= GE_CONT_START; + if (raw & PSM(PS_DUP)) button |= GE_CONT_UP; + if (raw & PSM(PS_DDOWN)) button |= GE_CONT_DOWN; + if (raw & PSM(PS_DLEFT)) button |= GE_CONT_LEFT; + if (raw & PSM(PS_DRIGHT)) button |= GE_CONT_RIGHT; + o->button = button; + o->actHeld = held; +} + +/* Legacy reference: the pre-D469 inline pad block, transcribed verbatim from + * release/v0.4.1 (input.c, trigger/face-button section) onto raw source bits. + * Used ONLY by GE_PADMAPTEST to prove preset 0 is byte-identical. */ +static void padMapLegacy(u32 raw, u32 prevA, u32 prevB, u32 prevX, u32 prevY, unsigned buttonIn, + const PadMapCtx *c, PadMapOut *o) +{ + unsigned button = buttonIn; + int crouchNow = 0, gadget = 0; + if (raw & PSM(PS_RT)) button |= GE_CONT_G; + if (raw & PSM(PS_LT)) button |= GE_CONT_R; + if (c->southpaw) { + int t = button; + button = (t & ~(GE_CONT_G | GE_CONT_R)) + | ((t & GE_CONT_G) ? GE_CONT_R : 0) + | ((t & GE_CONT_R) ? GE_CONT_G : 0); + } + int padA = !!(raw & PSM(PS_A)), padX = !!(raw & PSM(PS_X)); + int padB = !!(raw & PSM(PS_B)), padY = !!(raw & PSM(PS_Y)); + if (c->facePlayable) { + if ((raw & PSM(PS_LS)) || (raw & PSM(PS_RS))) crouchNow = 1; + if (c->slotNative) { + if ((padA && !prevA) || (padX && !prevX)) button |= GE_CONT_B; + } + if (padB && !prevB) gadget = 1; + if (padY && !prevY) button |= GE_CONT_A; + } else { + if (padA || padX) button |= GE_CONT_A; + if (padB || padY) button |= GE_CONT_B; + } + if (c->facePlayable && (raw & PSM(PS_LB))) button |= GE_CONT_R; + if (raw & PSM(PS_START)) button |= GE_CONT_START; + if (raw & PSM(PS_DUP)) button |= GE_CONT_UP; + if (raw & PSM(PS_DDOWN)) button |= GE_CONT_DOWN; + if (raw & PSM(PS_DLEFT)) button |= GE_CONT_LEFT; + if (raw & PSM(PS_DRIGHT)) button |= GE_CONT_RIGHT; + o->button = button; + o->crouch = crouchNow; + o->gadgetEdge = gadget; + o->actHeld = 0; +} + +/* Seat that feeds controller slot idx (pads[] index; D448/D416). */ +static int padSeatForSlot(int idx) +{ + if (idx < 0) idx = 0; + if (idx >= MAX_PADS) idx = MAX_PADS - 1; + if (inputMpSplit() && inputMpKbmP1() && idx > 0) return idx - 1; + return idx; +} /* D401: per-pad haptics (N64 Rumble Pak -> SDL_GameControllerRumble). * padRumbleOn is detected when the pad opens (inputOpenPads); gRumbleScale * is the single global Input.RumbleScale config value (0 = silent, * 1 = full), applied to every pad. */ static int padRumbleOn[MAX_PADS]; + +/* D471: controller family per seat, for button NAMES in the PC menus (no + * glyphs). Detected once when a pad is seated; 0 = Xbox-style (default, + * also Steam Deck / unknown), 1 = PlayStation, 2 = Nintendo. SDL's default + * GAMECONTROLLER_USE_BUTTON_LABELS=1 means BUTTON_A is the button printed + * "A" on a Nintendo pad, so only shoulders/triggers/Back/Start differ there. */ +enum { PADFAM_XBOX = 0, PADFAM_PS = 1, PADFAM_NINTENDO = 2 }; +static u8 padFamily[MAX_PADS]; + +static void padDetectFamily(int seat) +{ + padFamily[seat] = PADFAM_XBOX; +#if SDL_VERSION_ATLEAST(2, 0, 12) + switch (SDL_GameControllerGetType(pads[seat])) { + case SDL_CONTROLLER_TYPE_PS3: + case SDL_CONTROLLER_TYPE_PS4: +#if SDL_VERSION_ATLEAST(2, 0, 14) + case SDL_CONTROLLER_TYPE_PS5: +#endif + padFamily[seat] = PADFAM_PS; break; + case SDL_CONTROLLER_TYPE_NINTENDO_SWITCH_PRO: +#if SDL_VERSION_ATLEAST(2, 24, 0) + case SDL_CONTROLLER_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_CONTROLLER_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_CONTROLLER_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: +#endif + padFamily[seat] = PADFAM_NINTENDO; break; + default: break; + } +#endif +} static float gRumbleScale = 0.5f; static int mouseEnabled = 1; @@ -764,10 +1058,14 @@ static void inputOpenPads(void) if (pads[s] && !SDL_GameControllerGetAttached(pads[s])) { sysLogPrintf(LOG_NOTE, "input: seat %d pad detached%s", s, inMatch ? " (seat kept for the match)" : ""); +#if SDL_VERSION_ATLEAST(2, 0, 12) + SDL_GameControllerSetPlayerIndex(pads[s], -1); /* D471: LED off */ +#endif SDL_GameControllerClose(pads[s]); pads[s] = NULL; padInst[s] = -1; padRumbleOn[s] = 0; + padFamily[s] = PADFAM_XBOX; if (inMatch) s_seatCompactOwed = 1; } } @@ -781,6 +1079,7 @@ static void inputOpenPads(void) pads[d] = pads[s]; pads[s] = NULL; padInst[d] = padInst[s]; padInst[s] = -1; padRumbleOn[d] = padRumbleOn[s]; padRumbleOn[s] = 0; + padFamily[d] = padFamily[s]; padFamily[s] = PADFAM_XBOX; /* D471 */ sysLogPrintf(LOG_NOTE, "input: pad '%s' moved seat %d -> %d", SDL_GameControllerName(pads[d]), s, d); } @@ -809,6 +1108,7 @@ static void inputOpenPads(void) if (pads[seat]) { padInst[seat] = SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(pads[seat])); padDetectRumble(seat); + padDetectFamily(seat); /* D471 */ sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as seat %d (rumble %s)", i, SDL_GameControllerName(pads[seat]), seat, padRumbleOn[seat] ? "yes" : "no"); } @@ -831,6 +1131,13 @@ static void inputOpenPads(void) /* keyboard/mouse is controller 0; seat s is controller s + 1 */ connectedMask = (0x1 | (seatMask << 1)) & ((1 << MAX_PADS) - 1); } +#if SDL_VERSION_ATLEAST(2, 0, 12) + /* D471 (PD-port parity): the pad's player LED shows its player number; + * seat s is player s + 1 when keyboard/mouse is P1. Re-applied on every + * (re)scan, so compaction and MP-mode changes pick it up. */ + for (int s = 0; s < MAX_PADS; ++s) + if (pads[s]) SDL_GameControllerSetPlayerIndex(pads[s], s + (inputMpKbmP1() ? 1 : 0)); +#endif { /* D416 test harness: GE_MPVIRT= reports n extra idle controllers * (1..3) so the MP menu unlocks and a 2P+ stage loads with no pads. */ const char *v = getenv("GE_MPVIRT"); @@ -1114,6 +1421,7 @@ static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked, static int bindsVersion = 0; /* D380/D386: versioned migration of effective ini binds */ static int crouchMode = 0; /* 0 = hold, 1 = toggle (latched crouch input) */ +static void padMapSelfTest(void); int inputInit(void) { if (!SDL_WasInit(SDL_INIT_GAMECONTROLLER)) { @@ -1166,6 +1474,8 @@ int inputInit(void) applyCursorVisibility(); /* hide the OS cursor if we start focused */ inputApplyMouseRequests(); /* inputInit runs on the host thread */ + if (GE_ENVFLAG("GE_PADMAPTEST")) padMapSelfTest(); /* D469 */ + sysLogPrintf(LOG_INFO, "input: ready (mask=0x%x, %d controller(s), aimSpeed=%d)", connectedMask, numControllers, mouseAimSpeed); return connectedMask; @@ -1741,24 +2051,40 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * * (neutral pad, no stick) and the nav keys / wheel / gamepad drive the * overlay instead. Mirrors the WI-1 "cursor free in a stage -> withhold * input" pattern. Controllers 1-3 are untouched. */ + if (idx == 0 && !optionsOverlayIsOpen()) s_ovOwner = -1; /* D472: ownership lasts one open */ + + /* D472: a non-zero owner pad is swallowed too (neutral) while the overlay + * is open; pad 0 below still hosts the overlay tick. */ + if (idx != 0 && optionsOverlayIsOpen() && idx == inputOverlayOwnerPad()) { + padActPrev[idx] = pads[idx] ? padActHeld(&padTabGet()->seat[padSeatForSlot(idx)], padReadRaw(pads[idx])) : 0; + if (stick_x) *stick_x = 0; + if (stick_y) *stick_y = 0; + return 0; + } + if (idx == 0 && optionsOverlayIsOpen()) { const Uint8 *overlayKs = SDL_GetKeyboardState(NULL); - s_useHeldPrev = actHeld(overlayKs, IA_CANCEL) || - (pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_A)); - s_reloadHeldPrev = actHeld(overlayKs, IA_RELOAD) || - (pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_X)); + /* D469: pad use/reload/edge state follows the bound actions, not fixed A/X/B/Y */ + u32 ovHeld = pads[0] ? padActHeld(&padTabGet()->seat[0], padReadRaw(pads[0])) : 0; + s_useHeldPrev = actHeld(overlayKs, IA_CANCEL) || (ovHeld & (1u << PA_USE)); + s_reloadHeldPrev = actHeld(overlayKs, IA_RELOAD) || (ovHeld & (1u << PA_RELOAD)); s_crouchLatch = 0; s_crouchHeldPrev = 0; inputDropCrouch(); - padBPrev[0] = pads[0] ? SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_B) : 0; - padYPrev[0] = pads[0] ? SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_Y) : 0; - /* Select closes the overlay. padSelectPrev is tracked on this path - * and the open path below alike, so a button held across the - * transition cannot immediately re-toggle it. */ - int selNow = pads[0] ? SDL_GameControllerGetButton(pads[0], - SDL_CONTROLLER_BUTTON_BACK) : 0; - if (selNow && !padSelectPrev) optionsOverlayToggle(); - padSelectPrev = selNow; + padActPrev[0] = ovHeld; + /* D472: the OWNER pad's Select closes the overlay; every pad's edge is + * tracked here and on the open path alike, so a button held across + * the transition cannot immediately re-toggle it. */ + { + int own = inputOverlayOwnerPad(); + int closeNow = 0; + for (int i = 0; i < MAX_PADS; i++) { + int selNow = pads[i] ? SDL_GameControllerGetButton(pads[i], SDL_CONTROLLER_BUTTON_BACK) : 0; + if (i == own && selNow && !padSelectPrev[i]) closeNow = 1; + padSelectPrev[i] = selNow; + } + if (closeNow) optionsOverlayToggle(); + } optionsOverlayHandleInput(); if (stick_x) *stick_x = 0; if (stick_y) *stick_y = 0; @@ -1925,8 +2251,9 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * * combined edges prevent a held input from repeatedly using a door. * In menus/watch/tank the pad keeps native accept/cancel buttons. */ SDL_GameController *slot0Pad = slotPad(0); - int padUse = slot0Pad && SDL_GameControllerGetButton(slot0Pad, SDL_CONTROLLER_BUTTON_A); - int padReload = slot0Pad && SDL_GameControllerGetButton(slot0Pad, SDL_CONTROLLER_BUTTON_X); + u32 slot0Held = slot0Pad ? padActHeld(&padTabGet()->seat[padSeatForSlot(0)], padReadRaw(slot0Pad)) : 0; + int padUse = (slot0Held & (1u << PA_USE)) != 0; /* D469: bound actions (Jinx 1.1: A / X) */ + int padReload = (slot0Held & (1u << PA_RELOAD)) != 0; int useNow = scriptIsActive(0) ? scriptSlots[0].use : (actHeld(ks, IA_CANCEL) || padUse); int reloadNow = scriptIsActive(0) ? scriptSlots[0].reload : (actHeld(ks, IA_RELOAD) || padReload); int playable = inputCanUseGameplayActions(menuMode); @@ -2404,70 +2731,37 @@ static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char * if (ry < -RSTICK_THRESHOLD) button |= GE_CONT_E; } - int trigPt = padTriggerPct * 327; /* % of the 0..32767 trigger travel */ - if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) > trigPt) - button |= GE_CONT_G; - if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > trigPt) - button |= GE_CONT_R; - /* Wave A: southpaw swaps the fire (G) and grenade (R) trigger actions - * (right<->left trigger), after the raw edges above are captured. */ - if (padSouthpaw) { - int t = button; - button = (t & ~(GE_CONT_G | GE_CONT_R)) - | ((t & GE_CONT_G) ? GE_CONT_R : 0) - | ((t & GE_CONT_R) ? GE_CONT_G : 0); + /* D469: triggers/faces/shoulders/Start/D-pad via the data-driven + * action table (preset 0 == the pre-D469 mapping: RT fire, LT/LB aim, + * A use, X reload, B gadget, Y next weapon, stick clicks crouch, + * Start; menus keep native A/X accept + B/Y cancel). Xbox 1.1 + * Jinx-style; RB's remaster HD toggle is unavailable. */ + { + PadMapCtx pc; + PadMapOut po; + pc.facePlayable = (idx == 0 || inputMpSplit()) && inputCanUseGameplayActions(padMenuMode); + pc.slotNative = (idx > 0 || inputMpPadsOwnP1()); + pc.southpaw = padSouthpaw; + const PadBinds *pb = &padTabGet()->seat[padSeatForSlot(idx)]; + padMapPure(pb, padReadRaw(pad), padActPrev[idx], button, &pc, &po); + button = po.button; + if (po.crouch) crouchNow = 1; + if (po.gadgetEdge) inputCycleGadget(); + padActPrev[idx] = po.actHeld; } - /* Xbox 1.1 Jinx-style gameplay: A=use, X=reload (handled by the - * keyboard action gate), B=gadgets, Y=weapons, stick clicks=crouch, - * LB=aim. RB's remaster HD toggle is unavailable in the N64 port. - * Menus/watch/tank retain native accept/cancel mappings. */ - int padA = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_A); - int padX = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_X); - int padB = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_B); - int padY = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_Y); - int facePlayable = (idx == 0 || inputMpSplit()) && inputCanUseGameplayActions(padMenuMode); - if (facePlayable) { - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSTICK) || - SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_RIGHTSTICK)) - crouchNow = 1; - if (idx > 0 || inputMpPadsOwnP1()) { - /* Slots 1+ (and a pad-owned P1) have no keyboard block: pad A / X = the native B - * button on the press edge (use, reload when nothing to use). */ - if ((padA && !padAPrev[idx]) || (padX && !padXPrev[idx])) - button |= GE_CONT_B; - } - if (padB && !padBPrev[idx]) inputCycleGadget(); - if (padY && !padYPrev[idx]) button |= GE_CONT_A; - } else { - if (padA || padX) button |= GE_CONT_A; - if (padB || padY) button |= GE_CONT_B; - } - padBPrev[idx] = padB; - padYPrev[idx] = padY; - padAPrev[idx] = padA; - padXPrev[idx] = padX; - if (facePlayable && SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) - button |= GE_CONT_R; - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_START)) - button |= GE_CONT_START; - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_DPAD_UP)) - button |= GE_CONT_UP; - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) - button |= GE_CONT_DOWN; - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) - button |= GE_CONT_LEFT; - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) - button |= GE_CONT_RIGHT; - /* Select (BACK) opens the F10 options overlay -- the gamepad * equivalent of the F10 key for controller-only machines (Steam * 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). */ - if (selNow && !padSelectPrev && !frontOptionsBlocksOverlay()) optionsOverlayToggle(); - padSelectPrev = selNow; + /* D472: only opens here (closing is the owner's, in the overlay path) */ + if (selNow && !padSelectPrev[idx] && !frontOptionsBlocksOverlay() && !optionsOverlayIsOpen()) { + s_ovOwner = idx; + optionsOverlayToggle(); + } + padSelectPrev[idx] = selNow; } } @@ -2645,6 +2939,73 @@ int inputReleaseCapture(void) * stage) would persist and the mouse UI would be unusable. Force the cursor * free + visible every poll; the normal reconcile resumes once the overlay * closes and the early-return no longer fires. */ +/* D471: family of a seat's pad (GE_PADFAMILY=xbox|ps|nintendo overrides, + * a cached dev knob for headless screenshots). */ +int inputPadFamily(int seat) +{ + static int ov = -2; + if (ov == -2) { + const char *e = getenv("GE_PADFAMILY"); + ov = !e ? -1 : !SDL_strcasecmp(e, "ps") ? PADFAM_PS + : !SDL_strcasecmp(e, "nintendo") ? PADFAM_NINTENDO + : !SDL_strcasecmp(e, "xbox") ? PADFAM_XBOX : -1; + } + if (ov >= 0) return ov; + return (seat >= 0 && seat < MAX_PADS && pads[seat]) ? padFamily[seat] : PADFAM_XBOX; +} + +/* D471: expand {A} {B} {X} {Y} {BACK} {START} in a menu help template with + * the names of the menu pad's family (seat 0, which drives the menus). Xbox + * expands to exactly the old text. */ +void inputPadHelpFmtFam(char *out, size_t n, const char *tmpl, int fam) +{ + static const char *const names[3][6] = { + { "A", "B", "X", "Y", "BACK", "START" }, + { "CROSS", "CIRCLE", "SQUARE", "TRIANGLE", "SHARE", "OPTIONS" }, + { "A", "B", "X", "Y", "MINUS", "PLUS" }, + }; + static const char *const keys[6] = { "{A}", "{B}", "{X}", "{Y}", "{BACK}", "{START}" }; + if (fam < 0 || fam > 2) fam = PADFAM_XBOX; + size_t o = 0; + if (n == 0) return; + while (*tmpl && o + 1 < n) { + int hit = -1; + if (*tmpl == '{') + for (int k = 0; k < 6; k++) + if (!strncmp(tmpl, keys[k], strlen(keys[k]))) { hit = k; break; } + if (hit >= 0) { + for (const char *p = names[fam][hit]; *p && o + 1 < n; p++) out[o++] = *p; + tmpl += strlen(keys[hit]); + } else { + out[o++] = *tmpl++; + } + } + out[o] = 0; +} + +void inputPadHelpFmt(char *out, size_t n, const char *tmpl) +{ + inputPadHelpFmtFam(out, n, tmpl, inputPadFamily(optionsOverlayIsOpen() ? inputOverlayOwnerPad() : 0)); /* D472 */ +} + +/* D471: display name for a binding source token (kPadSrc names) on a seat's + * pad family; NULL = keep the caller's existing Xbox-style text. */ +const char *inputPadSourceFamilyName(int seat, const char *src) +{ + static const struct { const char *src, *ps, *nin; } t[] = { + { "a", "Cross", NULL }, { "b", "Circle", NULL }, { "x", "Square", NULL }, { "y", "Triangle", NULL }, + { "start", "Options", "Plus" }, + { "leftshoulder", "L1", "L" }, { "rightshoulder", "R1", "R" }, + { "lefttrigger", "L2", "ZL" }, { "righttrigger", "R2", "ZR" }, + { "leftstick", "L3", NULL }, { "rightstick", "R3", NULL }, + }; + int fam = inputPadFamily(seat); + if (fam == PADFAM_XBOX) return NULL; + for (size_t k = 0; k < sizeof(t) / sizeof(t[0]); k++) + if (!strcmp(src, t[k].src)) return fam == PADFAM_PS ? t[k].ps : t[k].nin; + return NULL; +} + int inputPadButton(int idx, SDL_GameControllerButton b) { if (idx < 0 || idx >= MAX_PADS || !pads[idx]) return 0; @@ -3120,8 +3481,205 @@ int portMouseAimPdGetTurn(f32 *tx, f32 *ty) return 1; } +/* ------------------------------------------------------------------------ + * D469: UI-facing pad binding API (F10 overlay + front-end PC Options) and + * the GE_PADMAPTEST self-test. + * ------------------------------------------------------------------------ */ +int inputPadActionCount(void) { return PA_COUNT; } +const char *inputPadActionLabel(int act) { return (act >= 0 && act < PA_COUNT) ? kPadAct[act].label : ""; } +int inputPadSourceCount(void) { return PS_COUNT; } +const char *inputPadSourceName(int src) { return (src >= 0 && src < PS_COUNT) ? kPadSrc[src].name : "none"; } +int inputPadActionForKey(const char *key) +{ + char want[48]; + for (int a = 0; a < PA_COUNT; a++) { + snprintf(want, sizeof(want), "Input.Pad.%s", kPadAct[a].key); + if (key && !strcmp(key, want)) return a; + } + return -1; +} +int inputPadSeatPresent(int seat) { return seat >= 0 && seat < MAX_PADS && pads[seat] != NULL; } +unsigned inputPadHeldSources(int seat) +{ + return (seat >= 0 && seat < MAX_PADS) ? padReadRaw(pads[seat]) : 0; +} +int inputPadPresetStep(int cur, int dir) +{ + (void)dir; /* only Jinx 1.1 (0) and Custom (3) are defined; 1/2 are reserved (D394 does not define 1.2/1.3) */ + return cur == 0 ? 3 : 0; +} + +/* "a,x" -> src[0]=A, src[1]=X; empty/none/unknown -> -1 (positional). */ +static void padSlotSources(const char *s, int src[INPUT_BIND_SLOTS]) +{ + src[0] = src[1] = -1; + for (int slot = 0; slot < INPUT_BIND_SLOTS && s; slot++) { + const char *e = strchr(s, ','); + size_t n = e ? (size_t)(e - s) : strlen(s); + for (int i = 0; i < PS_COUNT; i++) + if (strlen(kPadSrc[i].name) == n && !SDL_strncasecmp(s, kPadSrc[i].name, n)) { src[slot] = i; break; } + s = e ? e + 1 : NULL; + } +} + +void inputPadBindingText(int seat, int act, int slot, char *out, int n) +{ + int src[INPUT_BIND_SLOTS]; + if (seat < 0 || seat >= MAX_PADS || act < 0 || act >= PA_COUNT || slot < 0 || slot >= INPUT_BIND_SLOTS) { + snprintf(out, n, "none"); + return; + } + padSlotSources(padStr[seat][act], src); + snprintf(out, n, "%s", src[slot] >= 0 ? kPadSrc[src[slot]].name : "none"); +} + +/* src < 0 clears the slot. Returns 1 on success. Writes the seat's Input.Pad* + * string (the poll thread notices the signature change and republishes). */ +int inputPadBindingSet(int seat, int act, int slot, int src) +{ + int t[INPUT_BIND_SLOTS]; + if (seat < 0 || seat >= MAX_PADS || act < 0 || act >= PA_COUNT || + slot < 0 || slot >= INPUT_BIND_SLOTS || src >= PS_COUNT) return 0; + padSlotSources(padStr[seat][act], t); + t[slot] = src < 0 ? -1 : src; + char buf[sizeof(padStr[0][0])]; + snprintf(buf, sizeof(buf), "%s", t[0] >= 0 ? kPadSrc[t[0]].name : "none"); + if (t[1] >= 0) { + size_t l = strlen(buf); + snprintf(buf + l, sizeof(buf) - l, ",%s", kPadSrc[t[1]].name); + } + snprintf(padStr[seat][act], sizeof(padStr[seat][act]), "%s", buf); + return 1; +} + +/* Reset one action to the Jinx 1.1 default on every seat. Returns seats changed. */ +int inputPadBindingReset(int act) +{ + int n = 0; + if (act < 0 || act >= PA_COUNT) return 0; + for (int s = 0; s < MAX_PADS; s++) { + if (strcmp(padStr[s][act], kPadAct[act].def)) n++; + snprintf(padStr[s][act], sizeof(padStr[s][act]), "%s", kPadAct[act].def); + } + return n; +} + +/* GE_PADMAPTEST=1: boot-time proof (logs, then the game continues). + * 1. preset 0 == the pre-D469 mapping, exhaustively (every raw source + * combination x prev A/B/X/Y x facePlayable/slotNative/southpaw x 5 + * incoming masks); + * 2. a per-source table for preset 0 (menu and playable); + * 3. Custom remap moves the action; seats are independent; Start guard; + * 4. published-table path (padTabGet) honours the preset/strings. + * Config values are restored afterwards so nothing is persisted. */ +static void padMapSelfTest(void) +{ + int fail = 0; + PadBinds p0; + int bad = 0; + padBuildBinds(&p0, 0, 0, &bad); + static const unsigned kIn[5] = { 0, GE_CONT_G, GE_CONT_R, GE_CONT_G | GE_CONT_R, 0xffffu }; + long compared = 0, mism = 0; + for (u32 raw = 0; raw < (1u << PS_COUNT); raw++) + for (u32 pv = 0; pv < 16; pv++) { + u32 prevRaw = (pv & 1 ? PSM(PS_A) : 0) | (pv & 2 ? PSM(PS_B) : 0) | + (pv & 4 ? PSM(PS_X) : 0) | (pv & 8 ? PSM(PS_Y) : 0); + for (int cx = 0; cx < 8; cx++) { + PadMapCtx c = { cx & 1, (cx >> 1) & 1, (cx >> 2) & 1 }; + for (int k = 0; k < 5; k++) { + PadMapOut a, b; + padMapPure(&p0, raw, padActHeld(&p0, prevRaw), kIn[k], &c, &a); + padMapLegacy(raw, prevRaw & PSM(PS_A), prevRaw & PSM(PS_B), prevRaw & PSM(PS_X), + prevRaw & PSM(PS_Y), kIn[k], &c, &b); + compared++; + if (a.button != b.button || a.crouch != b.crouch || a.gadgetEdge != b.gadgetEdge) { + if (mism++ < 5) + sysLogPrintf(LOG_ERROR, "GE_PADMAPTEST MISMATCH raw=%04x prev=%x ctx=%d in=%04x new=%04x/%d/%d old=%04x/%d/%d", + raw, pv, cx, kIn[k], a.button, a.crouch, a.gadgetEdge, + b.button, b.crouch, b.gadgetEdge); + } + } + } + } + sysLogPrintf(mism ? LOG_ERROR : LOG_INFO, "GE_PADMAPTEST: preset0 == legacy: %ld cases compared, %ld mismatches", compared, mism); + fail += mism != 0; + + for (int s = 0; s < PS_COUNT; s++) + for (int play = 0; play < 2; play++) { + PadMapCtx c = { play, 0, 0 }; + PadMapOut a; + padMapPure(&p0, PSM(s), 0, 0, &c, &a); + sysLogPrintf(LOG_INFO, "GE_PADMAPTEST: preset0 %-13s %-8s -> mask=%04x crouch=%d gadget=%d", + kPadSrc[s].name, play ? "playable" : "menu", a.button, a.crouch, a.gadgetEdge); + } + + /* Custom: seat 0 Fire := a, seat 1 untouched, seat 2 Use := y. */ + int savedPreset = padPreset; + char saved[MAX_PADS][PA_COUNT][32]; + memcpy(saved, padStr, sizeof(saved)); + padPreset = 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"); + snprintf(padStr[3][PA_START], sizeof(padStr[0][0]), "none"); + const PadTable *t = padTabGet(); + PadMapCtx play = { 1, 0, 0 }; + PadMapOut o; + padMapPure(&t->seat[0], PSM(PS_A), 0, 0, &play, &o); + int c1 = (o.button & GE_CONT_G) != 0; + padMapPure(&t->seat[0], PSM(PS_RT), 0, 0, &play, &o); + int c2 = (o.button & GE_CONT_G) == 0; + padMapPure(&t->seat[1], PSM(PS_RT), 0, 0, &play, &o); + int c3 = (o.button & GE_CONT_G) != 0; /* seat 1 still default */ + padMapPure(&t->seat[1], PSM(PS_A), 0, 0, &play, &o); + int c4 = (o.button & GE_CONT_G) == 0; + PadMapCtx nat = { 1, 1, 0 }; + padMapPure(&t->seat[2], PSM(PS_Y), 0, 0, &nat, &o); + int c5 = (o.button & GE_CONT_B) != 0 && !(o.button & GE_CONT_A); /* Y is Use on seat 2 */ + padMapPure(&t->seat[1], PSM(PS_Y), 0, 0, &nat, &o); + int c6 = (o.button & GE_CONT_A) != 0; /* seat 1: Y still next weapon */ + padMapPure(&t->seat[3], PSM(PS_START), 0, 0, &play, &o); + int c7 = (o.button & GE_CONT_START) != 0; /* Start guard re-forces start */ + sysLogPrintf(LOG_INFO, "GE_PADMAPTEST: custom: seat0 Fire=a moves(%d) RT-off(%d); seat1 independent RT(%d) A-off(%d); seat2 Use=y(%d); seat1 Y=next(%d); start guard(%d)", + c1, c2, c3, c4, c5, c6, c7); + fail += !(c1 && c2 && c3 && c4 && c5 && c6 && c7); + /* Preset != Custom ignores the strings. */ + padPreset = 0; + t = padTabGet(); + padMapPure(&t->seat[0], PSM(PS_RT), 0, 0, &play, &o); + int c8 = (o.button & GE_CONT_G) != 0; + padMapPure(&t->seat[0], PSM(PS_A), 0, 0, &play, &o); + int c9 = (o.button & GE_CONT_G) == 0; + sysLogPrintf(LOG_INFO, "GE_PADMAPTEST: preset0 ignores Input.Pad.* (RT fire %d, A not fire %d)", c8, c9); + fail += !(c8 && c9); + /* binding API round trip */ + inputPadBindingSet(1, PA_FIRE, 1, PS_RB); + char tx[32], ty[32]; + inputPadBindingText(1, PA_FIRE, 0, tx, sizeof(tx)); + inputPadBindingText(1, PA_FIRE, 1, ty, sizeof(ty)); + int c10 = !strcmp(tx, "righttrigger") && !strcmp(ty, "rightshoulder") && !strcmp(padStr[1][PA_FIRE], "righttrigger,rightshoulder"); + inputPadBindingSet(1, PA_FIRE, 0, -1); + inputPadBindingText(1, PA_FIRE, 0, tx, sizeof(tx)); + int c11 = !strcmp(tx, "none") && !strcmp(padStr[1][PA_FIRE], "none,rightshoulder"); + sysLogPrintf(LOG_INFO, "GE_PADMAPTEST: binding API round trip (%d) clear keeps slot (%d) [%s]", c10, c11, padStr[1][PA_FIRE]); + fail += !(c10 && c11); + + memcpy(padStr, saved, sizeof(saved)); + padPreset = 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, 3); /* D469: 0 Jinx 1.1, 1/2 reserved (= 1.1), 3 Custom */ + 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); + else snprintf(padStrKey[seat][a], sizeof(padStrKey[seat][a]), "Input.Pad%d.%s", seat + 1, kPadAct[a].key); + snprintf(padStr[seat][a], sizeof(padStr[seat][a]), "%s", kPadAct[a].def); + configRegisterString(padStrKey[seat][a], padStr[seat][a], sizeof(padStr[seat][a])); + } configRegisterInt("Input.MPMode", &mpInputMode, 0, 2); /* D416: 0 Auto, 1 PadsOnly, 2 KbmP1 */ configRegisterInt("Input.MouseEnabled", &mouseEnabled, 0, 1); configRegisterInt("Input.MouseAimSpeed", &mouseAimSpeed, 1, 500); diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index b3efce00..c58cad9d 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -50,7 +50,9 @@ extern MENU current_menu; #include "front.h" /* selected_folder_num (D356 stage probe) */ #include "file.h" /* save_data (D356 reset probe: second-file isolation) */ #include "optionsoverlay.h" +#include "hudaspect.h" /* D335b/D472: PORT_HUD_ASPECT */ #include "watchsettings.h" +#include "audio.h" #include "../fast3d/gfx_api.h" /* file2.c; same extern as watchsettings.c (not in a header). */ @@ -80,7 +82,10 @@ extern s16 viGetY(void); /* ------------------------------------------------------------------------ */ enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA, ROW_RES, ROW_ACTION, ROW_FPSCAP, - ROW_HEADER, ROW_BOND_FILE, ROW_BIND /* key / mouse-button capture */ }; + ROW_HEADER, ROW_BOND_FILE, ROW_BIND /* key / mouse-button capture */, + ROW_PADBIND /* D469: gamepad button capture (per seat) */, + ROW_PADSEAT /* D469: which controller seat the pad rows edit */, + ROW_AUDIODEV /* D470: audio output device (string, cycles Default + enumerated) */ }; /* D346: wording pass -- Nightdive/Turok + PD-port conventions: title-case * On/Off, no all-caps value strings. Display-only; config stores 0/1 either way. */ @@ -89,6 +94,8 @@ 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 kTexFilter[] = { "Nearest", "Bilinear", "3-Point", NULL }; +static const char *const kPadPreset[] = { "Jinx 1.1", "Jinx 1.2 (n/a)", "Jinx 1.3 (n/a)", "Custom", NULL }; /* D469: 1.2/1.3 undefined by D394 -> behave as 1.1 */ +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 kAimRange[] = { "PC", "N64", NULL }; /* D338 */ static const int kMsaaSeq[] = { 1, 2, 4, 8, 16 }; /* D443: 16x added */ @@ -256,6 +263,7 @@ static struct Row rows[] = { { .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. */ @@ -283,6 +291,22 @@ static struct Row rows[] = { { .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): @@ -393,6 +417,10 @@ static struct Row rows[] = { { .key="__ResetGraphics", .label="Reset to defaults", .kind=ROW_ACTION }, { .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 @@ -434,7 +462,8 @@ 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")) return rowIndexByKey("__HdrInput"); + 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"); @@ -446,6 +475,7 @@ 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"); @@ -475,8 +505,37 @@ static SDL_SpinLock s_bindCaptureLock; static int s_bindPending = -1, s_bindCaptureRow = -1, s_bindError = 0; static SDL_atomic_t s_bindHold; /* captured key/mouse button, shared with pad poll */ +/* D469: gamepad capture (Controller page). Same modal flag as the key/mouse + * capture (so F10/Back/Start cannot close the page mid-capture), but driven + * by the edited seat's pad. Arming: nothing is accepted until every source + * is released (the A press that opened the modal must not bind itself). + * Cancel: Esc, tap B, or tap Back (either pad 0 or the edited pad), so a + * controller-only user is never trapped (D395). Clear: Delete, or HOLD Back. + * B itself is bindable by HOLDING it (a tap cancels). */ +static int s_padCapMode = 0, s_padCapRow = -1, s_padCapSeat = 0, s_padCapArmed = 0, s_padCapSwallow = 0; +static uint64_t s_padCapBackT = 0, s_padCapBT = 0; +#define PADCAP_HOLD_US 600000 + static void bindingBegin(struct Row *r) { + if (r && r->found && r->kind == ROW_PADBIND) { + SDL_AtomicLock(&s_bindCaptureLock); + s_bindPending = -1; + SDL_AtomicUnlock(&s_bindCaptureLock); + s_bindError = 0; + s_padCapMode = 1; + s_padCapRow = (int)(r - rows); + s_padCapSeat = s_padSeat; + s_padCapArmed = 0; + s_padCapSwallow = 0; + s_padCapBackT = s_padCapBT = 0; + s_bindCaptureRow = s_padCapRow; + SDL_AtomicSet(&s_bindPadCancelHold, 0); + SDL_AtomicSet(&s_bindHold, SDL_SCANCODE_UNKNOWN); + SDL_AtomicSet(&s_bindCaptureActive, 1); + return; + } + s_padCapMode = 0; if (!r || !r->found || r->kind != ROW_BIND) return; SDL_AtomicLock(&s_bindCaptureLock); s_bindPending = -1; @@ -511,6 +570,14 @@ int optionsBindingKeyDown(const SDL_KeyboardEvent *ev) if ((ev->keysym.sym == SDLK_F4 && (ev->keysym.mod & KMOD_ALT))) return 0; if (ev->repeat) return 1; SDL_Scancode sc = ev->keysym.scancode; + if (s_padCapMode) { /* D469: pad capture takes only Esc (cancel) / Delete (clear) from the keyboard */ + if (sc == SDL_SCANCODE_ESCAPE || sc == SDL_SCANCODE_DELETE) { + SDL_AtomicLock(&s_bindCaptureLock); + if (s_bindPending < 0) s_bindPending = (int)sc; + SDL_AtomicUnlock(&s_bindCaptureLock); + } + return 1; + } if (sc == SDL_SCANCODE_F10 || sc == SDL_SCANCODE_F12 || sc == SDL_SCANCODE_UNKNOWN) return 1; SDL_AtomicLock(&s_bindCaptureLock); @@ -523,6 +590,7 @@ int optionsBindingKeyDown(const SDL_KeyboardEvent *ev) int optionsBindingMouseDown(const SDL_MouseButtonEvent *ev) { if (!optionsBindingCaptureActive()) return 0; + if (s_padCapMode) return 1; /* D469: swallow clicks during a pad capture */ if (ev->button >= 1 && ev->button <= 5) { SDL_AtomicLock(&s_bindCaptureLock); if (s_bindPending < 0) s_bindPending = INPUT_BIND_MOUSE(ev->button); @@ -534,18 +602,102 @@ int optionsBindingMouseDown(const SDL_MouseButtonEvent *ev) /* The capturing screen calls this once per input tick. Return 1 while the * modal is active and through the captured key's release, so it cannot * simultaneously trigger menu navigation or a second action. */ +static void padCapFinish(void) +{ + SDL_AtomicSet(&s_bindCaptureActive, 0); + s_padCapSwallow = 1; /* swallow until every button is released again */ +} + +static int padCaptureTick(void) +{ + int seat = s_padCapSeat; + unsigned raw = inputPadHeldSources(seat); + int back = inputPadButton(seat, SDL_CONTROLLER_BUTTON_BACK) || inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_BACK); + int bHeld = inputPadButton(seat, SDL_CONTROLLER_BUTTON_B) || inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B); + int act = inputPadActionForKey(rows[s_padCapRow].key); + uint64_t now = sysGetMicroseconds(); + + SDL_AtomicLock(&s_bindCaptureLock); + int key = s_bindPending; + s_bindPending = -1; + SDL_AtomicUnlock(&s_bindCaptureLock); + if (key == SDL_SCANCODE_ESCAPE) { padCapFinish(); return 1; } + if (key == SDL_SCANCODE_DELETE) { + inputPadBindingSet(seat, act, rows[s_padCapRow].bindSlot, -1); + configSave(); + padCapFinish(); + return 1; + } + if (!s_padCapArmed) { /* release-before-capture arming */ + if (!raw && !back && !bHeld) s_padCapArmed = 1; + return 1; + } + /* Back: tap = cancel, hold = clear */ + if (back) { + if (!s_padCapBackT) s_padCapBackT = now; + if (now - s_padCapBackT >= PADCAP_HOLD_US) { + inputPadBindingSet(seat, act, rows[s_padCapRow].bindSlot, -1); + configSave(); + sysLogPrintf(LOG_INFO, "pad bind: %s seat %d slot %d cleared", rows[s_padCapRow].key, seat + 1, rows[s_padCapRow].bindSlot + 1); + padCapFinish(); + } + return 1; + } else if (s_padCapBackT) { + padCapFinish(); /* released early: cancel */ + return 1; + } + /* B: tap = cancel, hold = bind B (to the edited pad only) */ + if (bHeld) { + if (!s_padCapBT) s_padCapBT = now; + if (now - s_padCapBT >= PADCAP_HOLD_US && (raw & 2u)) { + inputPadBindingSet(seat, act, rows[s_padCapRow].bindSlot, 1 /* b */); + configSave(); + sysLogPrintf(LOG_INFO, "pad bind: %s seat %d slot %d = b", rows[s_padCapRow].key, seat + 1, rows[s_padCapRow].bindSlot + 1); + padCapFinish(); + } + return 1; + } else if (s_padCapBT) { + padCapFinish(); /* tap: cancel */ + return 1; + } + for (int src = 0; src < inputPadSourceCount(); src++) { + if (raw & (1u << src)) { + inputPadBindingSet(seat, act, rows[s_padCapRow].bindSlot, src); + configSave(); + sysLogPrintf(LOG_INFO, "pad bind: %s seat %d slot %d = %s", rows[s_padCapRow].key, seat + 1, + rows[s_padCapRow].bindSlot + 1, inputPadSourceName(src)); + padCapFinish(); + return 1; + } + } + return 1; +} + int optionsBindingCaptureTick(void) { + if (s_padCapMode) { /* D469 */ + if (optionsBindingCaptureActive()) return padCaptureTick(); + if (s_padCapSwallow) { + unsigned raw = inputPadHeldSources(s_padCapSeat); + if (raw || inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B) || inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_A) || + inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_X) || inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_BACK) || + inputPadButton(s_padCapSeat, SDL_CONTROLLER_BUTTON_BACK)) + return 1; + s_padCapSwallow = 0; + } + s_padCapMode = 0; + return 0; + } /* D395: capture only accepts keyboard/mouse. A controller B must be * able to cancel a modal entered with a keyboard/mouse; swallow B until * release so that the same press cannot also back out of the page. */ if (SDL_AtomicGet(&s_bindPadCancelHold)) { - if (!inputPadButton(0, SDL_CONTROLLER_BUTTON_B)) + if (!inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B)) SDL_AtomicSet(&s_bindPadCancelHold, 0); return 1; } if (optionsBindingCaptureActive()) { - if (inputPadButton(0, SDL_CONTROLLER_BUTTON_B)) { + if (inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B)) { SDL_AtomicLock(&s_bindCaptureLock); s_bindPending = -1; SDL_AtomicUnlock(&s_bindCaptureLock); @@ -868,6 +1020,7 @@ static void overlayInit(void) for (int i = 0; i < NUM_ROWS; i++) { if (rows[i].kind == ROW_RES || rows[i].kind == ROW_ACTION || rows[i].kind == ROW_HEADER || rows[i].kind == ROW_BOND_FILE || + rows[i].kind == ROW_PADSEAT || rows[i].kind == ROW_PADBIND || rows[i].kind == ROW_AUDIODEV || watchSettingsFieldForKey(rows[i].key) >= 0) { rows[i].found = 1; /* not config-backed */ continue; @@ -926,6 +1079,7 @@ static void overlayInit(void) { "__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 }, }; for (size_t k = 0; k < sizeof(pages) / sizeof(pages[0]); k++) { @@ -1232,6 +1386,10 @@ static void rowAdjust(struct Row *r, int dir) } case ROW_ENUM: { double lo = r->cfgMin, hi = r->cfgMax; + if (!strcmp(r->key, "Input.PadPreset")) { /* D469: only Jinx 1.1 and Custom are defined */ + rowSet(r, (double)inputPadPresetStep((int)lround(v), dir)); + break; + } v += dir; if (v < lo) v = hi; if (v > hi) v = lo; @@ -1259,8 +1417,23 @@ static void rowAdjust(struct Row *r, int dir) break; } case ROW_BIND: + case ROW_PADBIND: r->bindSlot = (r->bindSlot + dir + INPUT_BIND_SLOTS) % INPUT_BIND_SLOTS; break; + case ROW_PADSEAT: + s_padSeat = (s_padSeat + (dir >= 0 ? 1 : 3)) % 4; + break; + case ROW_AUDIODEV: { + /* D470: cycle Default + enumerated outputs; applied live on the audio thread. */ + int n = audioDeviceRefresh(); + int idx = 0; /* 0 = Default */ + for (int k = 0; k < n; k++) + if (!strcmp(audioDeviceName(k), audioDeviceCurrentName())) idx = k + 1; + idx = (idx + (dir >= 0 ? 1 : n) ) % (n + 1); + audioDeviceRequest(idx == 0 ? "" : audioDeviceName(idx - 1)); + sysLogPrintf(LOG_INFO, "optionsoverlay: audio device -> \"%s\"", idx == 0 ? "Default" : audioDeviceName(idx - 1)); + break; + } case ROW_ACTION: if (optionsRowChildHeader((int)(r - rows)) >= 0) break; /* link, not a Quit or value */ /* D356: reset rows arm/confirm through the shared activation @@ -1363,6 +1536,7 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Input.PadTriggerPct", 23 }, /* = 23 */ { "Input.RumbleScale", 0.5 }, /* = gRumbleScale (0.5f), D401 */ { "Input.CrouchMode", 0 }, /* = 0 (hold) */ + { "Input.PadPreset", 0 }, /* = 0 (Jinx 1.1), D469 */ /* GRAPHICS (port/src/video.c initializers) */ { "Video.MSAA", 2 }, /* = 2 */ { "Video.TextureFilter", 1 }, /* = 1 (bilinear) */ @@ -1391,6 +1565,8 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Video.VSync", 1 }, /* = 1 (on) */ { "Video.FpsCap", 60 }, /* = 60 */ { "Video.DisplayFPS", 0 }, /* registered 0 */ + /* AUDIO (port/src/audio.c, D470) */ + { "Audio.MasterVolume", 100 }, /* = 100 (bit-identical passthrough) */ }; static double kResetDefault(const char *key) @@ -1430,6 +1606,13 @@ static void rowResetSection(int iReset) r->bindSlot = 0; continue; } + if (r->kind == ROW_AUDIODEV) { audioDeviceRequest(""); continue; } /* D470 */ + if (r->kind == ROW_PADBIND) { /* D469: reset the action on every seat */ + inputPadBindingReset(inputPadActionForKey(r->key)); + r->bindSlot = 0; + continue; + } + if (r->kind == ROW_PADSEAT) continue; int field = watchSettingsFieldForKey(r->key); if (field >= 0) { watchSettingsSet((enum WatchSettingField)field, @@ -1698,27 +1881,27 @@ void optionsOverlayHandleInput(void) * on an edge, not slider adjustment. Only D-pad/left-stick X (or keyboard * left/right) adjust sliders, with hold-repeat. The pad is swallowed by * input.c while the overlay owns it. Start/Select still close it. */ - int gUp = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_UP) - || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTY) < -12000; - int gDn = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_DOWN) - || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTY) > 12000; - int gLf = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_LEFT) - || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTX) < -12000; - int gRt = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) - || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTX) > 12000; + int gUp = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_DPAD_UP) + || inputPadAxis(inputOverlayOwnerPad(), SDL_CONTROLLER_AXIS_LEFTY) < -12000; + int gDn = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_DPAD_DOWN) + || inputPadAxis(inputOverlayOwnerPad(), SDL_CONTROLLER_AXIS_LEFTY) > 12000; + int gLf = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_DPAD_LEFT) + || inputPadAxis(inputOverlayOwnerPad(), SDL_CONTROLLER_AXIS_LEFTX) < -12000; + int gRt = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_DPAD_RIGHT) + || inputPadAxis(inputOverlayOwnerPad(), SDL_CONTROLLER_AXIS_LEFTX) > 12000; int up = ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8] || gUp; int dn = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2] || gDn; int lf = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4] || gLf; int rightNav = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] || gRt; int accept = ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] || - inputPadButton(0, SDL_CONTROLLER_BUTTON_A) || - inputPadButton(0, SDL_CONTROLLER_BUTTON_X); + inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_A) || + inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_X); int rt = rightNav || accept; - int padBack = inputPadButton(0, SDL_CONTROLLER_BUTTON_B); + int padBack = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_B); static int prevStart = 0; /* not reset while closed: a Start held across close must not re-close on the next open */ - int startNow = inputPadButton(0, SDL_CONTROLLER_BUTTON_START); + int startNow = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_START); if (startNow && !prevStart) { prevStart = startNow; optionsOverlayToggle(); /* Start closes */ @@ -1735,6 +1918,20 @@ void optionsOverlayHandleInput(void) } prevPadBack = padBack; + /* D469: Y or Delete clears the selected pad-binding slot (one press, no modal). */ + { + static int prevYClr = 0, prevDelClr = 0; + int yNow = inputPadButton(inputOverlayOwnerPad(), SDL_CONTROLLER_BUTTON_Y), delNow = ks[SDL_SCANCODE_DELETE]; + if (s_section >= 0 && s_sel > 0 && ((yNow && !prevYClr) || (delNow && !prevDelClr))) { + struct Row *cr = &rows[s_visIdx[s_sel]]; + if (cr->kind == ROW_PADBIND) { + inputPadBindingSet(s_padSeat, inputPadActionForKey(cr->key), cr->bindSlot, -1); + configSave(); + } + } + prevYClr = yNow; prevDelClr = delNow; + } + /* ---- keyboard / D-pad nav (clamped at the ends; scroll follows). * D347: hold-to-repeat so a controller (and held keys) can traverse the * list without one-press-per-row. */ @@ -1774,7 +1971,10 @@ void optionsOverlayHandleInput(void) } else { struct Row *r = &rows[s_visIdx[s_sel]]; int keyConfirm = ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]; - if (r->kind == ROW_BIND && dir > 0 && keyConfirm) { + if (r->kind == ROW_PADBIND && dir > 0 && accept && !rightNav) { + bindingBegin(r); /* D469: A/X/Enter starts pad capture; D-pad right changes the slot */ + adjDir = 0; + } else if (r->kind == ROW_BIND && dir > 0 && keyConfirm) { bindingBegin(r); adjDir = 0; } else if (r->kind == ROW_BIND && dir > 0 && accept && !rightNav) { @@ -1816,6 +2016,14 @@ void optionsOverlayHandleInput(void) gfx_get_ui_screen_rect(&rx, &ry, &rw, &rh); if (rw > 0 && rh > 0) { double ox = (double)(mx - rx) * (double)viGetX() / 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(); + } + } double oy = (double)(my - ry) * (double)viGetY() / rh; int hoverVis = overlayRowAtY(ox, oy); /* Opt-in diagnostic for D391: distinguish hit-band/scroll errors @@ -1870,7 +2078,7 @@ void optionsOverlayHandleInput(void) s_dragRow = i; int field = watchSettingsFieldForKey(r->key); SDL_AtomicSet(&s_dragWatchField, field + 1); - } else if (r->kind == ROW_BIND) { + } 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) */ @@ -1969,6 +2177,40 @@ static void valueText(int i, char *out, int n) } return; } + if (r->kind == ROW_AUDIODEV) { + const char *cur = audioDeviceCurrentName(); + if (!cur[0]) snprintf(out, n, "Default"); + else if (strlen(cur) > 24) snprintf(out, n, "%.22s..", cur); + else snprintf(out, n, "%s", cur); + return; + } + if (r->kind == ROW_PADSEAT) { + snprintf(out, n, "Pad %d%s", s_padSeat + 1, inputPadSeatPresent(s_padSeat) ? "" : " (none)"); + return; + } + if (r->kind == ROW_PADBIND) { + if (optionsBindingCaptureActive() && i == s_bindCaptureRow) { + snprintf(out, n, "Press button..."); + } else { + static const char *const kSlotName[INPUT_BIND_SLOTS] = { "Primary", "Secondary" }; + char nm[32]; + inputPadBindingText(s_padSeat, inputPadActionForKey(r->key), r->bindSlot, nm, sizeof(nm)); + static const struct { const char *a, *b; } pretty[] = { + { "leftshoulder", "LB" }, { "rightshoulder", "RB" }, { "lefttrigger", "LT" }, + { "righttrigger", "RT" }, { "leftstick", "L-Click" }, { "rightstick", "R-Click" }, + { "dpup", "D-Up" }, { "dpdown", "D-Down" }, { "dpleft", "D-Left" }, { "dpright", "D-Right" }, + }; + const char *show = nm; + for (size_t k = 0; k < sizeof(pretty) / sizeof(pretty[0]); k++) + if (!strcmp(nm, pretty[k].a)) show = pretty[k].b; + { /* D471: PlayStation / Nintendo names for the pad being edited */ + const char *fam = inputPadSourceFamilyName(s_padSeat, nm); + if (fam) show = fam; + } + snprintf(out, n, "%s: %s", kSlotName[r->bindSlot], show); + } + return; + } if (r->kind == ROW_BIND) { if (optionsBindingCaptureActive() && i == s_bindCaptureRow) snprintf(out, n, "Key/mouse..."); @@ -2221,6 +2463,10 @@ Gfx *optionsOverlayEmit(void) * 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). */ + 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, @@ -2284,7 +2530,9 @@ Gfx *optionsOverlayEmit(void) 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) { @@ -2316,7 +2564,7 @@ Gfx *optionsOverlayEmit(void) } else { u32 valueInk = (strncmp(val, "ON", 2) == 0 || (isArmRow(r) && strcmp(val, "Confirm") == 0)) - ? 0xa0ffa0ff : ink; + ? (expRow ? 0xff8a80ff : 0xa0ffa0ff) : ink; gdl = drawBodyR(gdl, o.valueR, rowY, val, valueInk); } } @@ -2324,19 +2572,25 @@ Gfx *optionsOverlayEmit(void) * 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()) { + 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"; + help2 = "{B}/ESC CANCEL DEL CLEAR"; } else if (s_section < 0) { - help1 = "A/ENTER SELECT"; - help2 = "B/F10 CLOSE"; + 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"; + help2 = "ENTER BIND {B}/ESC BACK"; } else { - help1 = "A SELECT LEFT/RIGHT CHANGE"; - help2 = "B/ESC BACK F10 CLOSE"; + help1 = "{A} SELECT LEFT/RIGHT CHANGE"; + help2 = "{B}/ESC BACK F10 CLOSE"; } s32 helpX = o.left + 16 + metaWidth("CONTROLS:"); s32 helpRight = o.right - 10; @@ -2363,6 +2617,32 @@ Gfx *optionsOverlayEmit(void) } } gdl = drawMeta(gdl, o.left + 11, o.footerY, "CONTROLS:", 0xa6baaaff); + { /* D471: pad button names for the menu pad's family (Xbox = old text) */ + static char h1buf[80], h2buf[80]; + inputPadHelpFmt(h1buf, sizeof(h1buf), help1); + 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. */ + const char *tm[2] = { help1, help2 }; + char *bf[2] = { h1buf, h2buf }; + s32 av[2] = { helpRight - helpX, helpRight - (o.left + 16) }; + for (int L = 0; L < 2; L++) { + if (bodyWidth(bf[L]) <= av[L]) 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; } + 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); + } + } + 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); @@ -2371,6 +2651,13 @@ Gfx *optionsOverlayEmit(void) 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); + } + PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE); gDPPipeSync(gdl++); gSPEndDisplayList(gdl++); @@ -2429,7 +2716,13 @@ int optionsRowIsSlider(int i) int optionsRowIsBind(int i) { struct Row *r = rowAt(i); - return r && r->kind == ROW_BIND; + return r && (r->kind == ROW_BIND || r->kind == ROW_PADBIND); +} + +int optionsRowIsPadBind(int i) +{ + struct Row *r = rowAt(i); + return r && r->kind == ROW_PADBIND; } void optionsRowBeginBind(int i) diff --git a/port/src/video.c b/port/src/video.c index f1459fe2..f61766c5 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -31,6 +31,7 @@ #include "input.h" #include "optionsoverlay.h" #include "frontoptions.h" +#include "audio.h" #include "../fast3d/gfx_api.h" #include "../fast3d/gfx_sdl.h" @@ -1061,6 +1062,10 @@ void videoPumpEvents(void) SDL_Event ev; while (SDL_PollEvent(&ev)) { switch (ev.type) { + case SDL_AUDIODEVICEREMOVED: + /* D470: an open output device vanished; audio.c falls back to default. */ + if (!ev.adevice.iscapture) audioNotifyDeviceRemoved(ev.adevice.which); + break; case SDL_QUIT: videoRequestQuit("quit event"); break; diff --git a/src/boss.c b/src/boss.c index 0f019a32..ad78f099 100644 --- a/src/boss.c +++ b/src/boss.c @@ -832,8 +832,16 @@ void bossMainloop(void) indycommHostSendDump(taskGrabBuffer, (u8*)0x80000000, 0x400000); } +#ifdef PORT + /* D441 (census TOP-10 item 8): the reply message is a stack + * local; routing its address through the s32 rspReplyMsg + * truncates a 64-bit pointer (latent while the main thread's + * stack sits below 2 GiB). Pass the pointer itself. */ + rspGfxTaskStart(firstGdl, gdl, 0, (OSMesg)&localGfxDoneMsg); +#else rspReplyMsg = (s32)(&localGfxDoneMsg); rspGfxTaskStart(firstGdl, gdl, 0, (s32*)rspReplyMsg); +#endif pendingGfx++; memaSingleDefragPass(); diff --git a/src/game/bondview_r.c b/src/game/bondview_r.c index 86c1e33e..e2f9a287 100644 --- a/src/game/bondview_r.c +++ b/src/game/bondview_r.c @@ -424,9 +424,11 @@ void bondviewLoadSetupIntroSection(void) extern char *getenv(const char *name); extern int atoi(const char *s); const char *ic = getenv("GE_INTROCAM"); - if (ic || getenv("GE_D306C")) osSyncPrintf("D306: intro camera index %d of %d%s\n", (int)rand_camera_index, - (int)g_SetupIntroCameraCount, ic ? " (GE_INTROCAM override)" : ""); - if (ic) rand_camera_index = (s32)((u32)atoi(ic) % (u32)g_SetupIntroCameraCount); + if (ic) { + osSyncPrintf("D306: intro camera index %d of %d\n", (int)rand_camera_index, + (int)g_SetupIntroCameraCount); + rand_camera_index = (s32)((u32)atoi(ic) % (u32)g_SetupIntroCameraCount); + } } #endif while (rand_camera_index > 0) diff --git a/src/game/chr.c b/src/game/chr.c index 7d8c22ed..e6b98a26 100644 --- a/src/game/chr.c +++ b/src/game/chr.c @@ -2670,20 +2670,6 @@ s32 chrTick(PropRecord *prop) if (headSwitchVisible) { -#ifdef PORT - /* D466: gameplay timestamp -- authored-distance verdict only. */ - if (gpVisible) - { - if (chr->actiontype == ACT_PATROL) - { - chr->act_patrol.lastvisible60 = g_GlobalTimer; - } - else if (chr->actiontype == ACT_GOPOS) - { - chr->act_gopos.unk9c = g_GlobalTimer; - } - } -#else if (chr->actiontype == ACT_PATROL) { chr->act_patrol.lastvisible60 = g_GlobalTimer; @@ -2692,7 +2678,6 @@ s32 chrTick(PropRecord *prop) { chr->act_gopos.unk9c = g_GlobalTimer; } -#endif } } } diff --git a/src/game/chraction.c b/src/game/chraction.c index 076b58c6..7cbace76 100644 --- a/src/game/chraction.c +++ b/src/game/chraction.c @@ -3931,11 +3931,7 @@ void set_actor_on_path(ChrRecord *self, struct patrol_path *path) pad = chrlvGetNextPatrolStepPad(self); -#ifdef PORT - if (portPropGameplayOnScreen(self->prop) == FALSE) /* D466 */ -#else if ((self->prop->flags & PROPFLAG_ONSCREEN) == FALSE) -#endif { if (chrlvStanRoomRelatedPad(self, pad) != 0) { @@ -9330,11 +9326,7 @@ void chrlvTickGoPos(ChrRecord *self) chrlvActGoposRelated(self, &sp58, &sp54); if ((sp74 == 0) -#ifdef PORT - && (portPropGameplayOnScreen(self_prop) || (chrlvStanRoomRelated(self, &sp58, sp54) == 0))) /* D466 */ -#else && ((self_prop->flags & PROPFLAG_ONSCREEN) || (chrlvStanRoomRelated(self, &sp58, sp54) == 0))) -#endif { chrlvActGoposSetTargetPosRelated(self); self->act_gopos.unk9c = g_GlobalTimer; @@ -9481,11 +9473,7 @@ void chrlvTickPatrol(ChrRecord *self) if (self->act_patrol.waydata.mode == WAYMODE_MAGIC) { if ((sp34 == 0) -#ifdef PORT - && (portPropGameplayOnScreen(self_prop) || (chrlvStanRoomRelatedPad(self, temp_v0) == 0))) /* D466 */ -#else && ((self_prop->flags & PROPFLAG_ONSCREEN) || (chrlvStanRoomRelatedPad(self, temp_v0) == 0))) -#endif { self->act_patrol.lastvisible60 = g_GlobalTimer; chrlvSetNextActPatrolStepPadPos(self); diff --git a/src/game/lv.c b/src/game/lv.c index c98b30af..b605df96 100644 --- a/src/game/lv.c +++ b/src/game/lv.c @@ -1083,9 +1083,7 @@ void lvlManageMpGame(void) #ifdef PORT { extern void d318WatchdogTick(void); - extern void d318TimelineTick(void); d318WatchdogTick(); /* D318/D320 pin detector, log-only since D329 (findings D318) */ - d318TimelineTick(); /* D318T env-gated diagnostic timeline (GE_D318T=1) */ } #endif if ((g_CurrentStageToLoad != LEVELID_TITLE) && (D_80048394 == 0) && (g_ClockTimer > 0)) diff --git a/src/game/options.c b/src/game/options.c index c4781308..3b2673d4 100644 --- a/src/game/options.c +++ b/src/game/options.c @@ -3572,7 +3572,13 @@ Gfx *draw_watch_controller(Gfx *gdl) } else { +#ifdef PORT + /* D441 (missed sibling of D290): the (s32) cast truncates a 64-bit + * stack pointer; latent while Windows thread stacks sit below 2 GiB. */ + gdl = watchRenderControllerOpaque(gdl, &finalmtx, 1, &table2, &contpadnum1); +#else gdl = watchRenderControllerOpaque(gdl, &finalmtx, 1, (s32) (&table2), &contpadnum1); +#endif } }