# Controller & input options wave (port plan) Status: **Wave A IMPLEMENTED** (code complete, clean Windows/MSYS2 build; pending user by-ear/by-eye feel-check + Deck tuning of item 5) — the "low-hanging fruit" from the Nightdive *Turok* PC-port options menu that is cheap, well- scoped, and **port-layer only** (zero `src/` / `src/game` edits → rule 2 and the ABI exception do not apply). Sequenced after v0.4.0 ships. This is the *input* wave; the sibling *video/audio/graphics* Tier-A items (borderless window, primary display, brightness, master volume, button glyphs, uncapped-FPS) are a separate follow-on wave — see §Out of scope. Reference: `docs/dev/RUMBLE-PLAN.md` (D401) is the closest precedent — a controller option that lives entirely in `port/src/input.c` + `port/src/optionsoverlay.c` + a `configRegister*` key + a `kResetDefaults` entry, with the game side (`src/joy.c`, `src/` stick consumers) untouched. `docs/dev/GEPD-INPUT-PLAN.md` documents the pad poll path this wave extends. --- ## Ground truth (current state, verified 2026-09-27) **Pad poll = `inputComputePad()`** (`port/src/input.c:1375`), runs on the game thread (`osContStartReadData`). It reads the SDL axes/buttons for pad `idx`, writes out a 16-bit button mask plus `*stick_x` / `*stick_y` (the N64 `signed char` stick, clamped to `STICK_MAX`). All the knobs in this wave are applied **inside** `inputComputePad`, before the two `*stick_x`/`*stick_y` writes at `:2063-2064` — i.e. the port re-shapes the *input the game already consumes*, never the game's stick→turn math. **Two pitch modes drive the look stick** (selected by `Input.NaturalPitch`, the default-on "SOLITARE" mode, D194/D238): - **Continuous (natural-pitch, the default):** the **right stick** is the continuous look axis — `input.c:1927-1933`: `rxs = scaleAxis(rx);` … `rys = -scaleAxis(ry); if (padLookInvertY) rys = -rys; sx = rxs; sy = rys;`. The left stick becomes digital-step movement. **This is the path the X/Y look-sensitivity + look-smoothing + right-stick deadzone knobs target.** - **Digital (native, natural-pitch off):** the left stick is analog movement/turn and the right stick is digitized into the C-button look (`:1935-1947`, thresholds at `RSTICK_THRESHOLD`). Look here is the faithful N64 *digital* C-button turn — **not sens-scalable** (there is no analog look to scale), so the new look-sens/smoothing knobs are inert in this mode, exactly as `Input.AimMode`/`Input.AimRange` already are per mode. **Existing pad knobs** (all `configRegister*` in `input.c`, rows in `optionsoverlay.c` INPUT section): `Input.PadDeadzone` (single, `padDeadzone = STICK_DEADZONE`, `:524`), `Input.PadTriggerPct` (`:525`), `Input.PadLookInvertY` (`:526`), `Input.RumbleScale` (D401). `scaleAxis()` applies the shared deadzone + `STICK_MAX` scale to a raw `int16` axis. **Already covered elsewhere** (do not re-plan): mouse sens/invert/smoothing (`Input.MouseSensitivity`/`MouseInvertY`/`MouseSmoothing`), **uncapped FPS** (`docs/dev/UNLOCKED-FPS-PLAN.md` — parked; has a real perf prerequisite, D250), **controller rebinding** (PR #109 / D383 — the hard, open gap, out of scope). --- ## Items (all port-layer) ### 1. `Input.PadLookSensX` — right-stick look **horizontal** sensitivity *(S)* New int key, default **100** (= 1×, current behaviour), range ~**25–200 %**. Applied in the continuous-look branch only: after `rxs = scaleAxis(rx)`, `rxs = clamp(rxs * padLookSensX / 100, ±STICK_MAX)` before `sx = rxs` (`input.c:1928/1932`). Pure gain on the emitted look X. In digital mode: inert. - Row: INPUT, "Look sensitivity (left/right)", slider, after "Rumble strength". - Risk: none (monotonic gain; 100% = byte-identical to today). Deck-testable. ### 2. `Input.PadLookSensY` — right-stick look **vertical (pitch)** sensitivity *(S)* Same shape, applied to `rys` (`input.c:1930/1933`): `rys = clamp(rys * padLookSensY / 100, ±STICK_MAX)`. Default **100**, range ~**25–200 %**. Kept separate from X (Turok splits them; pitch and yaw feel different on a stick). Independent of the existing `Input.HipfirePitchSpeed` (that one is the hipfire/mouse pitch rate, a different path). In digital mode: inert. - Row: INPUT, "Look sensitivity (up/down)", slider. - Risk: none (same as item 1). ### 3. `Input.PadSouthpaw` — swap L/R triggers *(S)* New toggle, default **off**. Today (`input.c:1954-1958`): `TRIGGERRIGHT > trigPt → GE_CONT_G`; `TRIGGERLEFT > trigPt → GE_CONT_R`. Under southpaw, swap the two `GE_CONT_G`/`GE_CONT_R` assignments (left trigger becomes the right-shoulder action and vice-versa). Two lines. Lets left-handed / southpaw players put their index finger on the primary fire trigger. - Row: INPUT, "Southpaw", toggle. - Risk: none at default off; trivial to verify by re-aiming. ### 4. `Input.PadDeadzoneL` / `Input.PadDeadzoneR` — per-stick deadzone *(S–M)* Split the single `Input.PadDeadzone` into a **left (movement)** and a **right (look)** deadzone. Thread the deadzone through `scaleAxis()` (it is currently the global `padDeadzone`) as a per-call argument: left-stick calls use `padDeadzoneL`, right-stick calls use `padDeadzoneR`. - **Back-compat/migration:** on load, if `Input.PadDeadzoneL`/`R` are absent but legacy `Input.PadDeadzone` is set, seed both from it (one-time, like the `AimStyle`→`AimMode` migration at `input.c:449-454`). Keep `Input.PadDeadzone` registered so old inis keep loading; it becomes hidden/superseded (D337/`D333` precedent for a superseded key). - Rows: INPUT, "Deadzone (left stick)" + "Deadzone (right stick)", sliders. - Risk: low — a deadzone is a per-axis floor; splitting it cannot change behaviour when both equal the current single value. Watch the menu-navigation path (`:1918-1922`, left stick in menus) to use the **left** deadzone there. ### 5. `Input.PadLookSmoothing` — right-stick look low-pass *(M)* New key, default **0 (off)**, range 0–10 (0 = off = today). When >0, low-pass filter the *emitted* right-stick look deflection so the look axis eases rather than tracking the stick 1:1 — the pad analogue of `Input.MouseSmoothing` (which smooths the *relative* mouse delta). Because the continuous stick is **absolute** (not a delta), implement as a per-pad exponential moving average on the emitted `rxs`/`rys` (state kept per `idx`, reset on pad hot-unplug and on aim-mode exit so releasing the stick recentres cleanly). The higher the value, the heavier the easing. - Row: INPUT, "Look smoothing", slider (0 = off). - Risk: **medium** — an EMA on an absolute stick can feel laggy or fail to recentre if the filter isn't driven back to 0 when the stick is centred; needs hands-on tuning on the Deck (the natural test device). Keep default off so it ships inert until tuned. ### 6. Mouse **vertical** sensitivity row *(trivial — folds in)* `Input.MouseYScale` already exists as a registered key; it just has no F10 row. Add one slider row (INPUT) so the asymmetric X/Y mouse sensitivity is user-adjustable without editing the ini. No `input.c` change — row only. --- ## Shared plumbing (do once, not per item) - **`configRegisterInt`/`Float`** for each new key in `input.c` next to the existing pad-key registrations (~`:2536-2538`). - **Static state** next to `padDeadzone`/`padLookInvertY` (`:524-526`): `padLookSensX/Y`, `padSouthpaw`, `padDeadzoneL/R`, `padLookSmoothing` + the per-pad EMA state for item 5. - **F10 rows** in `optionsoverlay.c` `rows[]` INPUT section (after "Rumble strength", with the other controller rows) + matching **front options** rows via the shared `optionsRow*` accessors (both UIs read `rows[]` — D401 precedent, so one definition covers F10 *and* the front options screen). - **`kResetDefaults`** entries for each new key (neutral: sens 100, smoothing 0, southpaw 0, deadzones = `STICK_DEADZONE`) so INPUT "Reset to defaults" covers them (D401 item-3 precedent). ## Ground rules 1. **Port-layer only** — every edit is in `port/src/input.c`, `port/src/optionsoverlay.c`, `port/src/frontoptions.c`, and `CMakeLists.txt` (nothing new to compile — no new files). Zero `src/` / `src/game` edits → rule 2 and the pointer-width ABI exception are not engaged. 2. **Off-by-default = today's behaviour.** Every new key defaults to a neutral value that leaves the emitted stick/buttons byte-identical to the current build, so the golden gate (D117/`golden_gate.sh`) is unaffected at defaults and the v0.4.0 playtest baseline is preserved. 3. **Per-mode honesty.** The continuous-look knobs (1, 2, 5) apply only in natural-pitch mode; in digital mode they are inert — document this on the rows (mirrors how `Input.AimRange` is hidden in Centred mode, D338), rather than pretending they affect the digital C-button look. 4. **Deck is the reference device** for the pad wave (native Steam Controller, the real-world target); Windows + a plugged gamepad as the second check. ## Verification 1. `./build-pc.sh ntsc-final` clean (no new symbols/dupes — the `configRegister*` + `kResetDefaults` additions are the only new references). 2. `/linkcheck` + the golden gate (`tools_pc/golden_gate.sh`) — must pass **unchanged at defaults** (proves the neutral-default invariant). 3. Hands-on, on a pad / the Steam Deck: each knob at a non-neutral value changes feel as expected and at default leaves it identical; southpaw re-maps the triggers; per-stick deadzones floor the correct stick; look smoothing eases and **recentres on stick release** (item 5's known risk). 4. New finding (D4xx, next free label) recording the wave + per-item Deck-test result, cross-ref'd to D401 (precedent), D337/D338 (per-mode gating pattern), D333 (`AimStyle` migration pattern), PR #109 (out-of-scope rebind). ## Out of scope for this wave (see the assessment above) - **Sibling Tier-A, other sections (separate follow-on wave):** borderless windowed, primary-display selection, brightness slider, master audio volume, button-prompt glyphs. - **Tier B (medium, contained):** radial fog toggle, HUD opacity, flash- intensity slider (promote `Game.NoHitFlash`), show-HUD master toggle, audio output device, low-pass filter, SMAA/FXAA. - **Uncapped FPS** — `docs/dev/UNLOCKED-FPS-PLAN.md` (parked; D250 perf prerequisite means it is not quick). - **Tier C (rule-2 / big-feature / out-of-scope):** head-bobbing sliders, death-cinematic / switch-on-pickup / walk toggles (all `src/game` flow), bloom / light-scattering / water refraction-reflection / simple shadows, violence / blood colour, multiple graphics APIs, and **full controller rebinding** (PR #109 — the genuinely hard input item). ## Suggested sequencing (v0.5.0) 1. **Wave A (this doc):** items 1–4 + 6 (sens X/Y, southpaw, per-stick deadzone, mouse-Y row) — all low-risk, shippable behind neutral defaults. 2. **Item 5 (look smoothing)** lands in the same wave but may ship default-off pending Deck feel-tuning. 3. **Wave B:** the sibling video/audio/graphics Tier-A items. 4. Revisit Tier C only with explicit sign-off (rule-2 items need the `docs/dev-process.md` procedure). --- ## Implementation status (2026-09-28) Wave A code is complete and compiles clean (Windows/MSYS2 `ninja` incremental, exit 0, no new warnings — only the pre-existing `ramrom.c` / `gfx_pc.cpp` ones). Every edit is in `port/src/input.c` + `port/src/optionsoverlay.c`; `frontoptions.c` needed **no** change (both UIs read the shared `rows[]` via the `optionsRow*` API). Zero `src/` edits, no new files/symbols — only file-local statics + `configRegisterInt` calls — so the link is unchanged. Implemented (all off/neutral-by-default → byte-identical at defaults): - **1/2. `Input.PadLookSensX` / `Input.PadLookSensY`** (25–200 %, dflt 100): right-stick look, applied in the natural-pitch branch of `inputComputePad()` after `scaleAxis()`, re-clamped to `STICK_MAX`. Inert in digital mode (the right stick is digitized there, no analog look to scale). - **3. `Input.PadSouthpaw`** (toggle, dflt 0): swaps the fire (`GE_CONT_G`) and grenade (`GE_CONT_R`) trigger actions *after* the raw edges are captured, so edge-detection stays consistent. - **4. `Input.PadDeadzoneL` / `Input.PadDeadzoneR`** (0–30000, dflt 7000): `scaleAxis()` now takes a per-stick deadzone param; left-stick (movement) calls pass `L`, right-stick (look) calls pass `R`. The legacy `Input.PadDeadzone` key stays registered (no row) so old inis load; a one-time migration in `inputInit()` seeds any unset L/R (C-init sentinel `PAD_DZ_UNSET = -1`) from the legacy value, before any `configSave`. The single "Stick deadzone" row is replaced by "Deadzone (left stick)" / "(right stick)". - **5. `Input.PadLookSmooth`** (0–10, dflt 0 = off, shown "N/10"): per-pad EMA low-pass on the natural-pitch look output (`padSmSX/padSmSY[MAX_PADS]`), reset on hot-unplug (`inputRescanPads`) so a snapped-back stick can't lag from a stale value; otherwise the EMA decays to 0 as the stick recentres. Turok has this as on/off; GE ships a graduated 0-10 knob instead. Ships **default-off** pending Deck feel-tuning. - **6. `Input.MouseYScale`** row (dflt 100): the pre-existing registered key finally gets an INPUT slider row ("Mouse vertical sensitivity") — row only, no `input.c` change. **Verbiage + display baseline (Turok PC, 2026-09-28):** row labels now match the Turok options menu verbatim where GE has an analogue — *Mouse horizontal / vertical sensitivity*, *X axis / Y axis look sensitivity*, *Look smoothing*, *Southpaw*, *Vibration* (was "Rumble strength"), *Deadzone (left/right stick)*. The display is baselined to Turok's scales: sensitivity + deadzone knobs read **0-100 with 50 = the default** (the existing `calibratedDefault` "N/100" path), and look smoothing reads **0-10** ("N/10"). (Turok's Mouse/Controller subsection split is not mirrored — GE keeps one flat INPUT section per D356, so the two invert rows retain their "(mouse)"/"(controller)" suffixes to stay unambiguous.) `kResetDefaults` covers all six so INPUT "Reset to defaults" is complete; `calibratedDefault()` gives the sensitivity + deadzone rows their 50/100 default midpoint, and `dispMax` renders the smoothing knob as "N/10". **Still owed (verification, not code):** user by-ear/by-eye feel-check of the new knobs on a real pad (esp. item 5 smoothing strength + item 1/2 sens range), the Deck cross-check, and confirming the per-stick deadzone migration on an old-ini user (L/R seed from `Input.PadDeadzone`).