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.
This commit is contained in:
James Dansereau committed 2026-09-28 00:30:21 -04:00
1 parent 83ece6c370
commit 39d1fd381e
3 files changed
+163 -21

No files matched your search

+60 -2
View File
@@ -1,7 +1,9 @@
# 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
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,
@@ -186,3 +188,59 @@ user-adjustable without editing the ini. No `input.c` change — row only.
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`).
+68 -14
View File
@@ -520,10 +520,27 @@ static int naturalPitchMode = 1; /* D194/D238: 1 = force GE's own 1.2/SOLITAR
* 0 = legacy 1.1/HONEY + D166 digital pulse. */
static Uint64 s_lastLookPollCounter = 0; /* D194(b): monotonic clock, gameplay-look drain only */
/* Gamepad tuning -- defaults reproduce the old hardcoded constants exactly. */
static int padDeadzone = STICK_DEADZONE; /* left-stick deadzone, raw 0..32767 */
static int padTriggerPct = 23; /* trigger press point, % of travel (~30*256) */
static int padLookInvertY = 0; /* 1 = invert right-stick (look) Y */
/* Gamepad tuning -- defaults reproduce the old hardcoded constants exactly.
* Wave A (v0.5.0 controller/input wave, docs/dev/CONTROLLER-INPUT-PLAN.md):
* per-stick deadzone splits the single Input.PadDeadzone into a left (movement)
* and a right (look) value (padDeadzone stays registered so old inis load; the
* unset L/R values migrate from it once in inputInit). The look knobs
* (sens X/Y, smoothing) are feel-only and identity at their defaults. */
#define PAD_DZ_UNSET (-1) /* C init for padDeadzoneL/R: "not in ini -> migrate" */
static int padDeadzoneL = PAD_DZ_UNSET; /* left (movement) stick deadzone, raw 0..32767 */
static int padDeadzoneR = PAD_DZ_UNSET; /* right (look) stick deadzone, raw 0..32767 */
static int padDeadzone = STICK_DEADZONE; /* legacy shared deadzone (Input.PadDeadzone) */
static int padLookSensX = 100; /* right-stick look: horizontal sensitivity, % (100 = native) */
static int padLookSensY = 100; /* right-stick look: vertical (pitch) sensitivity, % */
static int padSouthpaw = 0; /* 1 = swap fire (G) / grenade (R) trigger actions */
static int padLookSmooth = 0; /* right-stick look low-pass strength, 0-10 (0 = off, 10 = max) */
static int padTriggerPct = 23; /* trigger press point, % of travel (~30*256) */
static int padLookInvertY = 0; /* 1 = invert right-stick (look) Y */
/* Per-pad look-smoothing EMA state (Wave A item 5), one X/Y pair per pad. Reset
* on hot-unplug (inputRescanPads) so a smoothed value can't stick after the
* stick snaps back; the low-pass otherwise recentres it toward 0 on its own. */
static double padSmSX[MAX_PADS] = {0};
static double padSmSY[MAX_PADS] = {0};
/* Smoothed mouse delta carried between polls when mouseSmoothing > 0. */
static double mouseSmDX = 0.0, mouseSmDY = 0.0;
@@ -848,6 +865,13 @@ int inputInit(void)
inputMigrateBinds(); /* D380: materialise effective GEPD defaults once */
inputRebuildBinds(); /* D214: parse [Bind] now that configLoad() has run */
/* Wave A: one-time per-stick deadzone migration. Old inis carry a single
* Input.PadDeadzone; if the player has not set the new per-stick keys, seed
* both from it (the C-init sentinel PAD_DZ_UNSET means "not in the ini").
* Runs before any configSave, so the saved L/R values are always valid. */
if (padDeadzoneL == PAD_DZ_UNSET) padDeadzoneL = padDeadzone;
if (padDeadzoneR == PAD_DZ_UNSET) padDeadzoneR = padDeadzone;
if (getenv("GE_BINDPROBE")) inputBindingProbe();
sysLogPrintf(LOG_INFO, "input: PC bindings, crouch mode %s",
@@ -912,9 +936,8 @@ void inputUpdate(void)
mouseDY += dy;
}
static int scaleAxis(int v)
static int scaleAxis(int v, int dz)
{
int dz = padDeadzone;
if (dz < 0) dz = 0;
if (dz > 30000) dz = 30000;
if (v > -dz && v < dz) {
@@ -1916,8 +1939,8 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
current_menu != GE_MENU_INVALID);
if (padMenuMode) {
int px = scaleAxis(lx);
int py = -scaleAxis(ly); /* SDL up = negative -> N64 up = positive */
int px = scaleAxis(lx, padDeadzoneL);
int py = -scaleAxis(ly, padDeadzoneL); /* SDL up = negative -> N64 up = positive */
if (px) sx = px;
if (py) sy = py;
} else if (naturalPitchMode) {
@@ -1931,15 +1954,30 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
if (lx < -RSTICK_THRESHOLD) button |= GE_CONT_C; /* strafe left */
if (lx > RSTICK_THRESHOLD) button |= GE_CONT_F; /* strafe right */
int rxs = scaleAxis(rx);
if (rxs || scaleAxis(ry)) s_aimDevMouse = 0; /* D337: pad aim active */
int rys = -scaleAxis(ry); /* SDL up = negative -> N64 up = positive */
/* Wave A (v0.5.0): right-stick look -- per-stick deadzone (right),
* per-axis sensitivity (% of native, 100 = unchanged, re-clamped to
* STICK_MAX), and an optional low-pass. All feel-only, identity at
* their defaults (sens 100%, smoothing off). */
int rxs = scaleAxis(rx, padDeadzoneR);
int rys = -scaleAxis(ry, padDeadzoneR); /* SDL up = negative -> N64 up = positive */
if (padLookSensX != 100 && rxs) rxs = rxs * padLookSensX / 100;
if (padLookSensY != 100 && rys) rys = rys * padLookSensY / 100;
if (rxs > STICK_MAX) rxs = STICK_MAX; else if (rxs < -STICK_MAX) rxs = -STICK_MAX;
if (rys > STICK_MAX) rys = STICK_MAX; else if (rys < -STICK_MAX) rys = -STICK_MAX;
if (rxs || rys) s_aimDevMouse = 0; /* D337: pad aim active */
if (padLookSmooth > 0) {
double a = padLookSmooth * 0.09; /* 10 -> 0.9 (heaviest); 0 = off */
padSmSX[idx] = padSmSX[idx] * a + (double)rxs * (1.0 - a);
padSmSY[idx] = padSmSY[idx] * a + (double)rys * (1.0 - a);
rxs = (int)lround(padSmSX[idx]);
rys = (int)lround(padSmSY[idx]);
}
if (padLookInvertY) rys = -rys;
if (rxs) sx = rxs;
if (rys) sy = rys;
} else {
int px = scaleAxis(lx);
int py = -scaleAxis(ly); /* SDL up = negative -> N64 up = positive */
int px = scaleAxis(lx, padDeadzoneL);
int py = -scaleAxis(ly, padDeadzoneL); /* SDL up = negative -> N64 up = positive */
if (px) sx = px;
if (py) sy = py;
if (px || py) s_aimDevMouse = 0; /* D337: pad aim active */
@@ -1956,6 +1994,14 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
button |= GE_CONT_G;
if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > trigPt)
button |= GE_CONT_R;
/* Wave A: southpaw swaps the fire (G) and grenade (R) trigger actions
* (right<->left trigger), after the raw edges above are captured. */
if (padSouthpaw) {
int t = button;
button = (t & ~(GE_CONT_G | GE_CONT_R))
| ((t & GE_CONT_G) ? GE_CONT_R : 0)
| ((t & GE_CONT_R) ? GE_CONT_G : 0);
}
/* Xbox 1.1 Jinx-style gameplay: A=use, X=reload (handled by the
* keyboard action gate), B=gadgets, Y=weapons, stick clicks=crouch,
@@ -2212,6 +2258,8 @@ void inputRescanPads(void)
pads[i] = NULL;
}
padRumbleOn[i] = 0; /* D401: re-detected by inputOpenPads below */
padSmSX[i] = 0.0; /* Wave A: clear the look-smoothing EMA so a stick
padSmSY[i] = 0.0; * snap-back after re-plug can't lag from a stale value */
}
inputOpenPads();
sysLogPrintf(LOG_NOTE, "input: rescanned pads (mask=0x%x, %d controller(s))",
@@ -2569,7 +2617,13 @@ PD_CONSTRUCTOR static void inputConfigInit(void)
configRegisterInt("Input.MouseSensitivity", &mouseSensitivity, 1, 500); /* D238 */
configRegisterInt("Input.MouseAimCurve", &aimCurveGamma, 50, 400); /* D194(a), x100 */
configRegisterInt("Input.NaturalPitch", &naturalPitchMode, 0, 1); /* D194/D238 */
configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000);
configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); /* legacy; Wave A migrates to L/R */
configRegisterInt("Input.PadDeadzoneL", &padDeadzoneL, 0, 30000); /* Wave A: left (movement) stick */
configRegisterInt("Input.PadDeadzoneR", &padDeadzoneR, 0, 30000); /* Wave A: right (look) stick */
configRegisterInt("Input.PadLookSensX", &padLookSensX, 25, 200); /* Wave A: look horizontal sensitivity (%) */
configRegisterInt("Input.PadLookSensY", &padLookSensY, 25, 200); /* Wave A: look vertical (pitch) sensitivity (%) */
configRegisterInt("Input.PadSouthpaw", &padSouthpaw, 0, 1); /* Wave A: swap fire/grenade triggers */
configRegisterInt("Input.PadLookSmooth", &padLookSmooth, 0, 10); /* Wave A: look low-pass strength, 0-10 (0 = off) */
configRegisterInt("Input.PadTriggerPct", &padTriggerPct, 1, 99);
configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 0, 1);
/* D401: global rumble scale (0 = silent .. 1 = full), applied to every
+35 -5
View File
@@ -167,6 +167,7 @@ struct Row {
* (e.g. deadzone raw 0..30000 -> % of full stick). NULL/0 = plain int. */
const char *unit;
int dispDiv;
int dispMax; /* >0: show the int value as "N/dispMax" (e.g. 0-10 -> "5/10") */
/* D356: 1 = this row's value lives in the selected save file (per-file
* watch rows). Carries the dim "(per profile)" tag on the only section that
@@ -191,7 +192,12 @@ static struct Row rows[] = {
* watchSettingsActiveFolder(). All rows use designated initializers
* (D351 class). */
{ .key="__HdrInput", .label="INPUT", .kind=ROW_HEADER },
{ .key="Input.MouseSensitivity", .label="Mouse sensitivity", .kind=ROW_SLIDER, .step=5 }, /* calibrated UI midpoint = raw 100 */
{ .key="Input.MouseSensitivity", .label="Mouse horizontal sensitivity", .kind=ROW_SLIDER, .step=5 }, /* calibrated UI midpoint = raw 100 */
/* Wave A (v0.5.0, CONTROLLER-INPUT-PLAN item 6): the pre-existing
* Input.MouseYScale key (extra vertical/pitch sensitivity, %) finally gets
* a row so the asymmetric X/Y mouse aim is tunable without editing the ini.
* Row only -- input.c already applies it (mouseYScale). */
{ .key="Input.MouseYScale", .label="Mouse vertical sensitivity", .kind=ROW_SLIDER, .step=5 },
{ .key="Input.MouseInvertY", .label="Invert look (mouse)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
/* Input.PdMouseAim (findings D332): the Perfect Dark port's mouse-aim
* model. The port only accumulates the mouse; the game's own crosshair
@@ -210,7 +216,16 @@ static struct Row rows[] = {
* style is CENTRED (PC), where the crosshair doesn't travel. */
{ .key="Input.AimRange", .label="Aim range", .kind=ROW_ENUM, .step=1, .names=kAimRange, .hiddenIfOn="Input.AimMode" },
{ .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 },
/* Wave A (v0.5.0, docs/dev/CONTROLLER-INPUT-PLAN.md): per-stick deadzone
* (left = movement, right = look) replaces the single "Stick deadzone" row;
* the look-sensitivity / look-smoothing / southpaw knobs are right-stick
* (natural-pitch) feel options, identity at their defaults. All port-layer. */
{ .key="Input.PadDeadzoneL", .label="Deadzone (left stick)", .kind=ROW_SLIDER, .step=500 },
{ .key="Input.PadDeadzoneR", .label="Deadzone (right stick)", .kind=ROW_SLIDER, .step=500 },
{ .key="Input.PadLookSensX", .label="X axis look sensitivity", .kind=ROW_SLIDER, .step=5 },
{ .key="Input.PadLookSensY", .label="Y axis look sensitivity", .kind=ROW_SLIDER, .step=5 },
{ .key="Input.PadLookSmooth", .label="Look smoothing", .kind=ROW_SLIDER, .step=1, .dispMax=10 },
{ .key="Input.PadSouthpaw", .label="Southpaw", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
{ .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,
@@ -221,7 +236,7 @@ static struct Row rows[] = {
* 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 },
{ .key="Input.RumbleScale", .label="Vibration", .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
@@ -1040,7 +1055,11 @@ static double rowHi(const struct Row *r)
static double calibratedDefault(const struct Row *r)
{
if (!strcmp(r->key, "Input.MouseSensitivity")) return 100.0;
if (!strcmp(r->key, "Input.PadDeadzone")) return 7000.0;
if (!strcmp(r->key, "Input.MouseYScale")) return 100.0; /* Wave A item 6: native */
if (!strcmp(r->key, "Input.PadDeadzoneL")) return 7000.0; /* Wave A: = STICK_DEADZONE */
if (!strcmp(r->key, "Input.PadDeadzoneR")) return 7000.0; /* Wave A: = STICK_DEADZONE */
if (!strcmp(r->key, "Input.PadLookSensX")) return 100.0; /* Wave A: native */
if (!strcmp(r->key, "Input.PadLookSensY")) return 100.0; /* Wave A: native */
if (!strcmp(r->key, "Input.PadTriggerPct")) return 23.0;
return -1.0;
}
@@ -1264,11 +1283,17 @@ int optionsRowIsReset(int i)
static const struct { const char *key; double def; } kResetDefaults[] = {
/* INPUT (port/src/input.c initializers) */
{ "Input.MouseSensitivity", 100 }, /* static int mouseSensitivity = 100 */
{ "Input.MouseYScale", 100 }, /* = 100 (native), Wave A item 6 */
{ "Input.MouseInvertY", 0 }, /* = 0 */
{ "Input.AimMode", 0 }, /* = AIMMODE_N64 (0) */
{ "Input.AimRange", 0 }, /* = 0 (PC) */
{ "Input.PadLookInvertY", 0 }, /* = 0 */
{ "Input.PadDeadzone", 7000 },/* = STICK_DEADZONE (7000) */
{ "Input.PadDeadzoneL", 7000 },/* = STICK_DEADZONE (7000), Wave A */
{ "Input.PadDeadzoneR", 7000 },/* = STICK_DEADZONE (7000), Wave A */
{ "Input.PadLookSensX", 100 }, /* = 100 (native), Wave A */
{ "Input.PadLookSensY", 100 }, /* = 100 (native), Wave A */
{ "Input.PadLookSmooth", 0 }, /* = 0 (off), Wave A */
{ "Input.PadSouthpaw", 0 }, /* = 0 (off), Wave A */
{ "Input.PadTriggerPct", 23 }, /* = 23 */
{ "Input.RumbleScale", 0.5 }, /* = gRumbleScale (0.5f), D401 */
{ "Input.CrouchMode", 0 }, /* = 0 (hold) */
@@ -1874,6 +1899,11 @@ static void valueText(int i, char *out, int n)
snprintf(out, n, "%d/100", (int)lround(rowFractionAt(r, v) * 100.0));
return;
}
/* Wave A: integer "N/dispMax" knobs (e.g. look smoothing 0-10 -> "5/10"). */
if (r->dispMax > 0) {
snprintf(out, n, "%d/%d", (int)lround(v), r->dispMax);
return;
}
/* Distance sliders are normalized to a 0..100 UI while retaining the
* original percent-based ini format (250% = 50/100). */
if (strcmp(r->key, "Video.DrawDistance") == 0 ||