D401: Rumble Pak -> real gamepad haptics (re-promotes D224/M-87)

Port layer only (zero src/ edits), per docs/dev/RUMBLE-PLAN.md:
- port/src/input.c: inputRumbleSupported/inputRumble -> SDL_GameControllerRumble;
  detection via SDL_GameControllerHasRumble (SDL >= 2.0.18) else
  SDL_JoystickIsHaptic + known-type Windows fallback; zeroed on pad close.
  Single global gRumbleScale (dflt 0.5) registered as Input.RumbleScale.
- port/src/libultra.c: the two M-87 blockers resolved - 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); osMotorInit/Start/Stop drive the haptics API.
- port/src/optionsoverlay.c: one global 'Rumble strength' ROW_SLIDER
  (Input.RumbleScale, 0.05 detents, %.2f) in the INPUT section +
  kResetDefaults entry. The four per-player Input.PlayerN.RumbleScale keys
  were dropped (user decision 2026-09-28); stale keys in an existing ini
  log four 'unknown key' LOG_NOTE lines and self-clean on next configSave.
- Real-pad haptics confirmed working by the user (2026-09-28).

Build env: build-pc.sh self-heal guard revision - re-exec now writes a
self-contained .ps1 (msys->native argv conversion mangles $-bearing
-Command strings; file write is conversion-free), builds PATH explicitly,
picks TMP from writable candidates (GetTempPath honours the broken
inherited TMP), self-logs to build-pc/ge007-native-reexec-*.log, and
treats a null LASTEXITCODE as 'did not execute' (PS 5.1 piped native
commands error non-terminating). OPEN: the guard still fails inside
build-pc.sh while the identical standalone .ps1 invocation works - AGENTS.md
documents the workaround. docs: RUMBLE-PLAN.md (new, tracked), findings.md
D401 entry + index rows, QOL-INVENTORY D224 -> DONE (D401),
findings-index.csv regenerated.
This commit is contained in:
James Dansereau committed 2026-09-27 23:23:05 -04:00
1 parent a548fc4de6
commit 471eecd6d3
10 files changed
+702 -13

No files matched your search

+47
View File
@@ -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 <ps1> "C:\msys64\usr\bin" "C:\msys64\mingw64\bin" C:/msys64 <repo-win-path> build-pc <romid>`.
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`.
+127 -1
View File
@@ -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}"
+1 -1
View File
@@ -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. |
+264
View File
@@ -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 ? padRumbleOn[idx] : 0
void inputRumble(int idx, float strength, float time); // scaled SDL_GameControllerRumble
float inputRumbleGetScale(int idx);
void inputRumbleSetScale(int idx, float v);
```
`inputRumble` body (from PD, verbatim logic):
```c
if (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.**
+2 -1
View File
@@ -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)."
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
31 D221
32 D222
33 D223
34 D224
35 D225
36 D226
37 D227
277 D398
278 D399
279 D400
280 D401
+97 -1
View File
@@ -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.
+11
View File
@@ -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
+85 -2
View File
@@ -70,6 +70,7 @@
*/
#include "port_math.h" /* real system math decls; see header for why */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -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);
+57 -7
View File
@@ -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 */
+11
View File
@@ -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 */