Low-hanging-fruit controller/input options ported from the Turok PC port's menu, scoped as port-layer-only (zero src/ edits): right-stick look X/Y sensitivity, southpaw trigger swap, per-stick deadzone (with the legacy single-deadzone migration), look smoothing, and a mouse-vertical-sensitivity row. All off-by-default (byte-identical at defaults, golden gate preserved). Documents what the GE port already has, the per-item code shape + insertion points in input.c, and the Tier B/C items explicitly out of scope (head bob, bloom/shadows, death-cam, uncapped-FPS -> UNLOCKED-FPS-PLAN, controller rebind -> PR #109).
11 KiB
Controller & input options wave (port plan)
Status: PROPOSED (v0.5.0 candidate) — 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 atRSTICK_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 asInput.AimMode/Input.AimRangealready 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/Rare absent but legacyInput.PadDeadzoneis set, seed both from it (one-time, like theAimStyle→AimModemigration atinput.c:449-454). KeepInput.PadDeadzoneregistered so old inis keep loading; it becomes hidden/superseded (D337/D333precedent 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/Floatfor each new key ininput.cnext 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.crows[]INPUT section (after "Rumble strength", with the other controller rows) + matching front options rows via the sharedoptionsRow*accessors (both UIs readrows[]— D401 precedent, so one definition covers F10 and the front options screen). kResetDefaultsentries 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
- Port-layer only — every edit is in
port/src/input.c,port/src/optionsoverlay.c,port/src/frontoptions.c, andCMakeLists.txt(nothing new to compile — no new files). Zerosrc//src/gameedits → rule 2 and the pointer-width ABI exception are not engaged. - 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. - 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.AimRangeis hidden in Centred mode, D338), rather than pretending they affect the digital C-button look. - 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
./build-pc.sh ntsc-finalclean (no new symbols/dupes — theconfigRegister*kResetDefaultsadditions are the only new references).
/linkcheck+ the golden gate (tools_pc/golden_gate.sh) — must pass unchanged at defaults (proves the neutral-default invariant).- 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).
- 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 (
AimStylemigration 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/gameflow), 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)
- 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.
- Item 5 (look smoothing) lands in the same wave but may ship default-off pending Deck feel-tuning.
- Wave B: the sibling video/audio/graphics Tier-A items.
- Revisit Tier C only with explicit sign-off (rule-2 items need the
docs/dev-process.mdprocedure).