diff --git a/AGENTS.md b/AGENTS.md index 680ee103..5494ac6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,53 @@ Needs CMake + SDL2 + zlib + OpenGL, and must run from the MSYS2 MINGW64 shell (see `build-pc.sh` header and `docs/building.md`). ROM goes in `./data/` (not distributed); assets must be extracted from it first (`docs/building.md`). +**Windows build environment (three recurring failure modes — diagnose in this order):** + +1. **`Cannot create temporary file in C:\Windows\: Permission denied`** at the + link step. The PE toolchain (ninja → cmd → gcc/ld) needs a writable + TMP/TEMP; the msys→native env conversion drops it in non-login shells + (agent harnesses; the `C:/msys64` tree here was built for `D:/M/msys64`, + so its conversion is unreliable). `build-pc.sh` now **self-heals**: it + probes a native child's TMP (via a file — piped `cmd.exe` stdout is + unreliable under msys console emulation) and, if broken, re-runs + cmake+build under PowerShell with `TMP`/`TEMP` set natively (the + native→native boundary passes env through intact; only msys→native is + broken). If you run cmake/ninja *by hand* from a broken shell: run them + from cmd/PowerShell with `C:\msys64\mingw64\bin` on PATH. + **Guard revision (2026-09-28):** the re-exec writes a self-contained + `.ps1` to a temp file and runs `powershell -File` with plain path/word + args only (the msys→native argv conversion mangles `$`-bearing + `-Command` strings; a file write is conversion-free). Inside: PATH is + built explicitly (never trust the inherited value); TMP is chosen from + writable candidates (`%LocalAppData%\Temp` first — + `[System.IO.Path]::GetTempPath()` honours the inherited *broken* TMP, + observed as `C:\Windows\`); every native step self-logs to + `build-pc/ge007-native-reexec-{diag,cmake,build}.log`; a null + `$LASTEXITCODE` ("did not execute") is a distinct failure (exit 2/3) — + in PS 5.1 a piped native command errors out non-terminating and never + sets it, so an unguarded `if ($LASTEXITCODE -ne 0)` passes vacuously. + In `.ps1`, `-DROMID=$Var` is a literal (bare tokens don't expand) — it + must be `"-DROMID=$Var"`. Launch powershell plainly — `env -i` before it + breaks the nested cmake launch (verified 2026-09-28). + **OPEN (next session):** the guard still fails *inside* `build-pc.sh` + (cmake "did not execute": the `.ps1` runs, but `$LASTEXITCODE` stays + empty) while the *identical* standalone invocation succeeds (cmake runs + and the build completes — that is how the D401 builds were made). First + test: diff the heredoc-generated `.ps1` against a hand-extracted copy + (suspect: heredoc line-ending/whitespace corruption). + **Working workaround:** extract the heredoc `.ps1` and run + `powershell -NoProfile -ExecutionPolicy Bypass -File "C:\msys64\usr\bin" "C:\msys64\mingw64\bin" C:/msys64 build-pc `. +2. **`cannot open output file ge007.x86_64.exe: Permission denied`.** A + **running** `ge007.x86_64.exe` locks the output file (Windows rule; you + can't relink over a live PE). Check with `Get-Process | Where-Object { + $_.ProcessName -like '*ge007*' }` and close the game before rebuilding. + An agent must not kill the user's game process to make a link succeed. +3. **`cc1.exe: ... libmpfr-6.dll: cannot open shared object file`.** The + calling PATH lacks `C:\msys64\mingw64\bin` (the gcc driver finds cc1 via + its own directory, but the child needs the mingw DLL dir on PATH). In an + msys shell: `export PATH="/c/msys64/mingw64/bin:/c/msys64/usr/bin:$PATH"` + before building. + ## Verification ritual (after any build-affecting change) 1. **Undefined symbols.** Every symbol referenced by the compiled set (see `CMakeLists.txt`: `SRC_GAME`, `SRC_ENGINE`, `SRC_LIBAUDIO`, `SRC_LIBULTRARE_AUDIO`, `SRC_LIBULTRARE_DATA`, `SRC_GU`, `SRC_PORT*`) must be defined exactly once in the compiled set or in `port/`. Symbols that live in EXCLUDED files (`libultra/io/*`, `libultrare/io/*` except `vitbl.c`, `libultra/os/*`, `libultrare/os/*`, `sched.c`, `rmon.c`, `vi.c`, `src/*.s`) must be provided by `port/src/libultra.c`, `gesched.c`, `n64stubs.c`, `random.c`, or `ucode.c`. diff --git a/build-pc.sh b/build-pc.sh index 34a6388c..6e0cc3a8 100644 --- a/build-pc.sh +++ b/build-pc.sh @@ -14,7 +14,7 @@ # Example (Linux): # sudo apt install cmake libsdl2-dev zlib1g-dev libgl1-mesa-dev # Example (macOS): -# brew install cmake sdl2 zlib +# brew install cmake libsdl2-dev # Example (Windows/MSYS2): # pacman -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-SDL2 \ # mingw-w64-x86_64-zlib mingw-w64-x86_64-cmake @@ -24,6 +24,132 @@ set -euo pipefail ROMID="${1:-ntsc-final}" BUILD_DIR="${BUILD_DIR:-build-pc}" +# --------------------------------------------------------------------------- +# Windows toolchain env guard (see AGENTS.md "Build" — the recurring +# "Cannot create temporary file in C:\Windows\: Permission denied" link +# failure). +# +# The PE toolchain (ninja -> cmd -> gcc/ld) creates temp files under the +# child's TMP/TEMP. A full MSYS2 MINGW64 login shell arranges that for +# native children; other shells (agent harnesses, non-login shells) do not +# -- the msys->native env conversion silently drops/breaks TMP and the +# linker falls back to C:\Windows\. Probe what a native child actually +# sees (via a file: msys console emulation makes piped cmd.exe stdout +# unreliable), and if broken, re-run the cmake+build steps under +# PowerShell. Crucially the temp vars are set INSIDE PowerShell: the broken +# boundary is msys->native, while native->native (powershell -> cmake -> +# ninja) passes env vars through intact. +# GE_PC_BUILD_VIA_NATIVE=1 prevents a re-exec loop. +# --------------------------------------------------------------------------- +if [ -n "${MSYSTEM:-}" ] && [ -z "${GE_PC_BUILD_VIA_NATIVE:-}" ]; then + _probe="$(mktemp -d "${TMPDIR:-/tmp}/gebuildprobe.XXXXXX")" + _probe_win="$(cygpath -w "${_probe}" 2>/dev/null || echo 'C:\\msys64\\tmp')" + /c/Windows/system32/cmd.exe //c "if exist \"%TMP%\" (echo ok > \"${_probe_win}/ok.txt\") else (echo bad > \"${_probe_win}/bad.txt\")" >/dev/null 2>&1 || true + if [ ! -s "${_probe}/ok.txt" ]; then + command -v powershell >/dev/null 2>&1 || { + echo "ERROR: native TMP is unusable from this shell and powershell.exe was not found for the fallback." >&2 + echo "Manual fix: run cmake+ninja from cmd/PowerShell with C:\\msys64\\mingw64\\bin on PATH, or" >&2 + echo "set the user env vars (setx TMP \"C:\\msys64\\tmp\"; setx TEMP \"C:\\msys64\\tmp\") and re-open the shell." >&2 + rm -rf "${_probe}"; exit 1 + } + echo "==> native TMP unusable from this shell; re-running cmake+build via PowerShell" + export GE_PC_BUILD_VIA_NATIVE=1 + _here="$(pwd -W 2>/dev/null || cygpath -w "$(pwd)")" + # Resolve the Windows-side toolchain paths from what msys found (no + # hardcoded install dir: C:\msys64 on this box, D:\M\msys64 + # elsewhere). usr/bin first: mingw gcc.exe needs msys-2.0.dll from + # there. + _cmake_root="$(dirname "$(command -v cmake)")" # .../mingw64/bin + _msys_root="$(cd "${_cmake_root}/../.." && pwd -W 2>/dev/null || cygpath -w "$(cd "${_cmake_root}/../.." && pwd)")" # msys install root (.. twice: bin -> mingw64 -> root) + _bin_w="$(cygpath -w "${_cmake_root}")" + _usrbin_w="$(cygpath -w "${_msys_root}/usr/bin")" + # The PowerShell script is written to a .ps1 FILE, not passed via + # -Command: the msys->native argv conversion mangles $-bearing + # strings (a '\$LASTEXITCODE' check arrived as an empty token and a + # failed cmake would have exited 0 silently). A file write is + # conversion-free; only plain path/word arguments cross the boundary. + _ps1="$(mktemp "${TMPDIR:-/tmp}/ge007-rerun.XXXXXX.ps1")" + cat > "${_ps1}" <<'PS1' +param([string]$UsrBin, [string]$Bin, [string]$MsysRoot, [string]$Here, [string]$BuildDir, [string]$RomId) +# Self-logging: a headless shell loses this process's console output, so +# every fact that matters goes to diag/log files in the build dir. +$diag = Join-Path $BuildDir "ge007-native-reexec-diag.log" +$cmakeLog = Join-Path $BuildDir "ge007-native-reexec-cmake.log" +$buildLog = Join-Path $BuildDir "ge007-native-reexec-build.log" +# The inherited native PATH may be mangled/dropped by the broken msys->native +# env conversion, so build it explicitly: the toolchain only needs the two +# msys bin dirs plus the usual system dirs. (usr/bin first: mingw gcc/cmake +# need msys-2.0.dll from there.) +$env:PATH = "$UsrBin;$Bin;C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem" +# Writable temp for the native toolchain. [System.IO.Path]::GetTempPath() +# CANNOT be used here: it honours the inherited (broken) TMP/TEMP env vars +# and returned C:\Windows\. Pick the first writable candidate instead. +$tmpdir = $null +foreach ($cand in @((Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Temp'), (Join-Path $MsysRoot 'tmp'))) { + try { + if (-not (Test-Path $cand)) { New-Item -ItemType Directory -Force -Path $cand -ErrorAction Stop | Out-Null } + $probe = Join-Path $cand 'ge007-tmp-write-test.tmp' + 'x' | Out-File -FilePath $probe -ErrorAction Stop + Remove-Item $probe -ErrorAction SilentlyContinue + $tmpdir = $cand + break + } catch { } +} +if ($null -eq $tmpdir) { "no writable temp dir found" | Out-File $diag -Encoding ascii; Write-Host 'no writable temp dir for the native toolchain'; exit 4 } +$env:TMP = $tmpdir +$env:TEMP = $tmpdir +"start UsrBin=[$UsrBin] Bin=[$Bin] MsysRoot=[$MsysRoot] Here=[$Here] BuildDir=[$BuildDir] RomId=[$RomId] tmp=[$tmpdir]" | Out-File $diag -Encoding ascii +$exe = "$Bin\cmake.exe" +"exe=[$exe] exists=[$(Test-Path -LiteralPath $exe)]" | Out-File $diag -Append -Encoding ascii +Set-Location $Here +"cwd=[$([System.IO.Directory]::GetCurrentDirectory())]" | Out-File $diag -Append -Encoding ascii +try { + # -DROMID must be a DOUBLE-QUOTED string: a bare `-DROMID=$RomId` token + # does NOT expand the variable in PowerShell (cmake got the literal + # "$RomId" and failed with "Unknown ROMID"). + $out = & $exe -S . -B $BuildDir "-DROMID=$RomId" 2>&1 + "cmake ran LASTEXITCODE=[$LASTEXITCODE]" | Out-File $diag -Append -Encoding ascii + $out | Out-File -FilePath $cmakeLog -Encoding ascii +} catch { + "cmake TERMINAL ERROR: $($_.Exception.Message)" | Out-File $diag -Append -Encoding ascii +} +if ($null -eq $LASTEXITCODE) { Write-Host "cmake.exe did not execute (diag: $diag)"; exit 2 } +if ($LASTEXITCODE -ne 0) { Write-Host "cmake re-configure failed (rc=$LASTEXITCODE; log: $cmakeLog)"; exit $LASTEXITCODE } +Write-Host "cmake reconfigured (TMP=$($env:TMP))" +try { + $out = & $exe --build $BuildDir -j 2>&1 + "build ran LASTEXITCODE=[$LASTEXITCODE]" | Out-File $diag -Append -Encoding ascii + $out | Out-File -FilePath $buildLog -Encoding ascii +} catch { + "build TERMINAL ERROR: $($_.Exception.Message)" | Out-File $diag -Append -Encoding ascii +} +if ($null -eq $LASTEXITCODE) { Write-Host "cmake --build did not execute (diag: $diag)"; exit 3 } +if ($LASTEXITCODE -ne 0) { Write-Host "build failed (rc=$LASTEXITCODE; log: $buildLog)"; exit $LASTEXITCODE } +"done" | Out-File $diag -Append -Encoding ascii +Write-Host "cmake + build ok (native re-exec, TMP=$($env:TMP))" +PS1 + _ps1_win="$(cygpath -w "${_ps1}")" + rc=0 + # NOTE: do NOT launch powershell via `env -i` (tried 2026-09-28): a + # native child started with a scrubbed msys env cannot launch + # cmake.exe (CreateProcess fails silently; LASTEXITCODE stays null). + powershell -NoProfile -ExecutionPolicy Bypass -File "${_ps1_win}" "${_usrbin_w}" "${_bin_w}" "${_msys_root}" "${_here}" "${BUILD_DIR}" "${ROMID}" || rc=$? + rm -f "${_ps1}" + if [ "${rc}" -ne 0 ]; then + # Surface the .ps1's own diagnostics (console output is lost in a + # headless shell) plus whatever the failing step wrote. + for _lg in "${BUILD_DIR}/ge007-native-reexec-diag.log" "${BUILD_DIR}/ge007-native-reexec-cmake.log" "${BUILD_DIR}/ge007-native-reexec-build.log"; do + if [ -s "${_lg}" ]; then + echo "---- ${_lg} (last 40 lines) ----" >&2 + tail -40 "${_lg}" >&2 + fi + done + fi + exit "${rc}" + fi + rm -rf "${_probe}" +fi + echo "==> Configuring PC port (ROMID=${ROMID})" cmake -S . -B "${BUILD_DIR}" -DROMID="${ROMID}" diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md index cf528d02..590ae158 100644 --- a/docs/dev/QOL-INVENTORY.md +++ b/docs/dev/QOL-INVENTORY.md @@ -33,7 +33,7 @@ ScreenShakeIntensity. Debug: FrameDump, InputLog. | **DisplayFPS** _(DONE M-83, D213)_ | config `Video.DisplayFPS` | P | S | Top-right readout, config-only; ~0.5 s sample window. | | **Anisotropic filtering** _(DONE M-83, D212)_ | `Video.Anisotropy` (1–16, dflt 4) | P | S | fast3d already had the GL hook + a hardcoded 4×; M-83 exposed it as config. MipmapFilter / TextureFilter2D still not surfaced. | | Per-pad tuning: per-stick deadzones, stick scale, rumble scale, device index, swap sticks, C-button mapping | `input.c` padsCfg block | P | M | Medium. Matters for real-controller users; PD pattern is a config section per pad. **M-87 survey addendum:** PD does *incremental* hot-plug (`SDL_CONTROLLERDEVICEADDED/REMOVED` opens/closes individual pads into the first free slot, remembers `deviceIndex=-1` on removal so a replugged pad returns to its configured seat, `input.c:473-491`); GE's port instead does a full rescan-close-and-reopen-everything (`inputRescanPads()`, `input.c:983-1000`) and the game itself latches the controller mask at boot, so a pad added later merges into controller 0 for play. PD's incremental-with-remembered-seat approach is the natural implementation shape for this row and sidesteps the boot-latch merge — fold into the design when this row gets built, not a separate item. | -| **Rumble → D224 (M-87). DEPRIORITIZED (M-87, user call) — gamepad-only, this release targets mouse/keyboard as primary.** GE's port fully no-ops the N64 Rumble Pak (`port/src/libultra.c:1220-1223` `osMotorInit/Start/Stop` all stubbed "no accessories on the PC") — GE genuinely has a rumble subsystem (`src/motor.c`, `src/joy.c`) that never reaches any output. **PD's PC port wires this straight through to real gamepad rumble**: `inputRumbleSupported()`/`inputRumble()` call `SDL_GameControllerRumble()` when the connected pad reports haptics, `Input.PadN.RumbleScale` config + an options-menu slider, `osMemSize`-style "pretend the accessory is there" pattern applied to a real one instead of faked. | P | M | Concrete PD-port precedent, straightforward port-layer wiring, ready whenever gamepad support is prioritized (no `src/` game-logic touch — GE's `motor.c` calls already exist, just need `osMotorStart/Stop` in `libultra.c` to route to a real `SDL_GameControllerRumble` call the way PD's do). | +| **Rumble** _(DONE D401 — D224 / M-87, re-promoted 2026-09-27)_ | `port/src/input.c` + `port/src/libultra.c` + `port/src/optionsoverlay.c` | P | M | D401 implements the PD precedent per `docs/dev/RUMBLE-PLAN.md`: `inputRumbleSupported()`/`inputRumble()` → `SDL_GameControllerRumble()` when the pad reports haptics (SDL ≥ 2.0.18 `HasRumble`, else `SDL_JoystickIsHaptic` + known-type Windows fallback), per-pad haptics detection (`SDL_GameControllerHasRumble` / `SDL_JoystickIsHaptic` + known-type Windows fallback), a single global `Input.RumbleScale` (0..1, dflt 0.5) config + one `Rumble strength` slider row; `libultra.c` sets `CONT_CARD_ON` in `g_contStatus` for rumble-capable pads and routes `osPfsInit` → `PFS_ERR_DEVICE`, `osMotorInit/Start/Stop` → the haptics API — the two M-87 addendum blockers that kept `joyRumblePakInit` from arming. Zero `src/` edits. **Real-pad haptics confirmed working by the user (2026-09-28).** Plan item 3 landed the same day: the original four per-player rows were replaced (user decision) with one global `Rumble strength` slider in the F10 overlay + front options screen (`optionsoverlay.c`, shared `rows[]`), 0.05 detents, `kResetDefaults` 0.5; the four `Input.PlayerN.RumbleScale` ini keys were dropped (stale keys self-clean on the next save). A disabled-when-unsupported row state is deferred with the per-pad tuning row (needs a generic row-disabled mechanism). Unverified: `RumbleScale=0` silence check (trivial early-out). | | FakeGamepads / FirstGamepadNum / UseHIDAPI | `input.c` | P | S–M | Niche (local MP on PC); defer until MP is actually played. | | Window polish: DefaultFullscreen/Maximize, CenterWindow, AllowHiDpi, ExclusiveFullscreen | `video.c` vid* block | P | S | HiDpi + center are the useful ones on modern Windows. | | VSync adaptive (−1..10) | `Video.VSync` range | P | S | Note for FPS plan — GE's 0/1 toggle is fine until then. | diff --git a/docs/dev/RUMBLE-PLAN.md b/docs/dev/RUMBLE-PLAN.md new file mode 100644 index 00000000..a7375309 --- /dev/null +++ b/docs/dev/RUMBLE-PLAN.md @@ -0,0 +1,264 @@ +# Rumble Pak → real gamepad haptics (port plan) + +Status: **IMPLEMENTED — finding D401 (2026-09-27/28).** Items 1+2+3 all +landed (port layer only; zero `src/` edits), re-promoting the +DEPRIORITIZED finding **D224** (M-87) to DONE. **Real-pad haptics confirmed +working by the user (2026-09-28).** Menu shape: **one global `Rumble +strength` slider** (`Input.RumbleScale`, user decision 2026-09-28 — the +four per-player rows were dropped). Only the `RumbleScale=0` silence check +remains unverified (trivial early-out path). + +Scope: wire the N64 Rumble Pak path (already present in `src/`) through to +`SDL_GameControllerRumble()` on the PC. **Port-layer only — zero `src/game` or +`src/` logic edits**, so rule 2 / ABI-exception do not apply. + +Reference: the PD PC port (local checkout `../pd_port`, `port/src/input.c` ++ `port/src/libultra.c`) implements the identical feature for the same Rare +engine family. Copy its shape; the only friction is that **GE's `src/joy.c` +calls the classic libultra names** (`osMotorInit/Start/Stop`, `osPfsInit/ +osPfsIsPlug`) while PD's engine renamed them (`osMotorProbe`, +`__osMotorAccess`, `osPfsInitPak`). We translate names, not logic. + +--- + +## Ground truth (current state) + +Game side — **already correct, untouched:** +- `src/joy.c` full Rumble-Pak state machine: `joyRumblePakInit` (:178), + `joyRumblePakTick` (:298), `joyRumblePakStart`/`Stop`. Call sites (verified + 2026-09-27 for D401) live in `gunfire.c:3284` (start) / `:3288` (stop), + `bondview2.c:9722` (start) / `:9726` (stop, incl. 2P second pad), + `lv.c:1688` (stop at level start), plus `sched.c:272` (stop on level + teardown). +- `src/motor.c` is **EXCLUDED** from the PC build (N64 SI-DMA driver), replaced + by no-op stubs. CMakeLists.txt:525 documents this. + +Port side — **all no-oped today, in `port/src/libultra.c`:** +- `osPfsInit` (:1413) → `return PFS_ERR_NOPACK;` +- `osPfsIsPlug` (:1415) → `*pattern = 0; return 0;` +- `osMotorInit` (:1417) → `return -1;` +- `osMotorStart` (:1419) → `return -1;` +- `osMotorStop` (:1420) → `return -1;` +- `g_contStatus[i].status = 0;` (:1102) — `CONT_CARD_ON` **never set**. + +Gamepad side — **exists, no change needed:** +- `port/src/input.c` owns `static SDL_GameController *pads[MAX_PADS]` + (MAX_PADS = 4, aligned with `MAXCONTROLLERS`), open/close, hotplug, + `inputComputePad()`. This is the exact object `SDL_GameControllerRumble()` + needs. + +## The two upstream blockers (D224 M-87 addendum — MUST land together) + +`joyRumblePakInit` (src/joy.c:178) only drives the motor if BOTH pass: +1. `g_ContStatus[index].status & CONT_CARD_ON` → today always 0, so init + never even runs. +2. `osPfsInit(...)` returns `PFS_ERR_ID_FATAL` **or** `PFS_ERR_DEVICE` → + today returns `PFS_ERR_NOPACK`, so `osMotorInit` is never attempted. + +If the SDL wiring is done without (1)+(2), the state machine parks at +`RUMBLEPAKINITSTATE_NOT_READY` forever and nothing rumbles despite "looking +wired." Both fixes are in the same file we already touch, so no extra cost. + +--- + +## Work items + +### 1. `port/src/input.c` — detection + API + +Add per-pad fields (GE uses flat statics, not PD's `padsCfg[]` array): +```c +static int padRumbleOn[MAX_PADS]; // set where pads[i] is opened (line ~582) +static float padRumbleScale[MAX_PADS] = { 0.5f, 0.5f, 0.5f, 0.5f }; +``` +On pad open (in the `SDL_GameControllerOpen` path, line ~575-589), mirror PD: +```c +#if SDL_VERSION_ATLEAST(2, 0, 18) + padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]); +#else + padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i])); + if (!padRumbleOn[i]) { + const SDL_GameControllerType t = SDL_GameControllerGetType(pads[i]); + padRumbleOn[i] = t && (t != SDL_CONTROLLER_TYPE_VIRTUAL); // Win no-haptic fallback + } +#endif +``` +On pad close, `padRumbleOn[i] = 0;`. GE has no `inputCloseController` +(PD-only) — the close sites are `inputDestroy()` and the close loop in +`inputRescanPads()`, both of which zero it (`inputOpenPads` re-detects on +open anyway). + +New public fns (declare in `port/include/input.h`, define in input.c): +```c +int inputRumbleSupported(int idx); // 0<=idx= MAX_PADS || !pads[idx]) return; +if (padRumbleScale[idx] <= 0.f) return; +if (padRumbleOn[idx]) { + strength *= padRumbleScale[idx]; + if (strength <= 0.f) { strength = 0; time = 0; } + else { strength *= 65535.f; time *= 1000.f; } + SDL_GameControllerRumble(pads[idx], (Uint16)strength, (Uint16)strength, (Uint32)time); +} +``` +In init, add the two PS4/5 hints (PD input.c:696-697) so BT pads report +haptics: +```c +SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1"); +SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1"); +``` +Register per-pad config (in the existing `configRegister*` block, ~line 2457+): +```c +char key[48]; +for (int c = 0; c < MAX_PADS; ++c) { + sprintf(key, "Input.Player%d.RumbleScale", c + 1); + configRegisterFloat(key, &padRumbleScale[c], 0.f, 1.f); +} +``` +Note: **`strFmt` is NOT available in `input.c`** (0 uses, no decl in +`port/include`) — use a local `sprintf` buffer as above. `configRegisterFloat` +is declared in `port/include/config.h` (already included by input.c:80); it's +used elsewhere (video.c) so the symbol is already in the link set. + +### 2. `port/src/libultra.c` — rewire the shims + +`contSnapshotFromKeyboard` (:1096): set the status bit for connected, +rumble-capable pads (need a per-channel capability check → call +`inputRumbleSupported(i)`; it's declared in input.h so include it): +```c +g_contStatus[i].type = connected ? CONT_TYPE_NORMAL : 0; +g_contStatus[i].status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0; +``` +Replace the 5 accessory stubs (:1413-1420) with real routing (PD libultra.c:235-351): +```c +s32 osPfsInit(OSMesgQueue *q, OSPfs *pfs, int channel) { + (void)q; + // "pretend the Rumble Pak is present" when the real pad can rumble: + // returning PFS_ERR_DEVICE is what joyRumblePakInit (src/joy.c:186-190) + // needs to proceed to osMotorInit. + return inputRumbleSupported(channel) ? PFS_ERR_DEVICE : PFS_ERR_NOPACK; +} +s32 osPfsIsPlug(OSMesgQueue *q, u8 *pattern) { + (void)q; + if (pattern) { + *pattern = 0; + for (int i = 0; i < MAXCONTROLLERS; ++i) + if (inputRumbleSupported(i)) *pattern |= (u8)(1 << i); + } + return 0; +} +s32 osMotorInit(OSMesgQueue *mq, OSPfs *pfs, int channel) { + if (pfs && inputRumbleSupported(channel)) { + pfs->queue = mq; + pfs->channel = channel; + pfs->activebank = 0xff; // PD's osMotorProbe stores this too + // NOTE: PFS_MOTOR_INITIALIZED is NOT defined in GE's headers, and + // joy.c never reads pfs->status — it only checks (osMotorInit()==0) + // to set RUMBLEPAKINITSTATE_READY (src/joy.c:190). Skip the status store. + return 0; + } + (void)mq; (void)channel; + return PFS_ERR_NOPACK; +} +s32 osMotorStart(OSPfs *pfs) { + if (!pfs) return PFS_ERR_NOPACK; + // N64 motor has no duration; the game's joyRumblePakTimer60 (joy.c) turns it + // off. Issue one generous window, PD-style ("hope the timer stops it"): + inputRumble(pfs->channel, 1.0f, 5.0f); + return 0; +} +s32 osMotorStop(OSPfs *pfs) { + if (!pfs) return PFS_ERR_NOPACK; + inputRumble(pfs->channel, 0.0f, 0.0f); // zero-duration / zero-strength = stop + return 0; +} +``` +**JPN note (2026-09-27):** `_HW_VERSION_1` defines `MAXCONTROLLERS` as 6 +while `MAX_PADS` is 4 — the `MAXCONTROLLERS`-bounded loops above are safe +because `inputRumbleSupported()` bounds-checks against `MAX_PADS` and reports +unsupported (silent) for `i >= 4`. `osPfsIsPlug` has **no GE game-side call +sites** — its rewiring is completeness/hygiene only; the load-bearing stubs +are `osPfsInit`/`osMotor*`. + +Headers confirmed: `OSPfs` (`include/PR/os.h:282`) has `queue` + `channel`; the +error macros `PFS_ERR_DEVICE` (:642) and `PFS_ERR_ID_FATAL` (:641) and +`PFS_ERR_NOPACK` (:631) are all defined there. **`PFS_MOTOR_INITIALIZED` is NOT +defined** anywhere in GE's headers and `joy.c` never reads `pfs->status`, so it +is not needed. `input.h` is already included by libultra.c (line 50), so +`inputRumbleSupported` is directly callable there. + +### 3. (DONE 2026-09-28) options-menu slider — single global + +Landed in `port/src/optionsoverlay.c` (not PD's `optionsmenu.c` — GE's F10 +overlay + front options screen share `rows[]`, walked by `frontoptions.c` +via the `optionsRow*` accessors): ONE `ROW_SLIDER` row `Rumble strength` +on `Input.RumbleScale` (0.05 detents, generic `CONFIG_OPT_FLOAT` display +`%.2f`), in the INPUT section after the controller rows, plus a +`kResetDefaults` entry (0.5, the `gRumbleScale` C initializer) so INPUT's +"Reset to defaults" covers it. + +**Decision (user, 2026-09-28):** the original four-per-player-row design +(`Rumble (Player 1..4)` on `Input.Player%d.RumbleScale`) was coded and +reviewed, then replaced with the single global row **before its first +compile**: the scale applies to every rumble-capable pad, and the four +`Input.PlayerN.RumbleScale` ini keys are dropped — stale keys in an +existing ini log four harmless `unknown key` `LOG_NOTE` lines at config +load and self-clean on the next `configSave` (which rewrites the whole +file with the registered keys). Per-player rumble tuning stays a G-class +stretch goal (QOL-INVENTORY) — and a per-pad override-on-top-of-global +design would be a no-op for everyone anyway, because `configSave` +rewrites all registered keys and the "user explicitly set this?" signal +is burned as soon as any key exists at the 0.5 default. The +row-disabled-when-unsupported state is deferred with the per-pad tuning +row (the overlay has no generic row-disabled mechanism); the row is inert +(no-op) on non-rumble setups. + +**Original plan (for reference):** PD wires the per-pad scale into the +options menu (`optionsmenu.c:533-540`): `inputRumbleGetScale(player)`, +`inputRumbleSetScale(player, v/10)`, disabled when +`!inputRumbleSupported(player)`. In GE this maps onto +`port/src/optionsoverlay.c` / the per-pad tuning row (QOL-INVENTORY.md). +Config file value alone is enough for the feature; the UI is the polish. + +--- + +## Verification ritual (AGENTS.md) + +- [x] `./build-pc.sh ntsc-final` compiles clean (2026-09-27). New + `inputRumble*` defined once in input.c, declared in input.h; no new + undefined/duplicate symbols (full `/linkcheck` sweep pending at D401 + close). +- [x] `CONT_CARD_ON` + `PFS_ERR_DEVICE` land (the two-blocker addendum); + the state machine's READY path is provable by code read — the runtime + proof is folded into the real-pad test below. +- [x] **By-hand, real pad:** user confirmed the rumble works on a real + controller (2026-09-28) — fire a weapon (`gunfire.c:3284` recoil), + explosion rumble, 2P second pad (`bondview2.c:9722/9726`). Non-rumble + pad and mouse/keyboard-only stay silent by construction (`pads[idx]` + NULL / `padRumbleOn` 0 ⇒ `inputRumble` no-op). +- [ ] `Input.RumbleScale=0` fully silences (unverified — the trivial + `gRumbleScale <= 0` early-out in `inputRumble`); max value loud + (confirmed implicitly by the working playtest at scale 0.5). + +## Finding log + +Done (2026-09-27): **D401** added to `docs/dev/findings.md` (§F index + +full entry) documenting the D224 implementation + the two-blocker +resolution; the `QOL-INVENTORY.md` M-87/D224 row re-promoted +DEPRIORITIZED → DONE (D401); `docs/dev/findings-index.csv` regenerated. + +--- + +## Effort + +- Items 1+2: **~1–2 h** coding (mostly a rename + copy from PD) + build/linkcheck. +- Hands-on pad testing: **~30 min–1 h** (the only genuinely open-ended part — + feels-right tuning). +- Item 3 (UI slider): **~1 h**, landed 2026-09-28 as the single global + `Rumble strength` row. + +**Total for working core: half a day max, <2 h of pure code.** diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 1700f1de..c37278b3 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -31,7 +31,7 @@ D192,CLOSED,Front-end mission/level-select grid pointer still can't reach the ou D221,FIXED,SELECT FILE mouse hit-box offset —,"FIXED (M-91) — explicit {min,max} coord2d pairs under #ifdef PORT; human mouse-playtest confirmed." D222,FIXED,High Video.FovScale breaks culling —,"FIXED, awaiting visual verification (M-121)." D223,FIXED,QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter —,"FIXED (M-105, port/src/input.c) —…" -D224,?,"QoL gap (M-87, from a PD-PC-port survey):","DEPRIORITIZED (M-87, user call)." +D224,PARTIAL,"QoL gap (M-87, from a PD-PC-port survey):","LANDED via D401 (2026-09-27, re-promoted from DEPRIORITIZED)." D225,FIXED,"Dev-tooling QoL (M-87, user ask):","FIXED (2026-09-18) — GE_DEBUG_UNLOCKALL=1 short-circuits fileGetIsCheatUnlocked() (src/game/file2.c, #ifdef PORT, cached getenv) to always return TRUE; default off, no save mutation." D226,FIXED,"QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.","FIXED (2026-09-24): Game.HudScale 75–150% (user-verified, capped at 150) via fast3d G_HUDSCALE_EXT rect scaling about per-element anchors; dialogue/bottom messages width-capped." D227,FIXED,Sky renders as two independently-moving halves —,"FIXED (M-100b, PR #43) —" @@ -277,3 +277,4 @@ D397,RESOLVED,SELECT FILE background + intro gun-barrel show an interlaced/comb D398,FIXED,PC Options label: D348's procedural slider glyph replaced with an existing mainfolderimages texture (2026-09-27).,"SUPERSEDED by D400 (icon removed, text-only label; purple-hover bug moot; the D400 textMeasure arg-swap bug was found and fixed there)." D399,PARTIAL,File-select bottom row (SELECT FILE text + Copy/Erase + PC Options dot/label) shifted 15px left as a block (user sign-off 2026-09-27).,"LANDED (port-only, D343 class):" D400,PARTIAL,"File-select ""PC Options"" label:","LANDED (port-only, frontoptions.c):" +D401,PARTIAL,Rumble Pak → real gamepad haptics (re-promotes D224/M-87):,"LANDED (port-only, input.c/input.h/libultra.c + optionsoverlay.c per docs/dev/RUMBLE-PLAN.md; zero src/ edits)." diff --git a/docs/dev/findings.md b/docs/dev/findings.md index bdb99bc4..1ede68f0 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -415,7 +415,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D221 | **SELECT FILE mouse hit-box offset — compiler stack-layout dependency, not coordinate mismatch (M-87 QA / M-91 fix).** — full `## D221` entry at file tail | **FIXED (M-91)** — explicit `{min,max}` `coord2d` pairs under `#ifdef PORT`; human mouse-playtest confirmed. Distinct from D192 (mode-0 clamp range, still open). | | D222 | **High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).** — full `## D222` entry at file tail | **FIXED, awaiting visual verification (M-121).** Root cause traced one step further than the original write-up… | | D223 | **QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter — wheel up = next weapon, wheel down = previous — instead of both directions…** — full `## D223` entry at file tail | **FIXED (M-105, `port/src/input.c`) —… | -| D224 | **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** — full `## D224` entry at file tail | **DEPRIORITIZED (M-87, user call).** Gamepad-only —… | +| D224 | **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** — full `## D224` entry at file tail | **LANDED via D401 (2026-09-27, re-promoted from DEPRIORITIZED).** Rumble Pak now drives real SDL gamepad haptics (`SDL_GameControllerRumble`) via the port-layer `inputRumble*` API + `CONT_CARD_ON`/`PFS_ERR_DEVICE` gating; single global `Input.RumbleScale` (0..1) config + one `Rumble strength` slider row. Real-pad playtest owed. Gamepad-only —… | | D225 | **Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.** — full `## D225` entry at file tail | **FIXED (2026-09-18)** — `GE_DEBUG_UNLOCKALL=1` short-circuits `fileGetIsCheatUnlocked()` (`src/game/file2.c`, `#ifdef PORT`, cached getenv) to always return TRUE; default off, no save mutation. Build-verified; headless boot with `GE_STARTMENU=7` (mission select) crash-free. | | D226 | **QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.** — full `## D226` entry at file tail | **FIXED (2026-09-24)**: Game.HudScale 75–150% (user-verified, capped at 150) via fast3d G_HUDSCALE_EXT rect scaling about per-element anchors; dialogue/bottom messages width-capped. | | D227 | **Sky renders as two independently-moving halves — "two sky processes" (M-93 QA; root cause M-100b: unit error, `unk20`/`unk24` are S10.5 not texels).** — full `## D227` entries (M-94→M-100b) at file tail | **FIXED (M-100b, PR #43)** — also fixed "scrolls too fast" (M-96) + over-tiling; human play-test confirmed. Full history in the sections. | @@ -685,6 +685,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D398 | **PC Options label: D348's procedural slider glyph replaced with an existing mainfolderimages texture (2026-09-27).** Full `## D398` entry at file tail. | SUPERSEDED by D400 (icon removed, text-only label; purple-hover bug moot; the D400 `textMeasure` arg-swap bug was found and fixed there). | | D399 | **File-select bottom row (SELECT FILE text + Copy/Erase + PC Options dot/label) shifted 15px left as a block (user sign-off 2026-09-27).** Full `## D399` entry at file tail. | LANDED (port-only, D343 class): `selectpos.f[0]` 110→95 (new `#ifdef PORT` in front.c), Copy 200/222→185/207, Erase 282/304→267/289 (D343 PORT block), `LABEL_X` 358→343 (`frontoptions.c`); hit bounds recompute from the shifted positions. Clean build, PCDUMP frame 195 no overlaps; by-eye check owed. | | D400 | **File-select "PC Options" label: icon removed, text-only; fixed a swapped textMeasure() arg bug and a 7px vertical misalignment vs Copy/Erase (user sign-off 2026-09-27).** Full `## D400` entry at file tail. | LANDED (port-only, `frontoptions.c`): D398's dot icon dropped on user instruction ("most straightforward design"); label now a plain `textRender` word, GAP 28 after Erase. Two measurement bugs fixed: (1) `textMeasure(textheight, textwidth)` args passed swapped -> label 35px above the bar, hot band transposed; (2) bare-label height measures 0 (vs 14 for newline-terminated Copy/Erase) -> 7px lower tops; fixed by restoring the `kLabelNL` height measure. PCDUMP frame 195: tops aligned 279.8 vs 279.3, 31px clearance from Erase; by-eye check owed. | +| D401 | **Rumble Pak → real gamepad haptics (re-promotes D224/M-87): `CONT_CARD_ON` gate + `osPfs*`/`osMotor*` shims rewired to `SDL_GameControllerRumble`, single global `Rumble strength` slider on `Input.RumbleScale` (2026-09-27/28).** Full `## D401` entry at file tail. | LANDED (port-only, `input.c`/`input.h`/`libultra.c` + `optionsoverlay.c` per `docs/dev/RUMBLE-PLAN.md`; zero `src/` edits). Both M-87 addendum blockers: `g_contStatus[i].status` now sets `CONT_CARD_ON` for connected rumble-capable pads (so `joyRumblePakInit` arms) and `osPfsInit` returns `PFS_ERR_DEVICE` for them (so `osMotorInit` is attempted). New `inputRumble*` API + global `Input.RumbleScale` (0..1, dflt 0.5; the four per-player `Input.PlayerN.RumbleScale` keys were dropped in the final menu-shape decision); `osMotorStart/Stop` drive it. **Real-pad haptics confirmed working by user (2026-09-28)**; plan item 3 (one global `Rumble strength` slider in the F10 overlay + front options screen, reset-table entry) landed the same day. `RumbleScale=0` silence check still unverified (trivial early-out path). | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS @@ -10772,6 +10773,13 @@ D243 (cutscene shake/duplication, its own CONSOLIDATED NEXT STEPS block appears ## D224 — QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized. +**DONE via D401 (2026-09-27).** Re-promoted from DEPRIORITIZED; implemented per +`docs/dev/RUMBLE-PLAN.md` exactly as the M-87 addendum prescribed — both +upstream blockers (`CONT_CARD_ON` gate + `osPfsInit` → `PFS_ERR_DEVICE`) landed +with the `SDL_GameControllerRumble` wiring, plus `Input.PlayerN.RumbleScale` +config (options-menu slider deferred). Real-pad playtest + `RumbleScale=0` +silence check owed. See `## D401` (file tail) for the full record. + **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** GE genuinely has a rumble subsystem (`src/motor.c`, `src/joy.c`) but the port's `osMotorInit/Start/Stop` (`port/src/libultra.c:1220-1223`) are all hard stubs — "no accessories on the PC," `osMotorStart`/`osMotorStop` return `-1` unconditionally, `osPfsFindFile`-family returns `PFS_ERR_NOPACK`. So every in-game rumble event (weapon recoil, explosions, etc.) computes and calls into a dead path. **PD's PC port wires the equivalent straight through to real gamepad rumble:** `inputRumbleSupported(idx)` checks `SDL_GameControllerHasRumble`/`SDL_JoystickIsHaptic` (with a Windows fallback: some pads report no haptics but rumble anyway), `inputRumble(idx, strength, time)` calls `SDL_GameControllerRumble()` scaled by a per-pad `RumbleScale` config value (options-menu slider, `Input.PadN.RumbleScale`), and `osPfsFindFile` reports a "Rumble Pak" present exactly when a real pad supports rumble (`libultra.c` — same "pretend the N64 accessory is there" pattern GE's port already uses for `osMemSize`/Expansion Pak, just applied to a real capability instead of faked). Cross-ref `QOL-INVENTORY.md` (folded into the existing "per-pad tuning" row). **Status (from §F table):** **DEPRIORITIZED (M-87, user call).** Gamepad-only — irrelevant to mouse/keyboard, which is the primary target for this next release; no keyboard/mouse play is affected by NOT doing this. Root cause + fix still valid whenever gamepad support becomes a priority (no `src/` game-logic touch: GE's `motor.c`/`joy.c` call sites already exist and already call the `osMotor*` shims; the fix is entirely in `port/src/libultra.c` + `input.c` — add `inputRumbleSupported`/`inputRumble` per the PD shape, wire `osMotorStart`/`osMotorStop` to them, add `Input.PadN.RumbleScale` config). **M-87 PD-legacy-survey addendum (`docs/dev/notes/PD-LEGACY-SURVEY.md` candidate 1, high confidence) — the D224 fix as originally stated is NOT sufficient by itself.** Two more upstream blockers sit before the stubbed `osMotorInit/Start/Stop` ever gets reached: (1) `port/src/libultra.c:1096-1098` sets `g_contStatus[i].status = 0` for every pad — `CONT_CARD_ON` is never set, and `src/joy.c:184` gates the *entire* rumble-pak init path on that bit, so `joyRumblePakInit()` never even calls `osPfsInit`. (2) `src/joy.c:186-190` only attempts `osMotorInit` if `osPfsInit`'s return is `PFS_ERR_ID_FATAL` or `PFS_ERR_DEVICE` — but the port's stub (`libultra.c:1214`) returns `PFS_ERR_NOPACK`, so motor init is never attempted even once (1) is fixed. PD's exact trick (`libultra.c:213-227,349-351`): set `CONT_CARD_ON` for connected pads, and make `osPfsInitPak()` return `PFS_ERR_DEVICE` specifically when the pad supports rumble. **Whenever D224 gets implemented, both of these must land alongside the `SDL_GameControllerRumble` wiring** or the game's state machine parks at `RUMBLEPAKINITSTATE_NOT_READY` forever and nothing audibly changes despite looking wired up correctly. @@ -15293,3 +15301,91 @@ right margin (canvas 440). **Owed: by-eye check via `scratch\playtest_v040.bat` (idle legibility, gold hover not purple, click opens the F10 overlay, vertical alignment with Copy/Erase).** Cross-ref D348 (glyph lineage), D398 (superseded icon approach), D399 (bottom-row 15px shift). + +## D401 — Rumble Pak → real gamepad haptics: `CONT_CARD_ON` gate + `osPfs*`/`osMotor*` shims rewired to `SDL_GameControllerRumble`, global `Input.RumbleScale` slider (2026-09-27/28). + +Implements **D224 (M-87)** per `docs/dev/RUMBLE-PLAN.md` — the N64 Rumble +Pak state machine (`src/joy.c` `joyRumblePakInit`/`joyRumblePakTick`/Start/Stop, +call sites `gunfire.c:3284/3288`, `bondview2.c:9722/9726`, `lv.c:1688`, +`sched.c:272`) now drives real SDL gamepad haptics. **Port layer only: zero +`src/` edits**, so rule 2 / the ABI exception are not implicated. + +**The two M-87 addendum blockers** (both `port/src/libultra.c`): + +1. `contSnapshotFromKeyboard` left `g_contStatus[i].status = 0`, so + `joyRumblePakInit` (`src/joy.c:178` — requires `status & CONT_CARD_ON`) never + ran and `joyRumblePak` was never populated. Now + `status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0` — + gated on real capability so non-rumble pads never enter the rumble path + (cleaner than PD's unconditional bit; `joyRumblePakInit` is the real gate). + `type` stays `CONT_TYPE_NORMAL` (includes `CONT_JOYPORT`; PD's + `CONT_ABSOLUTE` would have broken the init gate). +2. `osPfsInit` stubbed `PFS_ERR_NOPACK`, so `osMotorInit` was never + attempted (`src/joy.c:186-190` proceeds only on `PFS_ERR_ID_FATAL` or + `PFS_ERR_DEVICE`). Now returns `PFS_ERR_DEVICE` ("Rumble Pak present, not a + Memory Pak") for rumble-capable channels, `PFS_ERR_NOPACK` otherwise. + +**`port/src/input.c`** (flat statics, GE style — not PD's `padsCfg[]`): +`padRumbleOn[MAX_PADS]` + a single global `gRumbleScale` (default 0.5) — +the per-pad `padRumbleScale[]` array was superseded 2026-09-28 by the +global slider (see "Menu row" below). Detection in +`inputOpenPads`: `SDL_GameControllerHasRumble` on SDL ≥ 2.0.18, else +`SDL_JoystickIsHaptic` with the known-type Windows no-haptic fallback; zeroed +on close in `inputDestroy`/`inputRescanPads`. PS4/PS5 HIDAPI rumble hints in +`inputInit`. New API: `inputRumbleSupported(idx)` (bounds-checked against +`MAX_PADS` — JPN `_HW_VERSION_1` has `MAXCONTROLLERS == 6`, so out-of-range +channels report unsupported and stay silent), `inputRumble(idx, strength, +time)` (no-op on `gRumbleScale <= 0`; scales strength, `*65535` / `*1000` +units, `SDL_GameControllerRumble`; `(0,0)` stops), global +`inputRumbleGetScale(void)`/`inputRumbleSetScale(f32)`. Config: a single +`Input.RumbleScale` (range 0..1, default 0.5) registered in +`inputConfigInit`; the four per-player `Input.Player%d.RumbleScale` keys +were dropped in the same-day menu-shape decision (stale keys in an +existing ini log four `unknown key` `LOG_NOTE` lines and self-clean on the +next `configSave`). `keyboard`/mouse-only play stays silent: no pad open +⇒ `pads[idx]` NULL ⇒ `inputRumble` no-ops. + +**`port/src/libultra.c`** shims: `osPfsInit` as above; `osPfsIsPlug` sets its +pattern bits for the capable channels over `MAXCONTROLLERS` (completeness +only — **no GE game call sites**); `osMotorInit` stores `pfs->queue`, +`pfs->channel`, `pfs->activebank = 0xff` (PD `osMotorProbe` shape) and returns +0 for capable channels, `PFS_ERR_NOPACK` otherwise (no `PFS_MOTOR_INITIALIZED` +store — not defined in GE headers and `joy.c` never reads `pfs->status`); +`osMotorStart` → `inputRumble(channel, 1.0f, 5.0f)` (one generous window; +the game's `joyRumblePakTimer60` normally stops it, PD-style), `osMotorStop` → +`inputRumble(channel, 0.0f, 0.0f)`; both return 0 for non-NULL `pfs`. + +**Verification.** `./build-pc.sh ntsc-final` clean (no new undefined/duplicate +symbols; `inputRumble*` defined once in input.c, declared in input.h). +**Real-pad haptics confirmed working by the user (2026-09-28)** — the gun- +impact rumble path is live on a real controller. The `RumbleScale=0` +silence check remains unverified (it is the trivial `gRumbleScale <= 0` +early-out in `inputRumble`). Same-session environment fix: the +recurring `Cannot create temporary file in C:\Windows\: Permission denied` +link failure is now self-healed by `build-pc.sh` (native-TMP probe → +cmake+ninja re-run under PowerShell with `TMP`/`TEMP` set natively; see the +AGENTS.md "Build" note), and `cannot open output file ... Permission denied` +is documented as the running-`.exe`-lock case (close the game before +rebuilding). + +**Menu row (plan item 3, 2026-09-28).** The deferred options-menu slider +landed the day after the user confirmed the haptics, as a **single global +row** (user decision — "making the rumble menu just 1 player"): +`port/src/optionsoverlay.c` gains one `ROW_SLIDER` row — `Rumble strength` +on `Input.RumbleScale` (0.05 detents; the generic `CONFIG_OPT_FLOAT` +value/display path, shown `%.2f`) — placed in the INPUT section after the +other controller rows, with a `kResetDefaults` entry (0.5, the +`gRumbleScale` C initializer) so INPUT's "Reset to defaults" covers it. +The originally-coded four per-player rows (`Rumble (Player 1..4)` on +`Input.Player%d.RumbleScale`) were replaced before first compile; the +four ini keys are dropped rather than kept as hidden overrides (stale ones +self-clean on the next save, and a per-pad override would be a no-op since +`configSave` rewrites all registered keys — see `RUMBLE-PLAN.md` item 3). +Both the F10 overlay and the front options screen walk the shared `rows[]` +(`frontoptions.c` via the `optionsRow*` accessors), so the row appears in +both. A "row disabled when no pad is rumble-capable" state is NOT +implemented — the overlay has no generic row-disabled mechanism; the row is +inert (no-op) on unsupported/absent pads, and the disabled state is deferred +with the per-pad tuning row (`QOL-INVENTORY.md`), which will need the same +mechanism for deadzone/swap rows. + diff --git a/port/include/input.h b/port/include/input.h index 4030715b..8d454224 100644 --- a/port/include/input.h +++ b/port/include/input.h @@ -88,6 +88,17 @@ int inputMenuPointerLive(void); /* Re-enumerate gamepads after a hotplug (SDL_CONTROLLERDEVICEADDED/REMOVED). */ void inputRescanPads(void); +/* D401: Rumble Pak -> real gamepad haptics. The osMotor* shims in + * libultra.c (game's src/joy.c rumble state machine) route here. + * idx is the N64 controller channel (0..3; out-of-range is a no-op). + * strength is 0..1, time seconds; 0/0 stops an active rumble. The scale + * is the single global Input.RumbleScale config value (0 = silent), + * applied to every pad. */ +int inputRumbleSupported(int idx); +void inputRumble(int idx, f32 strength, f32 time); +f32 inputRumbleGetScale(void); +void inputRumbleSetScale(f32 v); + #ifdef __cplusplus } #endif diff --git a/port/src/input.c b/port/src/input.c index e9c5c020..7026f410 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -70,6 +70,7 @@ */ #include "port_math.h" /* real system math decls; see header for why */ +#include #include #include @@ -291,6 +292,13 @@ static int padBPrev[MAX_PADS]; /* B/Y edges; track through menus too */ static int padYPrev[MAX_PADS]; static int padSelectPrev = 0; /* Select (BACK) edge: overlay toggle */ +/* 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]; +static float gRumbleScale = 0.5f; + static int mouseEnabled = 1; static int mouseGrabbed = 1; /* released while the window is unfocused */ @@ -579,11 +587,27 @@ static void inputOpenPads(void) if (pads[i]) { continue; } + padRumbleOn[i] = 0; /* D401: (re)detected below when the pad opens */ pads[i] = SDL_GameControllerOpen(i); if (pads[i]) { connectedMask |= (1 << i); - sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as controller %d", - i, SDL_GameControllerName(pads[i]), i); + /* D401: haptics detection (PD pattern, pd_port input.c:320-324). + * SDL_GameControllerHasRumble() landed in SDL 2.0.18 even though + * SDL_GameControllerRumble() is from 2.0.9. */ +#if SDL_VERSION_ATLEAST(2, 0, 18) + padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]); +#else + padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i])); + if (!padRumbleOn[i]) { + /* At least on Windows some controllers report no haptics but + * rumble will still function: assume it's supported for a + * known controller type. */ + const SDL_GameControllerType ctype = SDL_GameControllerGetType(pads[i]); + padRumbleOn[i] = ctype && (ctype != SDL_CONTROLLER_TYPE_VIRTUAL); + } +#endif + sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as controller %d (rumble %s)", + i, SDL_GameControllerName(pads[i]), i, padRumbleOn[i] ? "yes" : "no"); } } for (int i = 0; i < MAX_PADS; ++i) { @@ -812,6 +836,11 @@ int inputInit(void) } } + /* D401: make BT PS4/PS5 pads report haptics (PD input.c:696-697). Must + * be set before the pads below are opened. */ + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1"); + for (int i = 0; i < MAX_PADS; ++i) { pads[i] = NULL; } @@ -854,6 +883,7 @@ void inputDestroy(void) SDL_GameControllerClose(pads[i]); pads[i] = NULL; } + padRumbleOn[i] = 0; /* D401: closed */ } if (SDL_WasInit(SDL_INIT_GAMECONTROLLER)) { SDL_QuitSubSystem(SDL_INIT_GAMECONTROLLER); @@ -2181,12 +2211,59 @@ void inputRescanPads(void) SDL_GameControllerClose(pads[i]); pads[i] = NULL; } + padRumbleOn[i] = 0; /* D401: re-detected by inputOpenPads below */ } inputOpenPads(); sysLogPrintf(LOG_NOTE, "input: rescanned pads (mask=0x%x, %d controller(s))", connectedMask, numControllers); } +/* ------------------------------------------------------------------------ + * D401: Rumble Pak -> real gamepad haptics. + * + * The game's Rumble-Pak state machine (src/joy.c joyRumblePakInit/Tick; + * events from gunfire.c / bondview2.c / lv.c / sched.c) drives the + * osMotor* shims in port/src/libultra.c, which route here. `strength` + * is 0..1 and `time` is seconds; a 0-strength / 0-duration call stops + * an active rumble. The global Input.RumbleScale config value (0 = fully + * silent) scales the strength for every pad. idx is the N64 controller + * channel 0..3 (MAXCONTROLLERS may be 6 on JPN; out-of-range is a no-op). + * ------------------------------------------------------------------------ */ +int inputRumbleSupported(int idx) +{ + if (idx < 0 || idx >= MAX_PADS) return 0; + return padRumbleOn[idx]; +} + +void inputRumble(int idx, f32 strength, f32 time) +{ + if (idx < 0 || idx >= MAX_PADS || !pads[idx]) return; + if (gRumbleScale <= 0.f) return; + if (padRumbleOn[idx]) { + strength *= gRumbleScale; + if (strength <= 0.f) { + strength = 0.f; + time = 0.f; + } else { + strength *= 65535.f; + time *= 1000.f; + } + SDL_GameControllerRumble(pads[idx], (Uint16)strength, (Uint16)strength, (Uint32)time); + } +} + +f32 inputRumbleGetScale(void) +{ + return gRumbleScale; +} + +void inputRumbleSetScale(f32 v) +{ + if (v < 0.f) v = 0.f; + if (v > 1.f) v = 1.f; + gRumbleScale = v; +} + int inputConnectedMask(void) { return connectedMask; @@ -2495,6 +2572,12 @@ PD_CONSTRUCTOR static void inputConfigInit(void) configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); configRegisterInt("Input.PadTriggerPct", &padTriggerPct, 1, 99); configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 0, 1); + /* D401: global rumble scale (0 = silent .. 1 = full), applied to every + * pad in inputRumble(). One setting for all pads (user call, 2026-09-28): + * the original per-pad Input.PlayerN.RumbleScale keys were dropped -- + * stale ones in an existing ini log a one-time "unknown key" note and + * self-clean on the next configSave. */ + configRegisterFloat("Input.RumbleScale", &gRumbleScale, 0.f, 1.f); /* D380: single PC layout. Old Input.Layout is ignored and removed on * configSave; BindingsVersion marks the one-time stale-default migration. */ configRegisterInt("Input.BindingsVersion", &bindsVersion, 0, 3); diff --git a/port/src/libultra.c b/port/src/libultra.c index 894a9fdc..6c29fe5f 100644 --- a/port/src/libultra.c +++ b/port/src/libultra.c @@ -1099,7 +1099,10 @@ static void contSnapshotFromKeyboard(void) u16 button = connected ? (u16)inputComputePad(i, &sx, &sy) : 0; g_contStatus[i].type = connected ? CONT_TYPE_NORMAL : 0; - g_contStatus[i].status = 0; + /* D401: present a Rumble Pak (CONT_CARD_ON) whenever the connected + * pad can actually rumble. joyRumblePakInit (src/joy.c:184) gates + * the whole rumble-pak path on this bit. */ + g_contStatus[i].status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0; g_contStatus[i].errno = connected ? 0 : CONT_NO_RESPONSE_ERROR; g_contPad[i].button = button; @@ -1409,15 +1412,62 @@ s32 osEepromLongRead(OSMesgQueue *mq, u8 addr, u8 *buf, int nbytes) s32 osEepromLongWrite(OSMesgQueue *mq, u8 addr, u8 *buf, int nbytes) { (void)mq; return geEepromRW(addr, buf, nbytes, 1); } -/* Memory Pak (PFS) + Rumble Pak (motor): no accessories on the PC. */ +/* Memory Pak (PFS) + Rumble Pak (motor): no Memory Pak on the PC (saves are + * file-backed EEPROM), but the Rumble Pak path is routed to real gamepad + * haptics (D401): a pad that can rumble presents itself as a "card is on" + * accessory, and the motor calls become SDL_GameControllerRumble via + * inputRumble (port/src/input.c). */ s32 osPfsInit(OSMesgQueue *queue, OSPfs *pfs, int channel) -{ (void)queue; (void)pfs; (void)channel; return PFS_ERR_NOPACK; } +{ + (void)queue; (void)pfs; + /* PFS_ERR_DEVICE ("wrong device type") = a Rumble Pak is present, not a + * Memory Pak: exactly what joyRumblePakInit (src/joy.c:186-190) needs + * to proceed to osMotorInit. PD's osPfsInitPak does the same. */ + return inputRumbleSupported(channel) ? PFS_ERR_DEVICE : PFS_ERR_NOPACK; +} s32 osPfsIsPlug(OSMesgQueue *queue, u8 *pattern) -{ (void)queue; if (pattern) *pattern = 0; return 0; } +{ + (void)queue; + if (pattern) { + *pattern = 0; + /* JPN: MAXCONTROLLERS can be 6 > MAX_PADS; inputRumbleSupported() + * bounds-checks, so the extra channels just never light up. */ + for (int i = 0; i < MAXCONTROLLERS; ++i) + if (inputRumbleSupported(i)) *pattern |= (u8)(1 << i); + } + return 0; +} s32 osMotorInit(OSMesgQueue *mq, OSPfs *pfs, int channel) -{ (void)mq; (void)pfs; (void)channel; return -1; } -s32 osMotorStart(OSPfs *pfs) { (void)pfs; return -1; } -s32 osMotorStop(OSPfs *pfs) { (void)pfs; return -1; } +{ + if (pfs && inputRumbleSupported(channel)) { + pfs->queue = mq; + pfs->channel = channel; + pfs->activebank = 0xff; + /* NOTE: PFS_MOTOR_INITIALIZED is not defined in GE's headers and + * joy.c never reads pfs->status -- it only tests osMotorInit()==0 + * (src/joy.c:190) to mark the pad RUMBLEPAKINITSTATE_READY, so no + * status store is needed (PD's osMotorProbe writes one). */ + return 0; + } + (void)mq; (void)channel; + return PFS_ERR_NOPACK; +} +s32 osMotorStart(OSPfs *pfs) +{ + if (!pfs) return PFS_ERR_NOPACK; + /* The N64 motor has no duration; the game's joyRumblePakTimer60 (src/joy.c) + * arms a per-event countdown and turns the motor off via osMotorStop. + * Issue one generous 5 s window, PD-style ("hope the timer stops it"); + * inputRumble no-ops internally for non-rumble pads. */ + inputRumble(pfs->channel, 1.0f, 5.0f); + return 0; +} +s32 osMotorStop(OSPfs *pfs) +{ + if (!pfs) return PFS_ERR_NOPACK; + inputRumble(pfs->channel, 0.0f, 0.0f); /* zero strength/duration = stop */ + return 0; +} /* ------------------------------------------------------------------------ */ /* SP (RSP) — runs the software RSP inline, then posts the done messages */ diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 741d5506..03110290 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -205,6 +205,16 @@ static struct Row rows[] = { { .key="Input.PadLookInvertY", .label="Invert look (controller)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, { .key="Input.PadDeadzone", .label="Stick deadzone", .kind=ROW_SLIDER, .step=500 }, { .key="Input.PadTriggerPct", .label="Trigger threshold", .kind=ROW_SLIDER, .step=1 }, + /* D401 item 3 (docs/dev/RUMBLE-PLAN.md): Rumble Pak strength (D224 + * re-promotion). One global Input.RumbleScale for all pads (user call, + * 2026-09-28 -- the original four per-player rows were dropped). The + * value lives in input.c's gRumbleScale (0 = silent, 1 = full, dflt + * 0.5), applied in inputRumble(); 0.05 detents give 20 steps, the + * generic float value path displays them as %.2f. Inert (inputRumble + * no-ops) when no rumble-capable pad is connected -- a generic + * row-disabled state for that case is deferred with the per-pad tuning + * row (QOL-INVENTORY). */ + { .key="Input.RumbleScale", .label="Rumble strength", .kind=ROW_SLIDER, .step=0.05 }, /* v0.4.0 M3 (modern options wave, D371): the GEPD mouse-injector * key-layout preset (docs/dev/notes/GEPORT-REFERENCE-DEEPDIVE.md * section 7.1) + the crouch bind's fire mode. Turok standard: these are @@ -1252,6 +1262,7 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Input.PadLookInvertY", 0 }, /* = 0 */ { "Input.PadDeadzone", 7000 },/* = STICK_DEADZONE (7000) */ { "Input.PadTriggerPct", 23 }, /* = 23 */ + { "Input.RumbleScale", 0.5 }, /* = gRumbleScale (0.5f), D401 */ { "Input.CrouchMode", 0 }, /* = 0 (hold) */ /* GRAPHICS (port/src/video.c initializers) */ { "Video.MSAA", 2 }, /* = 2 */