From c2938ca6a84c3269e987c6319638bd6604b32c0c Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:36:38 -0400 Subject: [PATCH] fix(D194a/D194/D238): aim curve, master sensitivity, natural-pitch scheme D194(a): bondview2.c's aim-mode turn speed formula, (stick-60)/10 clamped to 1.0, saturates at stick=70 -- the port's [61, 60+AimBand] mapping (up to stick=80) was treating an effectively-capped range as proportional, so a few px/poll already maxed out perceived sensitivity ("bang-bang"). Curve-map px/poll onto the game's real [61,70] band with a configurable gamma (Input.MouseAimCurve, default 160) for a wider low-speed zone. D238: added Input.MouseSensitivity as a master multiplier over both MouseAimSpeed and MouseTurnSpeed/HipfirePitchSpeed, so one knob scales overall feel; legacy per-axis knobs remain an independent trim. D194/D238 vertical-vs-horizontal asymmetry: GE's default 1.1/HONEY scheme only exposes hipfire pitch as a digital C-button, so the port duty-cycled it (D166) to approximate continuous motion -- structurally incapable of matching yaw's genuinely continuous analog mapping. Fix: force GE's own 1.2/SOLITARE control style (cur_player_set_control_type, an original player-selectable scheme, not new game logic) every poll, which gives pitch the same continuous analog treatment as yaw (canNaturalPitch/canNaturalTurn in bondviewProcessInput). Trade-off: SOLITARE moves movement from the analog stick to digital step buttons, so WASD forward/back now fires C-up/C-down instead of the stick (strafe was already C-button-driven in both schemes, unchanged). Gamepad stick roles swapped to match (left = digital-step movement, right = continuous look) so the global scheme switch doesn't break pad play. Escape hatch: Input.NaturalPitch=0 reverts to 1.1/HONEY + the D166 digital-pulse hack, same pattern as the existing MouseDtDecouple knob. Verified: build green, tools_pc/verify.sh bunker1 PASS (worst_cell=0). Needs a human A/B playtest for feel -- headlessly unverifiable. Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog --- port/src/input.c | 181 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 149 insertions(+), 32 deletions(-) diff --git a/port/src/input.c b/port/src/input.c index 518cd560..bc81e6bf 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -125,6 +125,32 @@ extern float getPlayer_c_screenheight(void); extern float getPlayer_c_screenleft(void); extern float getPlayer_c_screentop(void); extern float cursor_h_pos, cursor_v_pos; + +/* D194/D238 -- natural-pitch control scheme. + * + * GE's hipfire pitch is structurally digital: bondviewProcessInput's default + * (1.1/HONEY) scheme reads pitch only from C-up/C-down (see D166), so the + * port emulates continuous pitch by duty-cycling that button -- an + * approximation that can never fully match yaw's genuinely continuous, + * unbounded analog mapping, which is the root of the "vertical feels slower" + * complaint. GE's own 1.2/SOLITARE scheme (cur_player_get/set_control_type, + * src/game/options.c; dispatch in bondviewProcessInput, bondview2.c ~5192- + * 5450) gives BOTH pitch and yaw continuous analog stick control in hipfire + * (canNaturalTurn/canNaturalPitch) -- at the cost of moving movement + * (forward/back/strafe) from the analog stick onto digital step buttons + * (digitalStepForward/Back/Left/Right, fed from C-buttons/D-pad). This is an + * ORIGINAL, player-selectable GE control style (not new game logic) -- we + * select it from the port the same way the options menu would + * (cur_player_set_control_type), and remap WASD/gamepad accordingly so + * movement keeps working under it. No src/ edits; no behavior invented that + * GE didn't already support. Escape hatch: Input.NaturalPitch=0 reverts to + * the 1.1/HONEY default with the D166 digital-pitch-pulse hack. */ +extern int cur_player_get_control_type(void); +extern void cur_player_set_control_type(int type); +struct player; +extern struct player *g_CurrentPlayer; /* NULL-checked only, never dereferenced here */ +#define CONTROLLER_CONFIG_HONEY_ 0 +#define CONTROLLER_CONFIG_SOLITARE_ 1 #define MENU_POINTER_GAIN 1.5 #define TRIG_THRESHOLD (30 * 256) #define RSTICK_THRESHOLD 0x4000 @@ -140,10 +166,28 @@ extern float cursor_h_pos, cursor_v_pos; * hide that so mouse-down looks down by default; MouseInvertY flips it. */ #define MOUSE_TURN_GAIN 6.0 /* hipfire: raw px this poll -> stick-X counts */ #define MOUSE_PITCH_THRESH 1.5 /* hipfire: px/poll before a C-button fires */ -#define AIM_GAIN 4.0 /* aim mode: px/poll -> counts into the band */ -#define AIM_MOVE_THRESH 0.5 /* aim mode: px/poll before the stick moves */ +#define AIM_MOVE_THRESH 0.3 /* aim mode: px/poll before the stick moves */ #define HIP_PITCH_FULL 6.0 /* hipfire pitch: |px/poll| for a solid C hold (D166) */ +/* D194(a) -- aim-mode response curve. + * + * bondviewPlayerControlStuff (bondview2.c) turns an aim-mode stick value + * into turn speed as `(stick_x - 60) / 10.0`, clamped to 1.0 -- i.e. the + * game's own proportional band is stick in [61,70]; anything from 71 up to + * whatever AimBand allows (60+AimBand, up to 100) clamps to the exact same + * max speed as 70. The old code aimed for the port's [61, 60+AimBand] range + * (up to [61,80]) as if it were all proportional, so a config'd AimBand>10 + * bought nothing, and AIM_GAIN=4.0 reached the (effectively already-maxed) + * top of that range by ~5 px/poll -- "near bang-bang", matching the D194(a) + * complaint. Fix: curve-map px/poll onto the game's REAL proportional range + * [61,70] (independent of AimBand, which still acts as an extra ceiling for + * anyone who wants to cap below the game's own max), with a configurable + * gamma so slow motions land near the low end instead of jumping to nearly + * full speed immediately. */ +#define AIM_STICK_MIN 61 +#define AIM_STICK_GAME_MAX 70 /* bondview2.c: (stick-60)/10 saturates at stick=70 */ +#define AIM_FULL_SPEED_PX 20.0 /* px/poll (at MouseAimSpeed=100) that reaches AIM_STICK_GAME_MAX */ + /* D194(b) -- mouse sensitivity coupled to frame/poll rate. * * MOUSE_TURN_GAIN/AIM_GAIN above treat "px accumulated since the last @@ -222,6 +266,18 @@ static int mouseRawInput = 0; /* 1 = bypass OS pointer accel for aim */ static int mouseDtDecouple = 1; /* D194(b): normalize look sensitivity by real * elapsed poll time instead of raw px/poll. * Escape hatch: 0 = prior (coupled) behaviour. */ +static int mouseSensitivity = 100; /* D238: single master sensitivity, percent -- + * multiplies BOTH MouseAimSpeed and MouseTurnSpeed + * so one number tunes overall feel; the two legacy + * knobs stay as an independent per-mode trim on + * top of it (both default 100/16 => unchanged + * unless the user also touches those). */ +static int aimCurveGamma = 160; /* D194(a): aim-mode response curve exponent x100 + * (100 = linear, >100 = more low-speed control / + * less bang-bang, <100 = more twitchy). */ +static int naturalPitchMode = 1; /* D194/D238: 1 = force GE's own 1.2/SOLITARE + * control style for continuous analog pitch; + * 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. */ @@ -611,6 +667,16 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) unsigned button = 0; int sx = 0, sy = 0; + /* D194/D238: self-correcting every poll -- cheap (plain field writes, + * see options.c cur_player_set_control_type), and re-asserts itself if + * anything else ever calls the setter (menu, save load) in between. */ + if (idx == 0 && g_CurrentPlayer != NULL) { + int wantSolitare = naturalPitchMode ? CONTROLLER_CONFIG_SOLITARE_ : CONTROLLER_CONFIG_HONEY_; + if (cur_player_get_control_type() != wantSolitare) { + cur_player_set_control_type(wantSolitare); + } + } + if (idx < 0 || idx >= MAX_PADS) { if (stick_x) *stick_x = 0; if (stick_y) *stick_y = 0; @@ -654,8 +720,19 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) /* GE default control (1.1): stick Y = move fwd/back, stick X = turn, * C-left/right = sidestep, C-up/down = look. FPS layout: W/S move, * A/D strafe (C-buttons), mouse X turns (stick X), mouse Y looks. */ - if (actHeld(ks, IA_FORWARD)) sy = STICK_MAX; - if (actHeld(ks, IA_BACK)) sy = -STICK_MAX; + /* D194/D238 natural-pitch mode (GE's own 1.2/SOLITARE style) reads + * forward/back from digital C-up/C-down instead of the analog stick + * (bondview2.c: digitalStepForward/Back <- U/D_JPAD|U/D_CBUTTONS), + * because the stick's Y axis is what carries continuous analog + * pitch there instead. Strafe and turn are unchanged -- both + * schemes read them the same way. */ + if (naturalPitchMode) { + if (actHeld(ks, IA_FORWARD)) button |= GE_CONT_E; /* C-up = forward */ + if (actHeld(ks, IA_BACK)) button |= GE_CONT_D; /* C-down = back */ + } else { + if (actHeld(ks, IA_FORWARD)) sy = STICK_MAX; + if (actHeld(ks, IA_BACK)) sy = -STICK_MAX; + } if (actHeld(ks, IA_STRAFE_L)) button |= GE_CONT_C; /* strafe left */ if (actHeld(ks, IA_STRAFE_R)) button |= GE_CONT_F; /* strafe right */ if (actHeld(ks, IA_TURN_L)) sx = -STICK_MAX; /* keyboard turn */ @@ -860,34 +937,53 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) } } else if (aimHeld) { double aimEdx = edx * lookDtScale, aimDyLook = dyLook * lookDtScale; - double gx = fabs(aimEdx) * (mouseAimSpeed / 100.0) * AIM_GAIN; - double gy = fabs(aimDyLook) * (mouseAimSpeed / 100.0) * AIM_GAIN; + double aimSens = (mouseAimSpeed / 100.0) * (mouseSensitivity / 100.0); + double gamma = aimCurveGamma / 100.0; + double normX = fabs(aimEdx) * aimSens / AIM_FULL_SPEED_PX; + double normY = fabs(aimDyLook) * aimSens / AIM_FULL_SPEED_PX; + if (normX > 1.0) normX = 1.0; + if (normY > 1.0) normY = 1.0; + int ceilStick = 60 + aimBand; + if (ceilStick > AIM_STICK_GAME_MAX) ceilStick = AIM_STICK_GAME_MAX; if (fabs(aimEdx) >= AIM_MOVE_THRESH) { - int m = 61 + (int)gx; if (m > 60 + aimBand) m = 60 + aimBand; + int m = AIM_STICK_MIN + (int)(pow(normX, gamma) * (AIM_STICK_GAME_MAX - AIM_STICK_MIN)); + if (m > ceilStick) m = ceilStick; sx += (aimEdx > 0) ? m : -m; } if (fabs(aimDyLook) >= AIM_MOVE_THRESH) { - int m = 61 + (int)gy; if (m > 60 + aimBand) m = 60 + aimBand; + int m = AIM_STICK_MIN + (int)(pow(normY, gamma) * (AIM_STICK_GAME_MAX - AIM_STICK_MIN)); + if (m > ceilStick) m = ceilStick; sy += (aimDyLook > 0) ? m : -m; /* +stick_y = look down */ } } else { double hipEdx = edx * lookDtScale, hipDyLook = dyLook * lookDtScale; - sx += (int)(hipEdx * (mouseTurnSpeed / 100.0) * MOUSE_TURN_GAIN); - /* D166: hipfire pitch as C-button pulses whose frequency scales - * with mouse-Y speed -- fast mouse = solid hold, slow = sparse - * taps -- so it feels closer to the analog yaw. Aim mode (above) - * is untouched. */ - double sp = fabs(hipDyLook); - if (sp >= MOUSE_PITCH_THRESH) { - double duty = sp * (hipfirePitchSpeed / 100.0) / HIP_PITCH_FULL; - if (duty > 1.0) duty = 1.0; - hipPitchPhase += duty; - if (hipPitchPhase >= 1.0) { - hipPitchPhase -= 1.0; - button |= (hipDyLook > 0) ? GE_CONT_E : GE_CONT_D; /* E=C-up=look down */ - } + double hipSens = (mouseTurnSpeed / 100.0) * (mouseSensitivity / 100.0); + sx += (int)(hipEdx * hipSens * MOUSE_TURN_GAIN); + if (naturalPitchMode) { + /* D194/D238: SOLITARE gives hipfire pitch the same + * continuous analog stick treatment as yaw -- same + * formula as the sx line above, so X and Y are, by + * construction, symmetric. Forward/back moved to + * digital C-up/C-down above, freeing the stick's Y axis + * for this. */ + sy += (int)(hipDyLook * hipSens * MOUSE_TURN_GAIN); } else { - hipPitchPhase = 0.0; + /* D166 (legacy): hipfire pitch as C-button pulses whose + * frequency scales with mouse-Y speed -- fast mouse = + * solid hold, slow = sparse taps. Kept as the + * Input.NaturalPitch=0 escape hatch. */ + double sp = fabs(hipDyLook); + if (sp >= MOUSE_PITCH_THRESH) { + double duty = sp * (hipfirePitchSpeed / 100.0) * (mouseSensitivity / 100.0) / HIP_PITCH_FULL; + if (duty > 1.0) duty = 1.0; + hipPitchPhase += duty; + if (hipPitchPhase >= 1.0) { + hipPitchPhase -= 1.0; + button |= (hipDyLook > 0) ? GE_CONT_E : GE_CONT_D; /* E=C-up=look down */ + } + } else { + hipPitchPhase = 0.0; + } } } } @@ -912,16 +1008,34 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) int rx = SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_RIGHTX); int ry = SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_RIGHTY); - int px = scaleAxis(lx); - int py = -scaleAxis(ly); /* SDL up = negative -> N64 up = positive */ - if (px) sx = px; - if (py) sy = py; + if (naturalPitchMode) { + /* D194/D238: SOLITARE swaps stick roles -- left stick becomes + * digital-step movement (same analog-for-movement tradeoff as + * the keyboard remap above), right stick becomes continuous + * natural look (replacing its old digital-C-button emulation), + * matching how the mouse now drives look continuously too. */ + if (ly < -RSTICK_THRESHOLD) button |= GE_CONT_E; /* stick up = forward */ + if (ly > RSTICK_THRESHOLD) button |= GE_CONT_D; /* stick down = back */ + if (lx < -RSTICK_THRESHOLD) button |= GE_CONT_C; /* strafe left */ + if (lx > RSTICK_THRESHOLD) button |= GE_CONT_F; /* strafe right */ - if (padLookInvertY) ry = -ry; - if (rx > RSTICK_THRESHOLD) button |= GE_CONT_F; - if (rx < -RSTICK_THRESHOLD) button |= GE_CONT_C; - if (ry > RSTICK_THRESHOLD) button |= GE_CONT_D; - if (ry < -RSTICK_THRESHOLD) button |= GE_CONT_E; + int rxs = scaleAxis(rx); + int rys = -scaleAxis(ry); /* SDL up = negative -> N64 up = positive */ + 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 */ + if (px) sx = px; + if (py) sy = py; + + if (padLookInvertY) ry = -ry; + if (rx > RSTICK_THRESHOLD) button |= GE_CONT_F; + if (rx < -RSTICK_THRESHOLD) button |= GE_CONT_C; + if (ry > RSTICK_THRESHOLD) button |= GE_CONT_D; + if (ry < -RSTICK_THRESHOLD) button |= GE_CONT_E; + } int trigPt = padTriggerPct * 327; /* % of the 0..32767 trigger travel */ if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) > trigPt) @@ -1112,6 +1226,9 @@ PD_CONSTRUCTOR static void inputConfigInit(void) configRegisterInt("Input.MouseSmoothing", &mouseSmoothing, 0, 90); configRegisterInt("Input.MouseRawInput", &mouseRawInput, 0, 1); configRegisterInt("Input.MouseDtDecouple", &mouseDtDecouple, 0, 1); /* D194(b) */ + 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.PadTriggerPct", &padTriggerPct, 1, 99); configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 0, 1);