diff --git a/docs/dev/CONTROLLER-INPUT-PLAN.md b/docs/dev/CONTROLLER-INPUT-PLAN.md index b74f0ae3..d6e6ff7c 100644 --- a/docs/dev/CONTROLLER-INPUT-PLAN.md +++ b/docs/dev/CONTROLLER-INPUT-PLAN.md @@ -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`). diff --git a/port/src/input.c b/port/src/input.c index 7026f410..1039e058 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -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 diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 0a175d8e..c19ea872 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -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 ||