Files
goldeneye-pc-port/docs/dev/CONTROLLER-INPUT-PLAN.md
T
James Dansereau 39d1fd381e input: implement Wave A controller options + Turok-verbatim labels (v0.5.0)
Port-layer only (port/src/input.c, optionsoverlay.c); zero src/game edits.
All new knobs off/neutral at default = byte-identical at defaults.

New controller input options (CONTROLLER-INPUT-PLAN Wave A):
- Input.PadLookSensX/Y: per-axis right-stick look sensitivity (100 = native)
- Input.PadSouthpaw: swap fire<->grenade triggers (applied after edge capture,
  so edge-detection stays consistent)
- Input.PadDeadzoneL/R: per-stick deadzone; legacy Input.PadDeadzone key kept
  registered (no row) + one-time migration in inputInit() seeds any unset L/R
  from it (C-init sentinel PAD_DZ_UNSET) before the first configSave
- Input.PadLookSmooth: 0-10 per-pad EMA low-pass on the natural-pitch look
  output (default 0 = off), reset on pad hot-unplug
- Input.MouseYScale: pre-existing registered key finally gets an INPUT row

Verbiage baseline (Turok PC options menu, user-provided verbatim): Mouse
horizontal/vertical sensitivity, X axis/Y axis look sensitivity, Look
smoothing, Southpaw, Vibration (was "Rumble strength"), Deadzone
(left/right stick). GE keeps one flat INPUT section (D356), so the two
Invert-look rows retain their (mouse)/(controller) suffixes.

Display baseline (Turok scales): sensitivity + deadzone rows render 0-100
with 50 = default (existing calibratedDefault path); look smoothing renders
0-10 via a new dispMax "N/M" row formatter (alongside the existing N/100 and
N/255 special cases).

Tested on a real pad (Windows build) - all six options behave as intended.
2026-09-28 00:30:21 -04:00

14 KiB
Raw Blame History

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).