diff --git a/port/include/input.h b/port/include/input.h index d8536435..4030715b 100644 --- a/port/include/input.h +++ b/port/include/input.h @@ -69,9 +69,16 @@ short inputPadAxis(int idx, SDL_GameControllerAxis a); void inputPostWheel(int notches); /* v0.4.0 M3: re-derive the keyboard binds after an F10 change to - * Input.Layout / Input.CrouchMode (optionsoverlay rowSetCommit hook). + * Input.CrouchMode or a future binding capture (optionsoverlay hook). * Also drops the latched-crouch state. Scheduler thread only. */ -void inputLayoutApply(void); +void inputBindingsApply(void); +/* D384/D385: Primary/Secondary each accept a key or Mouse 1..5. + * Legacy extra ini tokens remain on disk but are ignored until action edit. */ +#define INPUT_BIND_SLOTS 2 +#define INPUT_BIND_MOUSE(button) (SDL_NUM_SCANCODES + (button)) +const char *inputBindingSlot(const char *key, int slot); +int inputBindingSetSlot(const char *key, int slot, int code); +int inputBindingResetKey(const char *key); /* D345(b): 1 while the 1:1 menu pointer owns cursor_h/v_pos (in a menu, * abs pointer available, mouse used within the re-assert window). Port diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h index a094f4b1..65eb77cd 100644 --- a/port/include/optionsoverlay.h +++ b/port/include/optionsoverlay.h @@ -48,6 +48,14 @@ void optionsOverlayHandleInput(void); /* Mouse-wheel notch -> move the selection (host event pump). */ void optionsOverlayScroll(int dir); +/* D383/D385 keyboard + mouse rebinding modal, shared by both options UIs. + * Host event pump hands off one key/button; menu thread commits it. */ +int optionsBindingCaptureActive(void); +int optionsBindingInputBlocked(void); /* active or captured key still held */ +int optionsBindingKeyDown(const SDL_KeyboardEvent *ev); /* 1 = consumed */ +int optionsBindingMouseDown(const SDL_MouseButtonEvent *ev); /* 1 = consumed */ +int optionsBindingCaptureTick(void); /* 1 = swallow menu nav this tick */ + /* Build the overlay's 2D display list for this frame, or return NULL when the * overlay is closed. Called by fast3d after running the game DL. */ Gfx *optionsOverlayEmit(void); @@ -57,9 +65,13 @@ Gfx *optionsOverlayEmit(void); * into the screen's sections. */ int optionsRowCount(void); int optionsRowIsHeader(int i); +int optionsRowHeaderParent(int i); /* -1 for root sections */ +int optionsRowChildHeader(int i); /* -1 unless this row opens a nested section */ int optionsRowIsShown(int i); const char *optionsRowLabel(int i); int optionsRowIsSlider(int i); +int optionsRowIsBind(int i); +void optionsRowBeginBind(int i); int optionsRowIsBondChooser(int i); /* D353: the Bond-file chooser row (front options screen only) */ /* D356: 1 when this row's value lives in the selected save file (content diff --git a/port/src/input.c b/port/src/input.c index 22d9d74e..e9c5c020 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -19,15 +19,13 @@ * GE's default "1.1" control style: analog stick = move/strafe, the four * C-buttons = aim/turn/look (DIGITAL on N64), R = aim mode, Z = fire. * - * Keyboard + mouse (controller 0): - * W/S/A/D or arrows .. analog stick (move / strafe) - * mouse motion ....... aim (mode-aware -- see MOUSE-LOOK below) - * left mouse / LCtrl . Z trigger (fire) - * right mouse / LShift R trigger (aim mode) - * Space / Z / E ...... A button (action / use) - * X / R / F ......... B button (reload / cancel) - * Q ................. L trigger - * Enter / Tab ....... Start + * Keyboard + mouse (controller 0, migrated PC defaults): + * W/S/A/D or arrows .. movement / strafe + * mouse motion ....... look (mode-aware -- see MOUSE-LOOK below) + * left mouse ......... fire; right mouse / LShift .. aim + * Q .................. next weapon; E .. dedicated use + * R .................. dedicated reload; LCtrl .. independent crouch + * Enter / Tab ........ Start * * Xbox / SDL_GameController (controller 0 merges pad 0 with kbd/mouse; * pads 1-3 -> controllers 1-3): @@ -35,12 +33,14 @@ * right stick ....... C-buttons (digital, 50% threshold -- aim) * right trigger ..... Z trigger (fire) * left trigger ...... R trigger (aim mode) - * A / X ............. A button - * B / Y ............. B button - * LB ............... L trigger - * RB ............... B button (reload) + * A ................. use/interact (in-game); accept (menus) + * X ................. reload (in-game); accept (menus) + * B ................. cycle inventory gadgets (in-game); cancel (menus) + * Y ................. next weapon (in-game); cancel (menus) + * stick clicks ...... crouch (in-game) + * LB ................ aim (alternate to LT); RB reserved (no HD assets) * D-pad ............ N64 D-pad - * Start ............ Start + * Start ............ Start; Back opens the PC options overlay * * MOUSE-LOOK (mode-aware, no src/ changes) * GE's aim model (bondview2.c bondviewProcessInput / MoveData) is @@ -86,10 +86,18 @@ * Game header pulled in through the same shim path every other compiled game * file uses; we only READ vv_theta/vv_verta/speedtheta/speedverta/aspect. */ #include "player.h" +#include "gun.h" /* native weapon flags + dedicated reload entry points */ +#include "bondinv.h" /* inventory list for the Xbox-style gadget cycle */ /* D194 spazz diagnosis: game ticks batched into the current poll (lv.h). * Read-only; declared locally to avoid pulling lv.h's wider dependency set. */ extern s32 g_ClockTimer; +extern s32 lvlGetControlsLockedFlag(void); /* same gate as bondviewProcessInput */ +extern int gameScriptedCameraActive(void); +extern bool bond_interact_object(void); /* chrprop.c: use target, no reload fallback */ +/* gun.c: native weapon-switch entry points (not exposed in gun.h). */ +extern ITEM_IDS get_next_weapon_in_cycle_for_hand(GUNHAND hand, s32 direction); +extern void gunRequestHandWeaponChange(enum GUNHAND hand, s32 nextWeapon, s32 cycleDirection); /* N64 button bits (from PR/os.h -- duplicated here to avoid pulling os.h, * whose `u8 errno;` field collides with 's macro). */ @@ -279,7 +287,8 @@ static int numControllers = 1; static int connectedMask = 0x1; /* controller 0 always present */ static SDL_GameController *pads[MAX_PADS]; -static int padShoulderPrev[MAX_PADS]; /* LB/RB edge state for weapon cycling */ +static int padBPrev[MAX_PADS]; /* B/Y edges; track through menus too */ +static int padYPrev[MAX_PADS]; static int padSelectPrev = 0; /* Select (BACK) edge: overlay toggle */ static int mouseEnabled = 1; @@ -600,7 +609,9 @@ static void inputOpenPads(void) * LEFT RIGHT CUP CDOWN CLEFT CRIGHT) pulse for INPUTSCRIPT_PULSE controller * reads from . Analog-stick tokens (SUP SDOWN SLEFT SRIGHT) are * SUSTAINED: the stick stays deflected until a later entry changes it; SNONE - * re-centres it. "Frame" = count of controller-0 reads since launch (roughly + * re-centres it. CHOLD/CREL hold/release GEPD crouch (D377); + * UHOLD/UREL and RELOADHOLD/RELOADREL exercise dedicated use/reload (D378). + * "Frame" = count of controller-0 reads since launch (roughly * 2 per rendered frame -- watch GE_INPUTLOG to calibrate). Unset env => no * effect; when set it is the ONLY controller-0 input source. */ #define INPUTSCRIPT_MAX 64 @@ -608,7 +619,9 @@ static void inputOpenPads(void) struct scriptEntry { long frame; unsigned mask; int sx, sy; int hasStick; int hasMouse, mdx, mdy; int hasHold, hold; /* D337 mouse/aim tokens */ - int hasZHold, zhold; }; /* D207: sustained fire */ + int hasZHold, zhold; /* D207: sustained fire */ + int hasCrouch, crouch; /* D377: free-crouch QA */ + int hasUse, use, hasReload, reload; }; /* D378: split-action QA */ static struct scriptEntry scriptEntries[INPUTSCRIPT_MAX]; static int scriptCount = -1; /* -1 = not parsed yet, 0 = parsed empty */ static long scriptFrame = 0; @@ -649,6 +662,12 @@ static void scriptApplyToken(struct scriptEntry *e, const char *s, int n) * independent of RHOLD so aim + fire can be held together. */ if (n == 5 && SDL_strncasecmp("ZHOLD", s, 5) == 0) { e->hasZHold = 1; e->zhold = 1; return; } if (n == 4 && SDL_strncasecmp("ZREL", s, 4) == 0) { e->hasZHold = 1; e->zhold = 0; return; } + if (n == 5 && SDL_strncasecmp("CHOLD", s, 5) == 0) { e->hasCrouch = 1; e->crouch = 1; return; } + if (n == 4 && SDL_strncasecmp("CREL", s, 4) == 0) { e->hasCrouch = 1; e->crouch = 0; return; } + if (n == 5 && SDL_strncasecmp("UHOLD", s, 5) == 0) { e->hasUse = 1; e->use = 1; return; } + if (n == 4 && SDL_strncasecmp("UREL", s, 4) == 0) { e->hasUse = 1; e->use = 0; return; } + if (n == 10 && SDL_strncasecmp("RELOADHOLD", s, 10) == 0) { e->hasReload = 1; e->reload = 1; return; } + if (n == 9 && SDL_strncasecmp("RELOADREL", s, 9) == 0) { e->hasReload = 1; e->reload = 0; return; } e->hasStick = 1; if (n == 3 && SDL_strncasecmp("SUP", s, 3) == 0) { e->sy = STICK_MAX; return; } if (n == 5 && SDL_strncasecmp("SDOWN", s, 5) == 0) { e->sy = -STICK_MAX; return; } @@ -683,6 +702,8 @@ static void scriptParse(void) e->hasMouse = e->mdx = e->mdy = 0; e->hasHold = e->hold = 0; e->hasZHold = e->zhold = 0; + e->hasCrouch = e->crouch = 0; + e->hasUse = e->use = e->hasReload = e->reload = 0; while (*p && *p != ';') { const char *tok = p; while (*p && *p != ',' && *p != ';') ++p; @@ -710,10 +731,10 @@ static int scriptIsActive(void) * Returns the scripted button mask for the current frame; advances the frame * counter (call exactly once per controller-0 read). */ /* D337: current sustained scripted mouse delta / aim hold (latest entry). */ -static int s_scriptMouseOn = 0, s_scriptMDX = 0, s_scriptMDY = 0, s_scriptHold = 0, s_scriptZHold = 0; +static int s_scriptMouseOn = 0, s_scriptMDX = 0, s_scriptMDY = 0, s_scriptHold = 0, s_scriptZHold = 0, s_scriptCrouch = 0, s_scriptUse = 0, s_scriptReload = 0; static void scriptPreMouse(void) { - long bestM = -1, bestH = -1, bestZ = -1; + long bestM = -1, bestH = -1, bestZ = -1, bestC = -1, bestU = -1, bestR = -1; for (int i = 0; i < scriptCount; ++i) { long d = scriptFrame - scriptEntries[i].frame; if (d < 0) continue; @@ -731,6 +752,18 @@ static void scriptPreMouse(void) bestZ = scriptEntries[i].frame; s_scriptZHold = scriptEntries[i].zhold; } + if (scriptEntries[i].hasCrouch && scriptEntries[i].frame > bestC) { + bestC = scriptEntries[i].frame; + s_scriptCrouch = scriptEntries[i].crouch; + } + if (scriptEntries[i].hasUse && scriptEntries[i].frame > bestU) { + bestU = scriptEntries[i].frame; + s_scriptUse = scriptEntries[i].use; + } + if (scriptEntries[i].hasReload && scriptEntries[i].frame > bestR) { + bestR = scriptEntries[i].frame; + s_scriptReload = scriptEntries[i].reload; + } } } @@ -762,9 +795,13 @@ static unsigned scriptApply(unsigned button) } static void inputRebuildBinds(void); /* D214; defined below with keyDown() */ +static void inputMigrateBinds(void); /* D380; after configLoad */ +static void inputBindingProbe(void); /* D383/D384; opt-in, restores ini state */ +static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked, + unsigned *button, int *sx, int *sy); -static int keyLayout = 0; /* v0.4.0 D374: 0 = GEPD (the default), 1 = N64 layout */ -static int crouchMode = 0; /* 0 = hold, 1 = toggle (latched B) */ +static int bindsVersion = 0; /* D380/D386: versioned migration of effective ini binds */ +static int crouchMode = 0; /* 0 = hold, 1 = toggle (latched crouch input) */ int inputInit(void) { @@ -780,10 +817,12 @@ int inputInit(void) } inputOpenPads(); + inputMigrateBinds(); /* D380: materialise effective GEPD defaults once */ inputRebuildBinds(); /* D214: parse [Bind] now that configLoad() has run */ + if (getenv("GE_BINDPROBE")) inputBindingProbe(); - sysLogPrintf(LOG_INFO, "input: layout %s, crouch mode %s (rebuild after configLoad, D214/D374)", - keyLayout ? "N64" : "GEPD (default)", crouchMode ? "toggle" : "hold"); + sysLogPrintf(LOG_INFO, "input: PC bindings, crouch mode %s", + crouchMode ? "toggle" : "hold"); /* Relative mouse mode for mouse-look. Click-to-lock: we start released * and wait for a click in the window (video.c -> inputNotifyClick). */ @@ -870,15 +909,13 @@ static int keyDown(const Uint8 *ks, SDL_Scancode sc) * (as printed by SDL_GetScancodeName: "W", "Up", "Left Ctrl", "Space", ...). * The defaults reproduce the previously-hardcoded FPS layout exactly, so a * fresh or [Bind]-less ini changes nothing. Parsed once in inputInit(), after - * configLoad(). Mouse buttons (fire = LMB, aim = RMB) stay hardwired. + * configLoad(). D385: buttons 1..5 share these slots with keyboard keys. * ---------------------------------------------------------------------- */ enum { IA_FORWARD, IA_BACK, IA_STRAFE_L, IA_STRAFE_R, IA_TURN_L, IA_TURN_R, IA_FIRE, IA_AIM, IA_ACTION, IA_CANCEL, IA_LEAN_L, IA_START, - /* v0.4.0 M3 (modern options wave): GEPD-layout actions. Both map to - * the N64 B button -- the game's own B-context logic - * (bond_interact_object) picks reload (tap, gun in hand, no - * interact target) vs crouch (held) exactly as on the N64. */ + /* Reload and crouch use the port's dedicated action/stance paths in + * playable stages; the native B/C-down buttons have other meanings. */ IA_RELOAD, IA_CROUCH, IA_COUNT }; @@ -899,20 +936,17 @@ static const struct { const char *key; const char *def; } kBindDefs[IA_COUNT] = [IA_CROUCH] = { "Input.Bind.Crouch", "" }, }; -/* v0.4.0 M3 (D371) / D374: the GEPD key-layout preset -- now the DEFAULT - * layout (docs/dev/notes/GEPORT-REFERENCE-DEEPDIVE.md section 7.1: Q = A - * button (accept/next weapon), E = B button (use/cancel/crouch/reload), +/* Legacy pre-D380 N64 defaults (kBindDefs above) are only a migration + * signature. The single PC layout is the GEPD/mouse default: Q = A + * button (accept/next weapon), E = B button (use/cancel/reload), * R = dedicated reload, Ctrl = crouch). The preset is the effective default - * for any action the user has not EXPLICITLY overridden in [Input.Bind]; - * an ini value equal to the N64-layout default (the migration pass writes - * every key on clean exit) counts as "unset", so a pre-wave ini file gets - * the preset, not its stale persisted defaults (D374: the stale - * Fire=Left Ctrl persisted default is exactly what made Ctrl fire the gun - * in the first GEPD playtest). Fire loses its Left Ctrl key (LMB fires, - * RMB aims; Ctrl is crouch); LeanLeft is unbound (lean is GEPD-removed). - * NULL entries keep the N64 default. */ + * for a one-time ini conversion only. On disk, every Bind key now stores + * its actual effective value; `NONE` explicitly unbinds even a preset key. + * Fire loses its Left Ctrl key (Mouse 1 fires; Ctrl crouches), LeanLeft unbound. + * NULL entries retain their old defaults (movement etc.). */ static const char *const kGepdPreset[IA_COUNT] = { - [IA_FIRE] = "", + [IA_FIRE] = "Mouse 1", + [IA_AIM] = "Mouse 3,Left Shift", [IA_ACTION] = "Q", [IA_CANCEL] = "E", [IA_LEAN_L] = "", @@ -921,10 +955,100 @@ static const char *const kGepdPreset[IA_COUNT] = { }; static int s_crouchLatch = 0; static int s_crouchHeldPrev = 0; +static int s_crouchApplied = 0; /* port-owned stance; not the native C-down crouch */ +static struct player *s_crouchPlayer = NULL; +static int s_useHeldPrev = 0, s_reloadHeldPrev = 0; -#define BIND_MAX_KEYS 4 +/* GEPD-style crouch operates on the game's existing stance field, without + * holding the aim button. The game interpolates ducking_height_offset from + * crouchpos (bondview2.c:7038-7074). Only undo a stance we applied; preserve + * auto-crouch in tight spaces (autocrouchpos is a separate minimum). */ +static void inputDropCrouch(void) +{ + /* The engine can take over the stance (tank, respawn, etc.) between + * polls; do not undo its new value or write into a different player. */ + if (s_crouchApplied && g_CurrentPlayer == s_crouchPlayer && + g_CurrentPlayer->crouchpos == CROUCH_SQUAT) + g_CurrentPlayer->crouchpos = CROUCH_STAND; + s_crouchApplied = 0; + s_crouchPlayer = NULL; +} + +#define BIND_MAX_KEYS INPUT_BIND_SLOTS static char g_bindStr[IA_COUNT][64]; -static SDL_Scancode g_bind[IA_COUNT][BIND_MAX_KEYS]; +static int s_legacyBindWarned[IA_COUNT]; +static int g_bind[IA_COUNT][BIND_MAX_KEYS]; + +/* Formerly LMB/RMB were unconditional, even for a NONE keyboard bind. Move + * that effective behaviour into the two visible slots on first v2 load. + * If both slots are already occupied, preserve both user keys and log the + * mouse default that could not fit; never keep an invisible active third. */ +static void migrateMouseDefault(int a, int button) +{ + char old[sizeof(g_bindStr[0])]; + snprintf(old, sizeof(old), "%s", g_bindStr[a]); + char name[16]; + snprintf(name, sizeof(name), "Mouse %d", button); + if (strstr(old, name)) return; + const char *first = old; + const char *comma = strchr(old, ','); + if (comma && strchr(comma + 1, ',')) { + sysLogPrintf(LOG_WARNING, "input: %s has extra legacy keys; cannot add %s in two slots", + kBindDefs[a].key, name); + return; + } + if (comma && strncmp(old, "NONE,", 5) != 0 && + SDL_strcasecmp(comma + 1, "NONE") != 0) { + sysLogPrintf(LOG_WARNING, "input: %s already has two keys; rebind a slot to restore %s", + kBindDefs[a].key, name); + return; + } + if (SDL_strcasecmp(first, "NONE") == 0 || !*old) first = ""; + else if (comma && strncmp(old, "NONE,", 5) == 0) first = comma + 1; + else if (comma) { + /* A trailing NONE slot is empty; preserve the first key. */ + char *end = strchr(old, ','); + *end = 0; + first = old; + } + if (strlen(name) + (*first ? 1 + strlen(first) : 0) >= sizeof(g_bindStr[a])) { + sysLogPrintf(LOG_WARNING, "input: %s too long to add %s; unchanged", + kBindDefs[a].key, name); + return; + } + strcpy(g_bindStr[a], name); + if (*first) { + strcat(g_bindStr[a], ","); + strcat(g_bindStr[a], first); + } +} + +static void inputMigrateBinds(void) +{ + if (bindsVersion >= 3) return; + if (bindsVersion == 2) { + /* D386: D385 called SDL button 2 "right" in its default, but SDL + * button 2 is middle; button 3 is right. Only fix the exact old + * default, never overwrite a user's custom Aim assignment. */ + if (strcmp(g_bindStr[IA_AIM], "Mouse 2,Left Shift") == 0) + snprintf(g_bindStr[IA_AIM], sizeof(g_bindStr[IA_AIM]), + "Mouse 3,Left Shift"); + } + if (bindsVersion == 0) { + for (int a = 0; a < IA_COUNT; a++) { + const char *preset = kGepdPreset[a]; + if (preset && strcmp(g_bindStr[a], kBindDefs[a].def) == 0) + snprintf(g_bindStr[a], sizeof(g_bindStr[a]), "%s", preset); + } + } + if (bindsVersion < 2) { + migrateMouseDefault(IA_FIRE, SDL_BUTTON_LEFT); + migrateMouseDefault(IA_AIM, SDL_BUTTON_RIGHT); + } + bindsVersion = 3; + configSave(); + sysLogPrintf(LOG_INFO, "input: migrated binds to visible PC keyboard/mouse slots"); +} static void inputRebuildBinds(void) { @@ -932,93 +1056,297 @@ static void inputRebuildBinds(void) for (int k = 0; k < BIND_MAX_KEYS; k++) { g_bind[a][k] = SDL_SCANCODE_UNKNOWN; } - /* v0.4.0 D374: resolve the effective string for this action. - * Precedence: an EXPLICIT per-key [Input.Bind] value (non-empty and - * different from the N64-layout default, or an explicit unbind of a - * bound-by-default action) wins over everything. Otherwise the - * layout preset is the effective default: GEPD (Input.Layout=0, the - * shipped default) or the N64-layout kBindDefs defaults (Layout=1, - * byte-identical to the pre-wave binds). A persisted ini value that - * merely equals the N64 default counts as "unset" -- the config - * migration writes every key on clean exit, so without this rule a - * pre-wave ini would mask the preset with its stale defaults. */ - const char *src = NULL; - if (g_bindStr[a][0] != 0) { - if (strcmp(g_bindStr[a], kBindDefs[a].def) != 0) - src = g_bindStr[a]; /* explicit user bind */ - } else if (kBindDefs[a].def[0] != 0) { - src = g_bindStr[a]; /* explicit unbind of a default-bound action */ - } - const char *def; - if (src) { - def = src; - } else if (keyLayout == 1) { - def = kBindDefs[a].def; /* N64 layout */ - } else { - const char *preset = kGepdPreset[a]; - def = preset ? preset : kBindDefs[a].def; + /* D380: ini values ARE the effective bindings, not hidden defaults. + * NONE is an explicit unbind (PD-port convention). */ + const char *def = SDL_strcasecmp(g_bindStr[a], "NONE") == 0 ? "" : g_bindStr[a]; + /* D384: do not silently activate invisible 3rd/4th keys in the + * two-slot UI. Preserve the old ini text until the user edits this + * particular action, at which point only its visible pair is saved. */ + const char *second = strchr(def, ','); + if (second && strchr(second + 1, ',') && !s_legacyBindWarned[a]) { + s_legacyBindWarned[a] = 1; + sysLogPrintf(LOG_WARNING, "input: %s has legacy extra keys (ignored; edit this action to remove)", + kBindDefs[a].key); } char buf[64]; strncpy(buf, def, sizeof(buf) - 1); buf[sizeof(buf) - 1] = 0; - /* Warn only for an explicitly-provided string (g_bindStr) that - * yields no valid keys; intentionally-empty preset/default strings - * (GEPD Fire, unbound Reload/Crouch in the N64 layout) are silent. */ + /* An empty bind or NONE is intentionally unbound, not a typo. */ int hadInput = (def == g_bindStr[a] && g_bindStr[a][0] != 0); - int n = 0; + int n = 0, valid = 0; for (char *tok = strtok(buf, ","); tok && n < BIND_MAX_KEYS; tok = strtok(NULL, ",")) { while (*tok == ' ' || *tok == '\t') tok++; char *end = tok + strlen(tok); while (end > tok && (end[-1] == ' ' || end[-1] == '\t')) *--end = 0; if (!*tok) continue; - SDL_Scancode sc = SDL_GetScancodeFromName(tok); + /* Explicit NONE preserves an empty slot before later keys. */ + if (SDL_strcasecmp(tok, "NONE") == 0) { n++; continue; } + int sc; + if (!SDL_strncasecmp(tok, "Mouse ", 6) && + tok[6] >= '1' && tok[6] <= '5' && tok[7] == 0) { + sc = INPUT_BIND_MOUSE(tok[6] - '0'); + } else if (!strncmp(tok, "SC:", 3)) { + char *endnum; + long id = strtol(tok + 3, &endnum, 10); + sc = *endnum == 0 && id > 0 && id < SDL_NUM_SCANCODES + ? (SDL_Scancode)id : SDL_SCANCODE_UNKNOWN; + } else { + sc = SDL_GetScancodeFromName(tok); + } if (sc == SDL_SCANCODE_UNKNOWN) { - sysLogPrintf(LOG_WARNING, "input: %s: unknown key name '%s'", + sysLogPrintf(LOG_WARNING, "input: %s: unknown binding '%s'", kBindDefs[a].key, tok); continue; } g_bind[a][n++] = sc; + valid++; } /* v0.4.0 M3: an intentionally-empty string (a preset or default * that leaves an action unbound, e.g. GEPD Fire) is silent -- * only a non-empty string that produced no valid keys warns. */ - if (n == 0 && hadInput) { + if (valid == 0 && hadInput) { sysLogPrintf(LOG_WARNING, "input: %s has no valid keys; action unbound", kBindDefs[a].key); } } } -/* v0.4.0 M3: re-derive the binds after an F10 change to Input.Layout / - * Input.CrouchMode (optionsoverlay rowSetCommit hook, scheduler thread). +/* Re-derive binds after an F10 input option or future key capture + * (optionsoverlay rowSetCommit hook, scheduler thread). * g_bind is read per-frame by the game thread's actHeld(): a mid-poll * rebuild can at worst drop or add one scancode for one frame (the * D214 startup-rebuild is the same thread-pairing, just at boot). - * Also drops the crouch latch so a latched crouch never carries B into - * a menu (where B = back/cancel). */ -void inputLayoutApply(void) + * Also drops the crouch latch on a layout/mode change. */ +void inputBindingsApply(void) { s_crouchLatch = 0; s_crouchHeldPrev = 0; + inputDropCrouch(); inputRebuildBinds(); - sysLogPrintf(LOG_INFO, "input: key layout %s, crouch %s applied", - keyLayout ? "N64" : "GEPD (default)", crouchMode ? "toggle" : "hold"); + sysLogPrintf(LOG_INFO, "input: PC bindings, crouch %s applied", + crouchMode ? "toggle" : "hold"); +} + +/* D384/D385: the UI edits two honest keyboard/mouse slots. Legacy extra ini tokens + * remain unmodified on disk until this action is explicitly edited; they + * cannot fire invisibly while only two slots are displayed. */ +static int bindingAction(const char *key) +{ + if (!key) return -1; + for (int a = 0; a < IA_COUNT; a++) + if (!strcmp(kBindDefs[a].key, key)) return a; + return -1; +} + +const char *inputBindingSlot(const char *key, int slot) +{ + int a = bindingAction(key); + if (a < 0 || slot < 0 || slot >= BIND_MAX_KEYS || + g_bind[a][slot] == SDL_SCANCODE_UNKNOWN) return "NONE"; + if (g_bind[a][slot] > SDL_NUM_SCANCODES && + g_bind[a][slot] <= INPUT_BIND_MOUSE(5)) { + static const char *names[] = { "Mouse 1", "Mouse 2", "Mouse 3", "Mouse 4", "Mouse 5" }; + return names[g_bind[a][slot] - INPUT_BIND_MOUSE(1)]; + } + const char *name = SDL_GetScancodeName((SDL_Scancode)g_bind[a][slot]); + if (name && *name && !strchr(name, ',')) return name; + /* Rare SDL names contain commas (e.g. keypad comma), which the ini + * treats as separators. The stable numeric fallback remains bindable. */ + static _Thread_local char fallback[16]; + snprintf(fallback, sizeof(fallback), "SC:%d", (int)g_bind[a][slot]); + return fallback; +} + +int inputBindingSetSlot(const char *key, int slot, int sc) +{ + int a = bindingAction(key); + if (a < 0 || slot < 0 || slot >= BIND_MAX_KEYS || + sc < SDL_SCANCODE_UNKNOWN || sc == SDL_NUM_SCANCODES || + sc > INPUT_BIND_MOUSE(5)) return 0; + int slots[BIND_MAX_KEYS]; + memcpy(slots, g_bind[a], sizeof(slots)); + if (sc != SDL_SCANCODE_UNKNOWN) { + for (int i = 0; i < BIND_MAX_KEYS; i++) + if (i != slot && slots[i] == sc) slots[i] = SDL_SCANCODE_UNKNOWN; + } + slots[slot] = sc; + char buf[sizeof(g_bindStr[0])] = {0}; + int used = 0; + for (int i = 0; i < BIND_MAX_KEYS; i++) { + if (slots[i] == SDL_SCANCODE_UNKNOWN) { + /* Only serialize holes before a later bound slot. */ + int later = 0; + for (int j = i + 1; j < BIND_MAX_KEYS; j++) + if (slots[j] != SDL_SCANCODE_UNKNOWN) later = 1; + if (!later) continue; + if (used + 5 + (used != 0) >= (int)sizeof(buf)) return 0; + if (used) buf[used++] = ','; + memcpy(buf + used, "NONE", 4); + used += 4; + buf[used] = 0; + continue; + } + const char *name; + char fallback[32]; + if (slots[i] > SDL_NUM_SCANCODES) { + snprintf(fallback, sizeof(fallback), "Mouse %d", slots[i] - SDL_NUM_SCANCODES); + name = fallback; + } else name = SDL_GetScancodeName((SDL_Scancode)slots[i]); + if (!name || !*name || strchr(name, ',')) { + snprintf(fallback, sizeof(fallback), "SC:%d", (int)slots[i]); + name = fallback; + } + int len = (int)strlen(name); + if (used + len + (used != 0) >= (int)sizeof(buf)) return 0; + if (used) buf[used++] = ','; + memcpy(buf + used, name, len); + used += len; + buf[used] = 0; + } + snprintf(g_bindStr[a], sizeof(g_bindStr[a]), "%s", used ? buf : "NONE"); + inputBindingsApply(); + return 1; +} + +int inputBindingResetKey(const char *key) +{ + int a = bindingAction(key); + if (a < 0) return 0; + const char *def = kGepdPreset[a] ? kGepdPreset[a] : kBindDefs[a].def; + snprintf(g_bindStr[a], sizeof(g_bindStr[a]), "%s", def); + return 1; +} + +/* Isolated boot probe: validate multi-slot persistence without writing ini. + * The entire original binding is restored before gameplay begins. */ +static void inputBindingProbe(void) +{ + const char *key = "Input.Bind.Crouch"; + int a = bindingAction(key), ok = a >= 0; + char saved[sizeof(g_bindStr[0])]; + if (!ok) return; + memcpy(saved, g_bindStr[a], sizeof(saved)); + ok &= inputBindingSetSlot(key, 1, SDL_SCANCODE_KP_COMMA); + ok &= g_bind[a][1] == SDL_SCANCODE_KP_COMMA; + ok &= inputBindingSetSlot(key, 0, SDL_SCANCODE_UNKNOWN); + ok &= g_bind[a][0] == SDL_SCANCODE_UNKNOWN && g_bind[a][1] == SDL_SCANCODE_KP_COMMA; + ok &= strstr(g_bindStr[a], "NONE,") == g_bindStr[a]; + ok &= inputBindingSetSlot(key, 1, SDL_SCANCODE_UNKNOWN); + ok &= strcmp(g_bindStr[a], "NONE") == 0; + memcpy(g_bindStr[a], saved, sizeof(saved)); + inputBindingsApply(); + /* Mouse buttons are real, serializable bindings; duplicate assignment + * clears the other visible slot rather than leaving a hidden action. */ + key = "Input.Bind.Fire"; + a = bindingAction(key); + memcpy(saved, g_bindStr[a], sizeof(saved)); + ok &= inputBindingSetSlot(key, 0, INPUT_BIND_MOUSE(5)); + ok &= strcmp(inputBindingSlot(key, 0), "Mouse 5") == 0; + ok &= inputBindingSetSlot(key, 1, INPUT_BIND_MOUSE(5)); + ok &= g_bind[a][0] == 0 && g_bind[a][1] == INPUT_BIND_MOUSE(5); + ok &= strcmp(g_bindStr[a], "NONE,Mouse 5") == 0; + memcpy(g_bindStr[a], saved, sizeof(saved)); + inputBindingsApply(); + Uint8 keys[SDL_NUM_SCANCODES] = {0}; + unsigned menuButton = 0; + int menuX = 0, menuY = 0; + keys[SDL_SCANCODE_A] = keys[SDL_SCANCODE_D] = keys[SDL_SCANCODE_C] = 1; + pcOptionsKeyboardPad(keys, 0, 0, &menuButton, &menuX, &menuY); + ok &= !menuButton && !menuX && !menuY; /* gameplay keys cannot steer editor */ + keys[SDL_SCANCODE_LEFT] = keys[SDL_SCANCODE_RETURN] = 1; + pcOptionsKeyboardPad(keys, 0, 0, &menuButton, &menuX, &menuY); + ok &= menuX == -STICK_MAX && !menuY && menuButton == GE_CONT_A; + pcOptionsKeyboardPad(keys, 0, 1, &menuButton, &menuX, &menuY); + ok &= !menuButton && !menuX && !menuY; + sysLogPrintf(ok ? LOG_INFO : LOG_ERROR, + "GE_BINDPROBE: %s (2 slots, mouse 1-5, sparse/comma, A/D/C ignored, arrows/Enter, modal isolation; ini restored)", + ok ? "PASS" : "FAIL"); } static int actHeld(const Uint8 *ks, int act) { + /* Front-end mouse clicks stay fixed UI controls. Click-to-lock while + * playing cannot fire a newly rebound button until the grab succeeds. */ + Uint32 mb = mouseEnabled && mouseGrabbed && + current_menu == GE_MENU_RUN_STAGE && !optionsOverlayIsOpen() + ? SDL_GetMouseState(NULL, NULL) : 0; for (int k = 0; k < BIND_MAX_KEYS; k++) { - if (keyDown(ks, g_bind[act][k])) return 1; + int code = g_bind[act][k]; + if (code > SDL_NUM_SCANCODES && code <= INPUT_BIND_MOUSE(5)) { + if (mb & SDL_BUTTON(code - SDL_NUM_SCANCODES)) return 1; + } else if (code > SDL_SCANCODE_UNKNOWN && code < SDL_NUM_SCANCODES && + keyDown(ks, (SDL_Scancode)code)) return 1; } return 0; } +/* PC settings use immutable keyboard navigation, independent of gameplay + * bindings (which are being edited here). Do not consume A/D/C as controls. */ +static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked, + unsigned *button, int *sx, int *sy) +{ + *button = 0; + *sx = *sy = 0; + if (blocked) return; + if (ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8]) *sy = STICK_MAX; + if (ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2]) *sy = -STICK_MAX; + if (ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]) *sx = -STICK_MAX; + if (ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6]) *sx = STICK_MAX; + if (ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]) *button |= GE_CONT_A; + if (ks[SDL_SCANCODE_ESCAPE]) *button |= GE_CONT_B; + if (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) *button |= GE_CONT_A; + if (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) *button |= GE_CONT_B; +} + +/* Direct use/reload and modern face buttons share the same game-state gate. + * In watch, pause, tank, front end or scripted cameras use native menu bits. */ +static int inputCanUseGameplayActions(int menuMode) +{ + return !menuMode && g_CurrentPlayer && !g_CurrentPlayer->bonddead && + g_CurrentPlayer->outside_watch_menu && !g_CurrentPlayer->pause_state && + !g_CurrentPlayer->mpmenuon && !g_PlayerIsInTank && + !lvlGetControlsLockedFlag() && !gameScriptedCameraActive(); +} + +/* D394: the Xbox B gadget cycle has no N64 button equivalent (native A + * deliberately skips IDs >= ITEM_BOMBCASE). Select the next owned, + * equippable inventory gadget using the game's normal weapon-switch path; + * never conjure a missing item or touch the N64 game logic. The first press + * from a gun selects the lowest gadget; the next press wraps the list. */ +static void inputCycleGadget(void) +{ + struct player *p = g_CurrentPlayer; + if (!p || !p->ptr_inventory_first_in_cycle) return; + + int current = get_next_weapon_in_cycle_for_hand(GUNRIGHT, 1); + int first = -1, next = -1; + InvItem *head = p->ptr_inventory_first_in_cycle; + InvItem *item = head; + do { + if (item->type == INV_ITEM_WEAPON) { + int id = item->type_inv_item.type_weap.weapon; + if (id >= ITEM_BOMBCASE && id <= ITEM_WATCHMAGNETATTRACT) { + if (first < 0 || id < first) first = id; + if (id > current && (next < 0 || id < next)) next = id; + } + } + item = item->next; + } while (item && item != head); + + if (next < 0) next = first; + if (next >= 0 && next != current) { + gunRequestHandWeaponChange(GUNRIGHT, next, 1); + gunRequestHandWeaponChange(GUNLEFT, ITEM_UNARMED, 1); + } +} + /* Fill button mask + stick for controller idx. Returns the 16-bit mask. */ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) { unsigned button = 0; int sx = 0, sy = 0; + int crouchNow = 0; /* D194/D238: self-correcting every poll -- cheap (plain field writes, * see options.c cur_player_set_control_type), and re-asserts itself if @@ -1041,6 +1369,16 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) * overlay instead. Mirrors the WI-1 "cursor free in a stage -> withhold * input" pattern. Controllers 1-3 are untouched. */ if (idx == 0 && optionsOverlayIsOpen()) { + const Uint8 *overlayKs = SDL_GetKeyboardState(NULL); + s_useHeldPrev = actHeld(overlayKs, IA_CANCEL) || + (pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_A)); + s_reloadHeldPrev = actHeld(overlayKs, IA_RELOAD) || + (pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_X)); + s_crouchLatch = 0; + s_crouchHeldPrev = 0; + inputDropCrouch(); + padBPrev[0] = pads[0] ? SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_B) : 0; + padYPrev[0] = pads[0] ? SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_Y) : 0; /* Select closes the overlay. padSelectPrev is tracked on this path * and the open path below alike, so a button held across the * transition cannot immediately re-toggle it. */ @@ -1138,9 +1476,9 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) if (actHeld(ks, IA_TURN_R)) sx = STICK_MAX; if (sx) s_aimDevMouse = 0; /* D337: keyboard stick turn is the active aim device */ - if ((mb & SDL_BUTTON(SDL_BUTTON_LEFT)) || actHeld(ks, IA_FIRE)) + if (actHeld(ks, IA_FIRE)) button |= GE_CONT_G; - /* aimButton = the physical RMB / Aim bind (emits the game's R); + /* aimButton = the Aim bind (emits the game's R); * aimHeld = "the game is in aim mode" for routing the mouse. D337: the * game's aim buttons in 1.1/1.2 are L|R (bondview2.c aimButtons), so Q * ("LeanLeft", sends L) is a second aim button -- it used to leave the @@ -1148,8 +1486,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) * found. Routing also follows the game's own insightaimmode, which * covers the Aim Control "Toggle" option and gamepad aim. The R emit * stays tied to the physical button so Toggle still sees edges. */ - int aimButton = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) != 0 || - actHeld(ks, IA_AIM); + int aimButton = actHeld(ks, IA_AIM); int aimHeld = aimButton || actHeld(ks, IA_LEAN_L) || (g_CurrentPlayer != NULL && g_CurrentPlayer->insightaimmode); int aimRisingEdgeAim = aimHeld && !s_aimHeldPrev; @@ -1209,27 +1546,33 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) button |= GE_CONT_A | GE_CONT_G; wheelBack--; } - if (actHeld(ks, IA_CANCEL)) /* D145: Escape is in the default Cancel bind */ + /* D378/D393: GE's B tap calls bond_interact_object(), then reloads + * only when no target was found (lv.c:796). E and pad A use the same + * interaction with NO fallback; R and pad X only reload. Their + * combined edges prevent a held input from repeatedly using a door. + * In menus/watch/tank the pad keeps native accept/cancel buttons. */ + int padUse = pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_A); + int padReload = pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_X); + int useNow = scriptIsActive() ? s_scriptUse : (actHeld(ks, IA_CANCEL) || padUse); + int reloadNow = scriptIsActive() ? s_scriptReload : (actHeld(ks, IA_RELOAD) || padReload); + int playable = inputCanUseGameplayActions(menuMode); + if (playable) { + if (useNow && !s_useHeldPrev) { + bool empty = bond_interact_object(); + if (configGetInputLog()) sysLogPrintf(LOG_NOTE, "GE_INPUTLOG dedicated use (no target=%d)", empty); + } + } else if (scriptIsActive() ? s_scriptUse : actHeld(ks, IA_CANCEL)) { + /* A on the pad is still native accept outside playable stages. */ button |= GE_CONT_B; - /* v0.4.0 M3: reload (B tap, same as any B press). Unbound in the - * N64 layout (no-op out of the box). */ - if (actHeld(ks, IA_RELOAD)) - button |= GE_CONT_B; - /* D375: crouch = C-DOWN while in aim (insight) mode -- that is - * what bondview2.c reads (crouchDown = insightaimmode && D-bit, - * lines 5318/5579); the B button is NOT the crouch input. S - * (IA_BACK, also C-down) crouches while aiming exactly as on - * N64. Hold, or latched toggle; a latch is never carried into - * a menu. */ - int crouchNow = actHeld(ks, IA_CROUCH); - if (crouchMode == 1) { - if (crouchNow && !s_crouchHeldPrev) s_crouchLatch ^= 1; - if (menuMode) s_crouchLatch = 0; /* never carry C-down into a menu */ - if (s_crouchLatch) button |= GE_CONT_D; - s_crouchHeldPrev = crouchNow; - } else if (crouchNow) { - button |= GE_CONT_D; } + if (reloadNow && !s_reloadHeldPrev && playable) { + attempt_reload_item_in_hand(GUNRIGHT); + attempt_reload_item_in_hand(GUNLEFT); + if (configGetInputLog()) sysLogPrintf(LOG_NOTE, "GE_INPUTLOG dedicated reload (no use)"); + } + s_useHeldPrev = useNow; + s_reloadHeldPrev = reloadNow; + crouchNow = actHeld(ks, IA_CROUCH); if (actHeld(ks, IA_LEAN_L)) button |= GE_CONT_L; if (actHeld(ks, IA_START)) @@ -1506,6 +1849,14 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) if (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) button |= GE_CONT_A; if (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) button |= GE_CONT_B; } + /* D384: the PC settings editor MUST NOT navigate using live game + * binds. Otherwise capturing A/D/C instantly turns that held key + * into C-left/right or stick input, cycling the selected bind slot + * and chasing the menu cursor (user repro, bind log). Physical arrow + * keys and Enter/Escape are fixed menu controls; mouse/pad still + * work. Scripted QA input remains the sole source when active. */ + if (current_menu == MENU_PC_OPTIONS && !scriptIsActive()) + pcOptionsKeyboardPad(ks, mb, optionsBindingInputBlocked(), &button, &sx, &sy); menuPrevActive = menuMode; s_menuPointerLive = (menuMode && mouseEnabled && !mouseGrabbed) ? 1 : 0; @@ -1576,31 +1927,29 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > trigPt) button |= GE_CONT_R; - /* Modern dual-stick layout (Xbox re-release style; the Steam Deck - * target). A/X = action/use/reload (the game's context-sensitive A - * line), B/Y = crouch, and LB/RB rising edges cycle weapons. - * In-game cycling is an A edge (forward) or A+Z held on the same tick - * (backward -- bondview2.c weaponForwardOffset/weaponBackOffset, the - * same trick the mouse wheel uses above); emit for exactly one poll - * so holding RB cannot latch invButtons and block firing. */ - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_A) || - SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_X)) - button |= GE_CONT_A; - if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_B) || - SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_Y)) - button |= GE_CONT_B; - { - int lbNow = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER); - int rbNow = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER); - int *prev = &padShoulderPrev[idx]; - /* Track edge state in menus too: a shoulder held across the - * menu->game transition must not fire a cycle on entry. */ - if (!padMenuMode) { - if (rbNow && !(*prev & 1)) button |= GE_CONT_A; /* next weapon */ - if (lbNow && !(*prev & 2)) button |= GE_CONT_A | GE_CONT_G; /* prev weapon */ - } - *prev = (rbNow ? 1 : 0) | (lbNow ? 2 : 0); + /* Xbox 1.1 Jinx-style gameplay: A=use, X=reload (handled by the + * keyboard action gate), B=gadgets, Y=weapons, stick clicks=crouch, + * LB=aim. RB's remaster HD toggle is unavailable in the N64 port. + * Menus/watch/tank retain native accept/cancel mappings. */ + int padA = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_A); + int padX = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_X); + int padB = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_B); + int padY = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_Y); + int facePlayable = idx == 0 && inputCanUseGameplayActions(padMenuMode); + if (facePlayable) { + if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSTICK) || + SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_RIGHTSTICK)) + crouchNow = 1; + if (padB && !padBPrev[idx]) inputCycleGadget(); + if (padY && !padYPrev[idx]) button |= GE_CONT_A; + } else { + if (padA || padX) button |= GE_CONT_A; + if (padB || padY) button |= GE_CONT_B; } + padBPrev[idx] = padB; + padYPrev[idx] = padY; + if (facePlayable && SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) + button |= GE_CONT_R; if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_START)) button |= GE_CONT_START; if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_DPAD_UP)) @@ -1623,6 +1972,51 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) } } + if (idx == 0 && scriptIsActive()) crouchNow = s_scriptCrouch; + + /* The GEPD preset's crouch key is independent of aim. Do not turn it + * into C-down: outside aim C-down moves backwards in 1.2, and inside + * aim it also zooms on weapons that disable crouch. This is a PC input + * adapter writing the same stance field the native game adjusts, not + * a change to the N64 game logic. */ + if (idx == 0) { + int stage = current_menu == GE_MENU_RUN_STAGE || current_menu == GE_MENU_INVALID; + if (!stage) s_crouchLatch = 0; + if (crouchMode == 1) { + if (stage && crouchNow && !s_crouchHeldPrev) s_crouchLatch ^= 1; + s_crouchHeldPrev = crouchNow; + crouchNow = s_crouchLatch; + } + struct player *p = g_CurrentPlayer; + int canCrouch = stage && p && !p->bonddead && p->outside_watch_menu && + !p->pause_state && !p->mpmenuon && !g_PlayerIsInTank && + !lvlGetControlsLockedFlag() && + !gameScriptedCameraActive() && + !bondwalkItemCheckBitflags(getCurrentPlayerWeaponId(GUNRIGHT), + WEAPONSTATBITFLAG_DISABLE_CROUCH); + if (canCrouch && crouchNow) { + /* In aim mode the native crouchUp branch runs every tick without + * C-down, undoing the port stance by one step. Feed the native + * axis ONLY while aiming; hipfire still crouches by stance alone. */ + if (p->insightaimmode || (button & (GE_CONT_R | GE_CONT_L))) + button |= GE_CONT_D; + p->crouchpos = CROUCH_SQUAT; + if (!s_crouchApplied && configGetInputLog()) + sysLogPrintf(LOG_NOTE, "GE_INPUTLOG free crouch applied (aim=%d)", p->insightaimmode); + s_crouchApplied = 1; + s_crouchPlayer = p; + } else if (!(canCrouch && (button & GE_CONT_D) && p->insightaimmode)) { + /* If native C-down is still held in aim, let the game retain its + * own crouch on this tick rather than force a stand. */ + if (s_crouchApplied && configGetInputLog()) + sysLogPrintf(LOG_NOTE, "GE_INPUTLOG free crouch released"); + inputDropCrouch(); + } else { + s_crouchApplied = 0; + s_crouchPlayer = NULL; + } + } + if (idx == 0 && scriptIsActive()) { /* D263: apply BEFORE the stick is written out -- scripted stick * tokens (SUP/SDOWN/SLEFT/SRIGHT) used to be discarded. */ @@ -2008,7 +2402,6 @@ static int hipDirectCompute(double dxPx, double dyLook) * directly, bypassing that dispatch entirely. Reuses the same * scripted-camera test D243's clamps use (bondview2.c, gameScriptedCameraActive, * formerly d243mCutsceneActive -- renamed since this isn't D243-specific). */ - extern int gameScriptedCameraActive(void); if (gameScriptedCameraActive()) return 0; @@ -2102,16 +2495,16 @@ PD_CONSTRUCTOR static void inputConfigInit(void) configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); configRegisterInt("Input.PadTriggerPct", &padTriggerPct, 1, 99); configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 0, 1); - /* v0.4.0 M3 (modern options wave): key-layout preset + crouch bind - * mode; both default to the N64-original FPS layout / hold. */ - configRegisterInt("Input.Layout", &keyLayout, 0, 1); + /* D380: single PC layout. Old Input.Layout is ignored and removed on + * configSave; BindingsVersion marks the one-time stale-default migration. */ + configRegisterInt("Input.BindingsVersion", &bindsVersion, 0, 3); configRegisterInt("Input.CrouchMode", &crouchMode, 0, 1); - /* D214: keyboard rebinding. Seed each buffer with its default so the knob - * is visible/editable in a fresh ge007.ini; configLoad() overwrites any the - * user set, then inputInit() calls inputRebuildBinds(). */ + /* Seed fresh ini files with the REAL PC binding strings. Older ini + * values are migrated at inputInit after configLoad, exactly once. */ for (int a = 0; a < IA_COUNT; a++) { - strncpy(g_bindStr[a], kBindDefs[a].def, sizeof(g_bindStr[a]) - 1); + const char *def = kGepdPreset[a] ? kGepdPreset[a] : kBindDefs[a].def; + strncpy(g_bindStr[a], def, sizeof(g_bindStr[a]) - 1); configRegisterString(kBindDefs[a].key, g_bindStr[a], sizeof(g_bindStr[a])); } } diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 032a5de9..741d5506 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -80,7 +80,7 @@ extern s16 viGetY(void); /* ------------------------------------------------------------------------ */ enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA, ROW_RES, ROW_ACTION, ROW_FPSCAP, - ROW_HEADER, ROW_BOND_FILE /* explicit chooser (front end only) */ }; + ROW_HEADER, ROW_BOND_FILE, ROW_BIND /* key / mouse-button capture */ }; /* D346: wording pass -- Nightdive/Turok + PD-port conventions: title-case * On/Off, no all-caps value strings. Display-only; config stores 0/1 either way. */ @@ -94,15 +94,13 @@ static const int kMsaaSeq[] = { 1, 2, 4, 8 }; /* v0.4.0 modern options wave: value names for the new rows (they land * in the functional sections -- Turok standard, D356 -- not a bucket). */ static const char *const kOnOffRev[] = { "On", "Off", NULL }; /* 0 = On */ -/* M2: named crosshair tints; index 0 = the N64 white (no tint applied). */ +/* D379: the authored N64 sprite is red. Original is the identity path; + * White at index 7 uses the same alpha-mask combiner as other true hues. */ static const char *const kCrosshairColor[] = { - "White", "Green", "Red", "Blue", "Yellow", "Cyan", "Magenta", - NULL, + "Original (red)", "Green", "Red", "Blue", "Yellow", "Cyan", "Magenta", + "White", "Custom", NULL, }; -/* D374: key-layout preset -- 0 = the GEPD layout (the default: it is the - * natural mouse/FPS layout and the N64 never had a keyboard), 1 = the - * N64-era keyboard binds (byte-identical to the pre-wave defaults) */ -static const char *const kKeyLayout[] = { "Default", "N64", NULL }; +static const char *const kCrosshairStyle[] = { "Original", "Thin cross", NULL }; /* D186: the sim's own tick pacemaker is hardcoded to the console's native VI * rate (60Hz NTSC / 50Hz PAL, port/src/libultra.c) -- Video.FpsCap can only * throttle down from there, never past it, and throttling it below 30 @@ -152,6 +150,10 @@ struct Row { * Resolved to hidePtr at init. */ const char *hiddenIfOn; int *hidePtr; + /* Show RGB sliders only while the colour selector is set to Custom. */ + const char *shownWhen; + int shownValue; + int *showPtr; /* D346: value-text decoration (display only). unit is appended to integer * slider values ("%"/"x"); dispDiv>0 divides the raw value before display @@ -161,14 +163,15 @@ struct Row { /* 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 - * mixes scopes (GAMEPLAY); on headers the flag is recomputed at init to + * mixes scopes (GAMEPLAY/HUD); on headers the flag is recomputed at init to * mean "this section contains per-file rows" (drives the "(File N)" * title annotation in both UIs). */ int saveScoped; + int bindSlot; /* D383/D385: selected key/mouse slot, 0..1 */ }; static struct Row rows[] = { - /* D356: Turok-style functional sections in Turok's order (INPUT, + /* D356/D388: Turok-style functional categories in order (INPUT, * GAMEPLAY, GRAPHICS, AUDIO, VIDEO; docs/dev/D356-SETTINGS-REGROUP-PLAN.md). * The D353 "BOND FILE" section -- the one named after WHERE a value is * saved -- is gone: its surviving rows split into GAMEPLAY (the per-file @@ -208,9 +211,35 @@ static struct Row rows[] = { * control bindings, so they live in INPUT -- no provenance bucket. * D374: the GEPD layout is the default (a mouse FPS layout is the * natural default; the N64 had no keyboard); crouch defaults to hold. */ - { .key="Input.Layout", .label="Key layout", .kind=ROW_ENUM, .step=1, .names=kKeyLayout }, { .key="Input.CrouchMode", .label="Crouch mode", .kind=ROW_ENUM, .step=1, .names=kHold }, { .key="__ResetInput", .label="Reset to defaults", .kind=ROW_ACTION }, + { .key="__OpenBindings", .label="Bindings...", .kind=ROW_ACTION }, + + /* D388: nested Input -> Bindings -> two short pages, each with a reset. + * Mouse buttons 1-5 share the visible slots with keys; pads stay fixed. */ + { .key="__HdrBindings", .label="BINDINGS", .kind=ROW_HEADER }, + { .key="__OpenMoveKeys", .label="Movement...", .kind=ROW_ACTION }, + { .key="__OpenActionKeys", .label="Actions...", .kind=ROW_ACTION }, + + { .key="__HdrMoveKeys", .label="MOVEMENT", .kind=ROW_HEADER }, + { .key="Input.Bind.Forward", .label="Move forward", .kind=ROW_BIND }, + { .key="Input.Bind.Back", .label="Move backward", .kind=ROW_BIND }, + { .key="Input.Bind.StrafeLeft", .label="Strafe left", .kind=ROW_BIND }, + { .key="Input.Bind.StrafeRight", .label="Strafe right", .kind=ROW_BIND }, + { .key="Input.Bind.TurnLeft", .label="Turn left", .kind=ROW_BIND }, + { .key="Input.Bind.TurnRight", .label="Turn right", .kind=ROW_BIND }, + { .key="__ResetMoveKeys", .label="Reset to defaults", .kind=ROW_ACTION }, + + { .key="__HdrActionKeys", .label="ACTIONS", .kind=ROW_HEADER }, + { .key="Input.Bind.Fire", .label="Fire key", .kind=ROW_BIND }, + { .key="Input.Bind.Aim", .label="Aim key", .kind=ROW_BIND }, + { .key="Input.Bind.Action", .label="Action / next weapon", .kind=ROW_BIND }, + { .key="Input.Bind.Cancel", .label="Use / back", .kind=ROW_BIND }, + { .key="Input.Bind.LeanLeft", .label="Lean left", .kind=ROW_BIND }, + { .key="Input.Bind.Start", .label="Pause / start", .kind=ROW_BIND }, + { .key="Input.Bind.Reload", .label="Reload", .kind=ROW_BIND }, + { .key="Input.Bind.Crouch", .label="Crouch", .kind=ROW_BIND }, + { .key="__ResetActionKeys", .label="Reset to defaults", .kind=ROW_ACTION }, { .key="__HdrGameplay", .label="GAMEPLAY", .kind=ROW_HEADER }, /* D356 exposure filter (menu surface only, the D181/D216/D304 pattern -- @@ -219,21 +248,16 @@ static struct Row rows[] = { * (stacking both is a double-negation trap); * Bond.AimControl collides with Input.AimMode (two rows fighting over * the same N64-vs-PC aim model). - * The four surviving per-file toggles move here from the D353 BOND FILE - * section and carry .saveScoped (dim "(per profile)" tag, the only mixed-scope - * section). */ + * Two gameplay/profile toggles stay here; HUD-specific save rows move + * under Gameplay -> HUD. Both pages carry their own reset. */ /* { .key="Bond.Look", .label="Look up/down (watch; stacks)", .kind=ROW_TOGGLE, .names=kReverse, .found=1, .uiMax=1, .cfgMax=1 }, */ { .key="Bond.AutoAim", .label="Auto-aim", .kind=ROW_TOGGLE, .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, /* { .key="Bond.AimControl", .label="Aim control", .kind=ROW_TOGGLE, .names=kHold, .found=1, .uiMax=1, .cfgMax=1 }, */ - { .key="Bond.Sight", .label="Sight on screen", .kind=ROW_TOGGLE, - .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, { .key="Bond.LookAhead", .label="Look ahead", .kind=ROW_TOGGLE, .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, - { .key="Bond.Ammo", .label="Ammo on screen", .kind=ROW_TOGGLE, - .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, /* D216/Game.SkipIntro: user report (v0.2.1 testing) that it breaks audio * -- pulled from the menu until root-caused. Not exposed to players; the * config var + lv.c hook stay in place (dead unless an existing ini has @@ -248,16 +272,32 @@ static struct Row rows[] = { * corrected by D356. Consumed at startup by main.c -- applies from the * next launch. */ { .key="Game.AllUnlocked", .label="All unlocked", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, - /* D226: scales the ammo counter, pickup / status messages and dialogue - * about their screen anchors. 100% = original, nothing emitted. */ - { .key="Game.HudScale", .label="HUD scale", .kind=ROW_SLIDER, .step=5, .unit="%" }, { .key="__ResetGameplay", .label="Reset to defaults", .kind=ROW_ACTION }, + { .key="__OpenHud", .label="HUD...", .kind=ROW_ACTION }, /* D293: only quit path used to be the OS window-close / Alt+F4 -- no * discoverable in-game way to exit, a real gap on Deck/controller-only * setups. Not config-backed (like __Resolution); activating it exits * the same way video.c's SDL_QUIT/Alt+F4 handlers already do. */ { .key="__QuitToDesktop", .label="Quit to desktop", .kind=ROW_ACTION }, + /* D388: Gameplay -> HUD collects both profile HUD visibility and global + * crosshair/scale controls. Its reset honours each row's own scope. */ + { .key="__HdrHUD", .label="HUD", .kind=ROW_HEADER }, + { .key="Bond.Sight", .label="Sight on screen", .kind=ROW_TOGGLE, + .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + { .key="Bond.Ammo", .label="Ammo on screen", .kind=ROW_TOGGLE, + .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 }, + /* D226: scales the ammo counter, pickup/status text and dialogue. */ + { .key="Game.HudScale", .label="HUD scale", .kind=ROW_SLIDER, .step=5, .unit="%" }, + { .key="Video.CrosshairHide", .label="Show crosshair", .kind=ROW_TOGGLE, .step=1, .names=kOnOffRev }, + { .key="Video.CrosshairColor", .label="Crosshair colour", .kind=ROW_ENUM, .step=1, .names=kCrosshairColor }, + { .key="Video.CrosshairRed", .label="Red", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, + { .key="Video.CrosshairGreen", .label="Green", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, + { .key="Video.CrosshairBlue", .label="Blue", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 }, + { .key="Video.CrosshairSize", .label="Crosshair size", .kind=ROW_SLIDER, .step=5, .unit="%" }, + { .key="Video.CrosshairStyle", .label="Crosshair style", .kind=ROW_ENUM, .step=1, .names=kCrosshairStyle }, + { .key="__ResetHUD", .label="Reset to defaults", .kind=ROW_ACTION }, + { .key="__HdrGraphics", .label="GRAPHICS", .kind=ROW_HEADER }, { .key="Video.MSAA", .label="Anti-aliasing", .kind=ROW_MSAA, .restart=1 }, { .key="Video.TextureFilter", .label="Texture filter", .kind=ROW_ENUM, .step=1, .names=kTexFilter }, @@ -273,12 +313,6 @@ static struct Row rows[] = { * Legacy AutoFov ini keys remain supported but no longer hide sliders. */ { .key="Video.DrawDistance", .label="Draw distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=400 }, { .key="Video.LodDistance", .label="LOD distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=400 }, - /* v0.4.0 M2 (D373, rule-2 sign-off PENDING): in-game crosshair - * on/off + tint (gunfire.c gunDrawSight, #ifdef PORT). 0/white = - * the N64 always-on crosshair (default); off and the tints are - * opt-in. */ - { .key="Video.CrosshairHide", .label="Crosshair", .kind=ROW_TOGGLE, .step=1, .names=kOnOffRev }, - { .key="Video.CrosshairColor", .label="Crosshair colour", .kind=ROW_ENUM, .step=1, .names=kCrosshairColor }, /* D304: the per-mode aim/turn-sensitivity sliders and the "Link" toggle * that papered over their decoupling risk were pulled from the menu * (user feedback after the D304 widening); the ONE master @@ -315,9 +349,6 @@ static struct Row rows[] = { { .key="__Resolution", .label="Resolution", .kind=ROW_RES }, { .key="Video.VSync", .label="VSync", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, { .key="Video.FpsCap", .label="Frame rate cap", .kind=ROW_FPSCAP }, - /* v0.4.0 M5 (D372): one-row low-end preset -- writes FpsCap 30 + - * MSAA x1 (restart). OFF restores the compiled defaults (60/2). */ - { .key="Video.LowEndMode", .label="Low-end mode (restart)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, { .key="Video.DisplayFPS", .label="Show FPS", .kind=ROW_TOGGLE, .step=1, .names=kOnOff }, { .key="__ResetVideo", .label="Reset to defaults", .kind=ROW_ACTION }, /* D356: the D353 BOND FILE header + "Edit file" chooser row are retired -- @@ -328,6 +359,38 @@ static struct Row rows[] = { }; #define NUM_ROWS ((int)(sizeof(rows) / sizeof(rows[0]))) +static int rowIndexByKey(const char *key) +{ + for (int i = 0; i < NUM_ROWS; i++) + if (strcmp(rows[i].key, key) == 0) return i; + return -1; +} + +/* D388: one shared page tree, used by both the front and F10 surfaces. + * ROW_HEADER still delimits each section's reset range; link actions only + * navigate and must never be treated as settings or the Quit action. */ +int optionsRowHeaderParent(int i) +{ + if (i < 0 || i >= NUM_ROWS || rows[i].kind != ROW_HEADER) return -1; + const char *key = rows[i].key; + if (!strcmp(key, "__HdrBindings")) return rowIndexByKey("__HdrInput"); + if (!strcmp(key, "__HdrMoveKeys") || !strcmp(key, "__HdrActionKeys")) + return rowIndexByKey("__HdrBindings"); + if (!strcmp(key, "__HdrHUD")) return rowIndexByKey("__HdrGameplay"); + return -1; +} + +int optionsRowChildHeader(int i) +{ + if (i < 0 || i >= NUM_ROWS) return -1; + const char *key = rows[i].key; + if (!strcmp(key, "__OpenBindings")) return rowIndexByKey("__HdrBindings"); + if (!strcmp(key, "__OpenMoveKeys")) return rowIndexByKey("__HdrMoveKeys"); + if (!strcmp(key, "__OpenActionKeys")) return rowIndexByKey("__HdrActionKeys"); + if (!strcmp(key, "__OpenHud")) return rowIndexByKey("__HdrHUD"); + return -1; +} + static int s_inited = 0; static volatile int s_open = 0; static int s_sel = 0; /* selection, index into s_visIdx (visible list) */ @@ -342,6 +405,127 @@ static int s_visN = 0; static int s_scroll = 0; static SDL_atomic_t s_wheelPending; /* D314: host-thread wheel notches */ +/* D383: SDL_KEYDOWN belongs to videoPumpEvents (host), while both menu + * controllers consume it on their own threads. Only the one-shot scancode + * crosses threads; binding strings/row state remain menu-thread owned. */ +static SDL_atomic_t s_bindCaptureActive; +static SDL_atomic_t s_bindPadCancelHold; /* swallow B until released after cancel */ +static SDL_SpinLock s_bindCaptureLock; +static int s_bindPending = -1, s_bindCaptureRow = -1, s_bindError = 0; +static SDL_atomic_t s_bindHold; /* captured key/mouse button, shared with pad poll */ + +static void bindingBegin(struct Row *r) +{ + if (!r || !r->found || r->kind != ROW_BIND) return; + SDL_AtomicLock(&s_bindCaptureLock); + s_bindPending = -1; + SDL_AtomicUnlock(&s_bindCaptureLock); + s_bindCaptureRow = (int)(r - rows); + s_bindError = 0; + SDL_AtomicSet(&s_bindPadCancelHold, 0); + SDL_AtomicSet(&s_bindHold, SDL_SCANCODE_UNKNOWN); + SDL_AtomicSet(&s_bindCaptureActive, 1); +} + +int optionsBindingCaptureActive(void) +{ + return SDL_AtomicGet(&s_bindCaptureActive) != 0; +} + +int optionsBindingInputBlocked(void) +{ + if (optionsBindingCaptureActive()) return 1; + int code = SDL_AtomicGet(&s_bindHold); + if (code > SDL_NUM_SCANCODES && code <= INPUT_BIND_MOUSE(5)) + return (SDL_GetMouseState(NULL, NULL) & SDL_BUTTON(code - SDL_NUM_SCANCODES)) != 0; + return code > SDL_SCANCODE_UNKNOWN && code < SDL_NUM_SCANCODES && + SDL_GetKeyboardState(NULL)[code]; +} + +/* Called before F10/ESC/screenshot handling on the SDL event host thread. + * Do not bind reserved system shortcuts or gamepad events. */ +int optionsBindingKeyDown(const SDL_KeyboardEvent *ev) +{ + if (!optionsBindingCaptureActive()) return 0; + if ((ev->keysym.sym == SDLK_F4 && (ev->keysym.mod & KMOD_ALT))) return 0; + if (ev->repeat) return 1; + SDL_Scancode sc = ev->keysym.scancode; + if (sc == SDL_SCANCODE_F10 || sc == SDL_SCANCODE_F12 || + sc == SDL_SCANCODE_UNKNOWN) return 1; + SDL_AtomicLock(&s_bindCaptureLock); + if (s_bindPending < 0) s_bindPending = (int)sc; + SDL_AtomicUnlock(&s_bindCaptureLock); + return 1; +} + +/* Keep menu clicking and click-to-lock out of a mouse-binding capture. */ +int optionsBindingMouseDown(const SDL_MouseButtonEvent *ev) +{ + if (!optionsBindingCaptureActive()) return 0; + if (ev->button >= 1 && ev->button <= 5) { + SDL_AtomicLock(&s_bindCaptureLock); + if (s_bindPending < 0) s_bindPending = INPUT_BIND_MOUSE(ev->button); + SDL_AtomicUnlock(&s_bindCaptureLock); + } + return 1; +} + +/* The capturing screen calls this once per input tick. Return 1 while the + * modal is active and through the captured key's release, so it cannot + * simultaneously trigger menu navigation or a second action. */ +int optionsBindingCaptureTick(void) +{ + /* D395: capture only accepts keyboard/mouse. A controller B must be + * able to cancel a modal entered with a keyboard/mouse; swallow B until + * release so that the same press cannot also back out of the page. */ + if (SDL_AtomicGet(&s_bindPadCancelHold)) { + if (!inputPadButton(0, SDL_CONTROLLER_BUTTON_B)) + SDL_AtomicSet(&s_bindPadCancelHold, 0); + return 1; + } + if (optionsBindingCaptureActive()) { + if (inputPadButton(0, SDL_CONTROLLER_BUTTON_B)) { + SDL_AtomicLock(&s_bindCaptureLock); + s_bindPending = -1; + SDL_AtomicUnlock(&s_bindCaptureLock); + SDL_AtomicSet(&s_bindCaptureActive, 0); + SDL_AtomicSet(&s_bindPadCancelHold, 1); + return 1; + } + SDL_AtomicLock(&s_bindCaptureLock); + int sc = s_bindPending; + s_bindPending = -1; + SDL_AtomicUnlock(&s_bindCaptureLock); + if (sc >= 0) { + struct Row *r = &rows[s_bindCaptureRow]; + if (sc != SDL_SCANCODE_ESCAPE) { + int chosen = sc == SDL_SCANCODE_DELETE + ? SDL_SCANCODE_UNKNOWN : sc; + if (!inputBindingSetSlot(r->key, r->bindSlot, chosen)) { + s_bindError = 1; /* oversized name: leave ini unchanged */ + } else { + configSave(); + sysLogPrintf(LOG_INFO, "bind capture: %s slot %d = %s", + r->key, r->bindSlot + 1, + inputBindingSlot(r->key, r->bindSlot)); + } + } + SDL_AtomicSet(&s_bindHold, sc); + SDL_AtomicSet(&s_bindCaptureActive, 0); + } + return 1; + } + int hold = SDL_AtomicGet(&s_bindHold); + if (hold != SDL_SCANCODE_UNKNOWN) { + const Uint8 *ks = SDL_GetKeyboardState(NULL); + if (hold > SDL_NUM_SCANCODES && hold <= INPUT_BIND_MOUSE(5)) { + if (SDL_GetMouseState(NULL, NULL) & SDL_BUTTON(hold - SDL_NUM_SCANCODES)) return 1; + } else if (hold > SDL_SCANCODE_UNKNOWN && hold < SDL_NUM_SCANCODES && ks[hold]) return 1; + SDL_AtomicSet(&s_bindHold, SDL_SCANCODE_UNKNOWN); + } + return 0; +} + /* D213: optional on-screen FPS readout (PD parity: Video.DisplayFPS). * Drawn top-right whenever enabled, independent of the F10 panel. */ static int s_showFps = 0; @@ -379,6 +563,8 @@ static void fpsTick(void) * values are right-aligned to the panel edge to survive the wide font. */ #define OV_TOP 8 #define OV_LINE 16 +#define OV_FOOTER_HEIGHT 38 /* two help lines, including gap below content */ +#define OV_FOOTER_LINE 13 /* Dark 2D watch-style overlay; the in-stage watch's 3D model is not loaded * here. All rendering AND hit-testing take coordinates from this layout; * 320x240 stages and 440x330 front screens share the same geometry. */ @@ -402,15 +588,15 @@ static struct OvLayout overlayLayout(void) o.valueR = o.right - 12; o.barX1 = o.right - 65; o.barX0 = o.barX1 - 74; - o.maxRows = (h - 6 - o.contentY - 24) / OV_LINE; + o.maxRows = (h - 6 - o.contentY - OV_FOOTER_HEIGHT) / OV_LINE; if (o.maxRows < 4) o.maxRows = 4; /* Root has five categories. A section's title/Back sits in its own * pinned band; only the content rows consume this scrollable area. */ int contentN = s_visN - (s_section >= 0 ? 1 : 0); int drawn = contentN < o.maxRows ? contentN : o.maxRows; - o.bottom = o.contentY + drawn * OV_LINE + 24; + o.bottom = o.contentY + drawn * OV_LINE + OV_FOOTER_HEIGHT; if (o.bottom > h - 6) o.bottom = h - 6; - o.footerY = o.bottom - 15; + o.footerY = o.bottom - 29; return o; } @@ -425,23 +611,51 @@ static void sliderBarSpan(s32 *x0, s32 *x1) } /* Visible position or -1. Match the rendered [s_scroll, pLast] window - * exactly (D304: never hit the invisible row below Quit) and centre the - * hit band on the drawn row (D316). The section title has its own pinned - * hit band and is always the selectable Back row, even while scrolling. */ -static int overlayRowAtY(double oy) + * exactly (D304: never hit the invisible row below Quit). D391: the text + * baseline is BELOW rowY: measured glyph pixels occupy rowY+3..+9 at + * 320x240, so the former symmetric [rowY-8,rowY+8) hit band assigned + * the bottom of a visible label to the NEXT row. Leave a small dead zone + * between rows instead of guessing which adjacent item was intended. */ +static int overlayRowAtY(double ox, double oy) { struct OvLayout o = overlayLayout(); - if (s_section >= 0 && oy >= o.sectionY - OV_LINE / 2 && - oy < o.sectionY + OV_LINE / 2) return 0; /* pinned Back/title */ + if (ox < o.left + 3 || ox >= o.right - 3) return -1; + if (s_section >= 0 && oy >= o.sectionY - 2 && + oy < o.sectionY + 12) return 0; /* pinned Back/title */ int pLast = s_scroll + o.maxRows - 1; if (pLast >= s_visN) pLast = s_visN - 1; for (int p = s_scroll; p <= pLast; p++) { - double top = o.contentY + (p - s_scroll) * OV_LINE - OV_LINE / 2; - if (oy >= top && oy < top + OV_LINE) return p; + double y = o.contentY + (p - s_scroll) * OV_LINE; + if (oy >= y - 2 && oy < y + 12) return p; } return -1; } +/* Opt-in geometry check on both the first and last scroll windows. The + * visible glyph ink occupies approximately y+3..y+9, so both ends must + * select the drawn row, with a dead zone after it and no off-card hit. */ +static int overlayHitCheck(void) +{ + struct OvLayout o = overlayLayout(); + int first = s_section >= 0 ? 1 : 0; + int lastScroll = s_visN - first > o.maxRows ? s_visN - o.maxRows : first; + int saved = s_scroll, bad = 0; + for (int scroll = first; scroll <= lastScroll; scroll += lastScroll - first ? lastScroll - first : 1) { + s_scroll = scroll; + int last = scroll + o.maxRows < s_visN ? scroll + o.maxRows : s_visN; + for (int p = scroll; p < last; p++) { + double y = o.contentY + (p - scroll) * OV_LINE; + if (overlayRowAtY(o.labelX, y + 3) != p || + overlayRowAtY(o.labelX, y + 9) != p || + overlayRowAtY(o.labelX, y + 12) != -1 || + overlayRowAtY(o.left - 1, y + 9) != -1) bad++; + } + if (first && overlayRowAtY(o.labelX, o.sectionY + 9) != 0) bad++; + } + s_scroll = saved; + return bad; +} + /* Rebuild the visible-row list and keep the selection in range. */ /* Move through the current category list or section (including its Back row). */ static int overlayStepSel(int pos, int dir) @@ -454,16 +668,18 @@ static void overlayUpdateVisible(void) { s_visN = 0; for (int i = 0; i < NUM_ROWS; i++) { - /* Category list: only headers. Section: its header serves as Back, - * followed by its settings. The file chooser belongs to the front. */ + /* Root: only top-level headers. Nested sections are opened through + * link rows in Input/Bindings and Gameplay. Section header = Back. */ if (s_section < 0) { - if (rows[i].kind == ROW_HEADER) s_visIdx[s_visN++] = i; + if (rows[i].kind == ROW_HEADER && optionsRowHeaderParent(i) < 0) + s_visIdx[s_visN++] = i; } else if (i == s_section) { s_visIdx[s_visN++] = i; /* Back */ } else if (i > s_section) { if (rows[i].kind == ROW_HEADER) break; if (rows[i].kind != ROW_BOND_FILE && - !(rows[i].hidePtr && *rows[i].hidePtr)) + !(rows[i].hidePtr && *rows[i].hidePtr) && + (!rows[i].showPtr || *rows[i].showPtr == rows[i].shownValue)) s_visIdx[s_visN++] = i; } } @@ -510,6 +726,41 @@ static void overlayUpdateScroll(void) if (s_scroll > s_visN - maxV) s_scroll = s_visN - maxV; } +static void overlayOpenHeader(int hdr) +{ + s_section = hdr; + s_sel = 1; + s_scroll = 0; + optionsResetClear(); + overlayUpdateVisible(); + overlayUpdateScroll(); + sysLogPrintf(LOG_INFO, "optionsoverlay: opened %s", rows[hdr].label); +} + +static void overlayBackOne(void) +{ + int child = s_section; + if (child < 0) return; + s_section = optionsRowHeaderParent(child); + s_sel = 0; + s_scroll = 0; + optionsResetClear(); + overlayUpdateVisible(); + /* Re-select the link that opened this child (or the root category). + * This works for both keyboard and pointer navigation. */ + for (int p = 0; p < s_visN; p++) { + int i = s_visIdx[p]; + if ((s_section < 0 && i == child) || + (s_section >= 0 && optionsRowChildHeader(i) == child)) { + s_sel = p; + break; + } + } + overlayUpdateScroll(); + sysLogPrintf(LOG_INFO, "optionsoverlay: back from %s to %s", + rows[child].label, s_section < 0 ? "categories" : rows[s_section].label); +} + /* The close box brackets the title row at the panel's right edge. */ static int overlayInCloseBox(double ox, double oy) { @@ -567,14 +818,14 @@ static void overlayInit(void) /* Resolve the hidden-while-on sources (manual % rows vs their auto * toggles), then build the initial visible list. */ for (int i = 0; i < NUM_ROWS; i++) { - if (!rows[i].hiddenIfOn) { - continue; - } for (int j = 0; j < NUM_ROWS; j++) { - if (strcmp(rows[j].key, rows[i].hiddenIfOn) == 0 && rows[j].found) { + if (!rows[j].found) continue; + if (rows[i].hiddenIfOn && + strcmp(rows[j].key, rows[i].hiddenIfOn) == 0) rows[i].hidePtr = rows[j].ptr; - break; - } + if (rows[i].shownWhen && + strcmp(rows[j].key, rows[i].shownWhen) == 0) + rows[i].showPtr = rows[j].ptr; } } /* D356: on a header row, saveScoped means "this section contains @@ -591,6 +842,67 @@ static void overlayInit(void) } overlayUpdateVisible(); + /* D388: opt-in in-memory walk of the real F10 page tree. No settings, + * binds or profile bytes are written; restore the UI state afterward. */ + if (getenv("GE_OPTIONTREEPROBE")) { + int oldSection = s_section, oldSel = s_sel, oldScroll = s_scroll; + int bad = 0; + static const char *const roots[] = { + "__HdrInput", "__HdrGameplay", "__HdrGraphics", "__HdrAudio", "__HdrVideo" + }; + s_section = -1; s_sel = 0; s_scroll = 0; + overlayUpdateVisible(); + bad += overlayHitCheck(); + if (s_visN != 5) bad++; + for (int p = 0; p < 5 && p < s_visN; p++) + if (strcmp(rows[s_visIdx[p]].key, roots[p])) bad++; + static const struct { const char *page, *childA, *childB; int maxRows; } pages[] = { + { "__HdrInput", "__OpenBindings", NULL, 14 }, + { "__HdrBindings", "__OpenMoveKeys", "__OpenActionKeys", 14 }, + { "__HdrMoveKeys", "Input.Bind.Forward", "__ResetMoveKeys", 14 }, + { "__HdrActionKeys", "Input.Bind.Fire", "__ResetActionKeys", 14 }, + { "__HdrGameplay", "__OpenHud", "__ResetGameplay", 14 }, + { "__HdrHUD", "Video.CrosshairColor", "__ResetHUD", 14 }, + { "__HdrVideo", "Video.FpsCap", "__ResetVideo", 14 }, + }; + for (size_t k = 0; k < sizeof(pages) / sizeof(pages[0]); k++) { + int hdr = rowIndexByKey(pages[k].page), gotA = 0, gotB = 0; + overlayOpenHeader(hdr); + bad += overlayHitCheck(); + if (s_visN - 1 > pages[k].maxRows) bad++; + for (int p = 1; p < s_visN; p++) { + gotA |= !strcmp(rows[s_visIdx[p]].key, pages[k].childA); + if (pages[k].childB) + gotB |= !strcmp(rows[s_visIdx[p]].key, pages[k].childB); + if (!strcmp(rows[s_visIdx[p]].key, "Video.LowEndMode")) bad++; + } + if (!gotA || (pages[k].childB && !gotB)) bad++; + overlayBackOne(); + int parent = optionsRowHeaderParent(hdr); + if (s_section != parent) bad++; + } + int hud = rowIndexByKey("__HdrHUD"); + overlayOpenHeader(hud); + static const char *const hudKeys[] = { + "Bond.Sight", "Bond.Ammo", "Game.HudScale" + }; + for (size_t k = 0; k < sizeof(hudKeys) / sizeof(hudKeys[0]); k++) { + int seen = 0; + for (int p = 1; p < s_visN; p++) + seen |= !strcmp(rows[s_visIdx[p]].key, hudKeys[k]); + if (!seen) bad++; + } + if (optionsRowChildHeader(rowIndexByKey("__OpenHud")) != hud || + optionsRowHeaderParent(hud) != rowIndexByKey("__HdrGameplay") || + optionsRowHeaderParent(rowIndexByKey("__HdrMoveKeys")) != + rowIndexByKey("__HdrBindings")) bad++; + s_section = oldSection; s_sel = oldSel; s_scroll = oldScroll; + overlayUpdateVisible(); + sysLogPrintf(bad ? LOG_ERROR : LOG_INFO, + "GE_OPTIONTREEPROBE: %s (5 roots, nested links/back, two bind pages, HUD, row caps, no M5)", + bad ? "FAIL" : "PASS"); + } + /* Build the windowed-resolution preset list: presets that fit the desktop, * plus the current window size snapped to the nearest surviving entry. */ { @@ -619,22 +931,62 @@ static void overlayInit(void) const char *e = getenv("GE_OPTIONSOVERLAY"); if (e && atoi(e) != 0) { s_open = 1; - /* Diagnostic screenshots: 1 = category root, 2..6 = the five - * sections. This is only a boot probe; normal F10 always opens - * at the category root. */ + /* Diagnostic screenshots: 1 = root, 2..6 = five categories; + * GE_OPTIONSOVERLAY_SECTION=__HdrHUD etc. targets nested pages. */ int page = atoi(e) - 2; - if (page >= 0 && page < 5) { + if (page >= 0) { int found = 0; for (int i = 0; i < NUM_ROWS; i++) { - if (rows[i].kind == ROW_HEADER && found++ == page) { - s_section = i; - s_sel = 1; + if (rows[i].kind == ROW_HEADER && optionsRowHeaderParent(i) < 0 && + found++ == page) { + overlayOpenHeader(i); break; } } - overlayUpdateVisible(); } + /* Isolated screenshot/probe of a nested page, never a menu mode. */ + const char *section = getenv("GE_OPTIONSOVERLAY_SECTION"); + int nested = section ? rowIndexByKey(section) : -1; + if (nested >= 0 && rows[nested].kind == ROW_HEADER) + overlayOpenHeader(nested); + /* Screenshot-only selection state for checking the pinned Back + * row against a normal selected setting on the same page. */ + if (s_section >= 0 && getenv("GE_OPTIONSOVERLAY_SELECTBACK")) + s_sel = 0; sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY=%s)", e); + /* D383 isolated host->menu smoke probe. Only with explicit opt-in; + * test data dir must be private because capture persists the key. */ + const char *probe = getenv("GE_BINDCAPTUREPROBE"); + if (probe && *probe) { + SDL_Scancode sc = !SDL_strcasecmp(probe, "ESC") ? SDL_SCANCODE_ESCAPE : + !SDL_strcasecmp(probe, "DEL") ? SDL_SCANCODE_DELETE : + SDL_SCANCODE_F2; + const char *bindKey = getenv("GE_BINDCAPTUREACTION"); + if (!bindKey || !*bindKey) bindKey = "Input.Bind.Fire"; + for (int i = 0; i < NUM_ROWS; i++) { + if (!strcmp(rows[i].key, bindKey) && rows[i].kind == ROW_BIND) { + bindingBegin(&rows[i]); + sysLogPrintf(LOG_INFO, "GE_BINDCAPTUREPROBE: %s found=%d active=%d", + bindKey, rows[i].found, optionsBindingCaptureActive()); + SDL_Event ev = {0}; + if (!SDL_strcasecmp(probe, "MOUSE5")) { + ev.button.type = SDL_MOUSEBUTTONDOWN; + ev.button.state = SDL_PRESSED; + ev.button.button = SDL_BUTTON_X2; + } else { + ev.key.type = SDL_KEYDOWN; + ev.key.state = SDL_PRESSED; + ev.key.repeat = 0; + ev.key.keysym.scancode = sc; + ev.key.keysym.sym = SDL_GetKeyFromScancode(sc); + ev.key.keysym.mod = KMOD_NONE; + } + int sent = SDL_PushEvent(&ev); + sysLogPrintf(LOG_INFO, "GE_BINDCAPTUREPROBE: queued=%d (%s)", sent, SDL_GetError()); + break; + } + } + } } } @@ -698,12 +1050,8 @@ static double rowValueAtFraction(const struct Row *r, double f) static struct Row *rowByKey(const char *key) { - for (int i = 0; i < NUM_ROWS; i++) { - if (strcmp(rows[i].key, key) == 0) { - return &rows[i]; - } - } - return NULL; + int i = rowIndexByKey(key); + return i < 0 ? NULL : &rows[i]; } static int s_linkDepth = 0; /* re-entrancy guard for the sens link below */ @@ -742,38 +1090,18 @@ static void rowSetCommit(struct Row *r, double v, int commit) default: return; } - /* Live-apply the video knobs that need a fast3d/SDL call. Everything else - * is read straight off the pointer by its owner every frame/poll. */ + /* D382: only the specific live GL/SDL knob that changed needs a render- + * thread apply. Reticle, draw/LOD, widescreen and HUD settings are read + * directly; rebuilding textures for every RGB/FOV detent caused hitches. */ if (strcmp(r->key, "Video.Fullscreen") == 0) { videoRequestFullscreen((int)lround(v)); - } else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart && - strcmp(r->key, "Video.DrawDistance") != 0 && - strcmp(r->key, "Video.LodDistance") != 0) { - /* These two distance knobs are read directly by the render path. - * Reapplying VSync/texture/GL image state every drag detent only - * stalls frames; they do not need videoStartFrame's config pass. */ - videoRequestLiveConfig(); + } else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) { + videoRequestLiveConfigForKey(r->key); } - /* v0.4.0 M3: key-layout preset / crouch mode -- re-derive the binds - * (inputRebuildBinds via inputLayoutApply; also drops the crouch latch). - * Safe at scheduler-thread commit time; a mid-poll rebuild can only - * drop/add one scancode for one frame (D214 thread-pairing). */ - if (strcmp(r->key, "Input.Layout") == 0 || - strcmp(r->key, "Input.CrouchMode") == 0) { - inputLayoutApply(); - } - - /* v0.4.0 M5 (D372): low-end perf preset -- one row, two writes: - * cap the frame rate at 30 (half the sim tick rate, D186) and drop - * MSAA to x1. OFF restores the compiled defaults (60 / 2) rather - * than remembering per-user pre-preset values (the defaults ARE - * the N64-original setting; simpler and predictable). */ - if (strcmp(r->key, "Video.LowEndMode") == 0) { - int on = (int)lround(v) != 0; - rowSet(rowByKey("Video.FpsCap"), on ? 30 : 60); - rowSet(rowByKey("Video.MSAA"), on ? 1 : 2); - } + /* Crouch mode drops the latch; future bind-capture rows can call the + * same rebuild hook. */ + if (strcmp(r->key, "Input.CrouchMode") == 0) inputBindingsApply(); /* Linked aim/turn sensitivity (Input.SensLink, default on): moving either * knob scales the other to hold the stock default ratio -- AimModeSens 38 @@ -860,7 +1188,11 @@ static void rowAdjust(struct Row *r, int dir) videoRequestWindowSize(kResList[i][0], kResList[i][1]); break; } + case ROW_BIND: + r->bindSlot = (r->bindSlot + dir + INPUT_BIND_SLOTS) % INPUT_BIND_SLOTS; + break; case ROW_ACTION: + if (optionsRowChildHeader((int)(r - rows)) >= 0) break; /* link, not a Quit or value */ /* D356: reset rows arm/confirm through the shared activation * contract (edge-triggered by their callers: fresh key press / fresh * click / A-press -- the held-repeat paths below never re-fire them). @@ -920,11 +1252,9 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Input.PadLookInvertY", 0 }, /* = 0 */ { "Input.PadDeadzone", 7000 },/* = STICK_DEADZONE (7000) */ { "Input.PadTriggerPct", 23 }, /* = 23 */ - { "Input.Layout", 0 }, /* = 0 (GEPD default layout, D374) */ { "Input.CrouchMode", 0 }, /* = 0 (hold) */ /* GRAPHICS (port/src/video.c initializers) */ { "Video.MSAA", 2 }, /* = 2 */ - { "Video.LowEndMode", 0 }, /* = 0 (preset off) */ { "Video.TextureFilter", 1 }, /* = 1 (bilinear) */ { "Video.Anisotropy", 4 }, /* = 4 */ { "Video.FovScale", 100 }, /* = 100 */ @@ -934,7 +1264,12 @@ static const struct { const char *key; double def; } kResetDefaults[] = { { "Video.DrawDistance", 250 }, /* midpoint: 50/100 */ { "Video.LodDistance", 250 }, /* midpoint: 50/100 */ { "Video.CrosshairHide", 0 }, /* = 0 (on, N64) */ - { "Video.CrosshairColor", 0 }, /* = 0 (white) */ + { "Video.CrosshairColor", 0 }, /* = 0 (authored red sprite) */ + { "Video.CrosshairRed", 255 }, + { "Video.CrosshairGreen", 255 }, + { "Video.CrosshairBlue", 255 }, + { "Video.CrosshairSize", 100 }, /* = 100% */ + { "Video.CrosshairStyle", 0 }, /* = 0 (original) */ /* GAMEPLAY ini rows (port/src/video.c initializers) */ { "Game.SkipIntro", 0 }, /* = 0 */ { "Game.NoHitFlash", 0 }, /* = 0 */ @@ -974,11 +1309,16 @@ static void rowResetSection(int iReset) } int end = hdr + 1; while (end < NUM_ROWS && rows[end].kind != ROW_HEADER) end++; - int nWatch = 0, nIni = 0; + int nWatch = 0, nIni = 0, nBinds = 0; for (int j = hdr + 1; j < end; j++) { struct Row *r = &rows[j]; if (r->kind == ROW_ACTION) continue; /* reset rows, Quit */ if (strcmp(r->key, "__Resolution") == 0) continue; /* documented exclusion */ + if (r->kind == ROW_BIND) { + nBinds += inputBindingResetKey(r->key); + r->bindSlot = 0; + continue; + } int field = watchSettingsFieldForKey(r->key); if (field >= 0) { watchSettingsSet((enum WatchSettingField)field, @@ -991,8 +1331,9 @@ static void rowResetSection(int iReset) nIni++; } } - sysLogPrintf(LOG_INFO, "optionsoverlay: section reset '%s': %d file row(s), %d ini row(s)", - rows[hdr].label, nWatch, nIni); + if (nBinds) inputBindingsApply(); + sysLogPrintf(LOG_INFO, "optionsoverlay: section reset '%s': %d file row(s), %d ini row(s), %d key row(s)", + rows[hdr].label, nWatch, nIni, nBinds); } /* Arm (first edge) or confirm (second edge within RESET_ARM_US). Callers @@ -1044,6 +1385,7 @@ void optionsOverlayToggle(void) { overlayInit(); if (s_open) { + if (optionsBindingCaptureActive()) return; /* Escape cancels modal */ int pending = SDL_AtomicSet(&s_dragWatchField, 0); if (pending > 0) watchSettingsCommit(pending - 1); } @@ -1136,7 +1478,7 @@ static int overlayControlSpan(int i, s32 *x0, s32 *x1); void optionsOverlayHandleInput(void) { - static int prevUp, prevDn, prevLf, prevRt, prevLmb, prevRmb; + static int prevUp, prevDn, prevLf, prevRt, prevLmb, prevRmb, prevPadBack; /* D347: hold-to-repeat state (18/4-frame cadence, same as the options * screen's D345(e)/(f) blocks). */ static int navDir = 0, navTimer = 0, adjDir = 0, adjTimer = 0; @@ -1161,6 +1503,7 @@ void optionsOverlayHandleInput(void) /* The overlay owns the mouse while it is open: force the OS cursor free + * visible (a stage poll would otherwise leave it locked/hidden). */ inputSuspendForOverlay(); + if (optionsBindingCaptureTick()) return; /* D361: regression probe on the ACTUAL joyPoll/F10 thread (not the * game-thread GE_WSPROBE_FRONT hook). A front-end write used to block @@ -1183,13 +1526,7 @@ void optionsOverlayHandleInput(void) overlayUpdateVisible(); if (SDL_AtomicSet(&s_backPending, 0)) { if (s_section >= 0) { - int hdr = s_section; - s_section = -1; - overlayUpdateVisible(); - s_sel = 0; - while (s_sel + 1 < s_visN && s_visIdx[s_sel] != hdr) s_sel++; - s_scroll = 0; - optionsResetClear(); + overlayBackOne(); } else { optionsOverlayToggle(); return; @@ -1207,15 +1544,10 @@ void optionsOverlayHandleInput(void) int lmb = (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ? 1 : 0; int rmb = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) ? 1 : 0; - /* Gamepad navigation (controller-only machines, e.g. Steam Deck): the - * D-pad or left stick moves the selection; A/X step forward (the Enter - * equivalent), B/Y step back; Start closes. OR-ed into the same edge - * logic as the keyboard, so repeat/clamp/scroll behaviour is identical. - * input.c swallows the pad while we are open, so none of this reaches - * the game. (Select also closes -- handled in input.c's toggle, which - * runs before this one.) - * D347: D-pad left/right and stick-X join A/X/B/Y as value adjust -- - * the standard controller convention; A/X/B/Y keep working. */ + /* D396: B backs out one F10 page (closes from root); A/X accept/toggle + * on an edge, not slider adjustment. Only D-pad/left-stick X (or keyboard + * left/right) adjust sliders, with hold-repeat. The pad is swallowed by + * input.c while the overlay owns it. Start/Select still close it. */ int gUp = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_UP) || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTY) < -12000; int gDn = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_DOWN) @@ -1226,21 +1558,32 @@ void optionsOverlayHandleInput(void) || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTX) > 12000; int up = ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8] || gUp; int dn = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2] || gDn; - int lf = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4] - || gLf - || inputPadButton(0, SDL_CONTROLLER_BUTTON_B) - || inputPadButton(0, SDL_CONTROLLER_BUTTON_Y); - int rt = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] - || ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] - || gRt - || inputPadButton(0, SDL_CONTROLLER_BUTTON_A) - || inputPadButton(0, SDL_CONTROLLER_BUTTON_X); + int lf = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4] || gLf; + int rightNav = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] || gRt; + int accept = ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] || + inputPadButton(0, SDL_CONTROLLER_BUTTON_A) || + inputPadButton(0, SDL_CONTROLLER_BUTTON_X); + int rt = rightNav || accept; + int padBack = inputPadButton(0, SDL_CONTROLLER_BUTTON_B); static int prevStart = 0; /* not reset while closed: a Start held across close must not re-close on the next open */ int startNow = inputPadButton(0, SDL_CONTROLLER_BUTTON_START); - if (startNow && !prevStart) optionsOverlayToggle(); /* Start closes */ + if (startNow && !prevStart) { + prevStart = startNow; + optionsOverlayToggle(); /* Start closes */ + return; + } prevStart = startNow; + if (padBack && !prevPadBack) { + prevPadBack = padBack; + navDir = adjDir = 0; + prevUp = up; prevDn = dn; prevLf = lf; prevRt = rt; + if (s_section >= 0) overlayBackOne(); + else optionsOverlayToggle(); + return; + } + prevPadBack = padBack; /* ---- keyboard / D-pad nav (clamped at the ends; scroll follows). * D347: hold-to-repeat so a controller (and held keys) can traverse the @@ -1268,22 +1611,34 @@ void optionsOverlayHandleInput(void) adjDir = dir; adjTimer = 18; if (s_section < 0) { - s_section = s_visIdx[s_sel]; - s_sel = 1; - s_scroll = 0; - overlayUpdateVisible(); + overlayOpenHeader(s_visIdx[s_sel]); adjDir = 0; } else if (s_sel == 0) { - int hdr = s_section; - s_section = -1; - overlayUpdateVisible(); - s_sel = 0; - while (s_sel + 1 < s_visN && s_visIdx[s_sel] != hdr) s_sel++; - s_scroll = 0; - optionsResetClear(); + overlayBackOne(); adjDir = 0; } else { - rowAdjust(&rows[s_visIdx[s_sel]], dir); + int child = optionsRowChildHeader(s_visIdx[s_sel]); + if (child >= 0) { + if (dir > 0) overlayOpenHeader(child); + adjDir = 0; + } else { + struct Row *r = &rows[s_visIdx[s_sel]]; + int keyConfirm = ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]; + if (r->kind == ROW_BIND && dir > 0 && keyConfirm) { + bindingBegin(r); + adjDir = 0; + } else if (r->kind == ROW_BIND && dir > 0 && accept && !rightNav) { + /* Pad accept does nothing on keyboard-only binding rows; + * D-pad/stick right still changes the displayed slot. */ + adjDir = 0; + } else if (r->kind == ROW_SLIDER && dir > 0 && accept && !rightNav) { + /* Accept never moves a slider; left/right own detents. */ + adjDir = 0; + } else { + rowAdjust(r, dir); + if (accept && !rightNav) adjDir = 0; /* one-shot toggles */ + } + } } } else if ((rt || lf) && adjDir != 0) { if (--adjTimer <= 0) { @@ -1312,7 +1667,26 @@ void optionsOverlayHandleInput(void) if (rw > 0 && rh > 0) { double ox = (double)(mx - rx) * (double)viGetX() / rw; double oy = (double)(my - ry) * (double)viGetY() / rh; - int hoverVis = overlayRowAtY(oy); + int hoverVis = overlayRowAtY(ox, oy); + /* Opt-in diagnostic for D391: distinguish hit-band/scroll errors + * from logical SDL mouse coords vs GL drawable pixel coords. */ + static int pointerLog = -1, pointerSamples = 0; + if (pointerLog < 0) pointerLog = getenv("GE_OVPOINTERMAP") != NULL; + if (pointerLog && ((lmb && !prevLmb) || ++pointerSamples == 120)) { + if (pointerSamples >= 120) pointerSamples = 0; + int winW = 0, winH = 0, drawW = 0, drawH = 0; + SDL_Window *win = SDL_GetMouseFocus(); + if (!win) win = SDL_GetKeyboardFocus(); + if (win) { + SDL_GetWindowSize(win, &winW, &winH); + SDL_GL_GetDrawableSize(win, &drawW, &drawH); + } + sysLogPrintf(LOG_INFO, "ovpointer: mouse=%d,%d window=%d,%d drawable=%d,%d rect=%d,%d,%d,%d vi=%d,%d logical=%.1f,%.1f section=%d scroll=%d sel=%d hit=%d key=%s click=%d", + mx, my, winW, winH, drawW, drawH, rx, ry, rw, rh, + viGetX(), viGetY(), ox, oy, s_section, s_scroll, s_sel, + hoverVis, hoverVis >= 0 ? rows[s_visIdx[hoverVis]].key : "-", + lmb && !prevLmb); + } int onClose = overlayInCloseBox(ox, oy); /* No hover-to-highlight: merely moving the mouse must not move the @@ -1331,13 +1705,10 @@ void optionsOverlayHandleInput(void) s_sel = hoverVis; /* explicit click -> select */ overlayUpdateScroll(); if (rows[s_visIdx[hoverVis]].kind == ROW_HEADER) { - int hdr = s_visIdx[hoverVis]; - if (s_section < 0) { s_section = hdr; s_sel = 1; } - else { s_section = -1; s_sel = 0; optionsResetClear(); } - s_scroll = 0; - overlayUpdateVisible(); - if (s_section < 0) - while (s_sel + 1 < s_visN && s_visIdx[s_sel] != hdr) s_sel++; + if (s_section < 0) overlayOpenHeader(s_visIdx[hoverVis]); + else overlayBackOne(); + } else if (optionsRowChildHeader(s_visIdx[hoverVis]) >= 0) { + overlayOpenHeader(optionsRowChildHeader(s_visIdx[hoverVis])); } else { int i = s_visIdx[hoverVis]; s32 x0, x1; @@ -1349,6 +1720,8 @@ void optionsOverlayHandleInput(void) s_dragRow = i; int field = watchSettingsFieldForKey(r->key); SDL_AtomicSet(&s_dragWatchField, field + 1); + } else if (r->kind == ROW_BIND) { + bindingBegin(r); /* mouse selects slot via arrows; click captures */ } else { rowAdjust(r, +1); /* toggle / cycle forward (wraps) */ } @@ -1426,7 +1799,12 @@ static void valueText(int i, char *out, int n) return; } if (r->kind == ROW_ACTION) { - if (isResetRow(r)) { + if (optionsRowChildHeader(i) >= 0) { + /* PD's selectable submenu rows use a title such as "Stick + * Settings...", not a second "Open >" value. The link label + * above carries that same affordance on both GE PC menus. */ + if (n > 0) out[0] = 0; + } else if (isResetRow(r)) { /* D356: the label is the button; the value column shows the * armed state so the confirm step is discoverable. */ snprintf(out, n, s_resetArmedRow == i ? "Confirm" : ""); @@ -1435,6 +1813,20 @@ static void valueText(int i, char *out, int n) } return; } + if (r->kind == ROW_BIND) { + if (optionsBindingCaptureActive() && i == s_bindCaptureRow) + snprintf(out, n, "Key/mouse..."); + else if (s_bindError && i == s_bindCaptureRow) + snprintf(out, n, "Binding too long"); + else { + static const char *const kSlotName[INPUT_BIND_SLOTS] = { + "Primary", "Secondary", + }; + snprintf(out, n, "%s: %s", kSlotName[r->bindSlot], + inputBindingSlot(r->key, r->bindSlot)); + } + return; + } if (r->kind == ROW_MSAA) { if ((int)lround(v) <= 1) snprintf(out, n, "None"); else snprintf(out, n, "%dx", (int)lround(v)); @@ -1472,6 +1864,13 @@ static void valueText(int i, char *out, int n) snprintf(out, n, "%d/100", normalized); return; } + /* RGB channels are 8-bit values; show their endpoint in both UIs. */ + if (strcmp(r->key, "Video.CrosshairRed") == 0 || + strcmp(r->key, "Video.CrosshairGreen") == 0 || + strcmp(r->key, "Video.CrosshairBlue") == 0) { + snprintf(out, n, "%d/255", (int)lround(v)); + return; + } /* D346: integer sliders -- optional raw->display divide + unit suffix. */ if (r->dispDiv > 0) { v = (double)(int)lround(v / (double)r->dispDiv); @@ -1662,11 +2061,21 @@ Gfx *optionsOverlayEmit(void) 1, 9, 7, 235); gdl = fillRect(gdl, o.left + 8, o.footerY - 5, o.right - 8, o.footerY - 4, 55, 102, 68, 170); - /* No row panels or control boxes. Selection is the brighter text in - * pass 2; only a slider's actual track/knob gets a background. */ + /* Selection is the brighter text in pass 2; sliders and the reticle + * colour's tiny preview swatch are the only controls with a fill. */ for (int p = s_scroll; p <= pLast; p++) { const struct Row *r = &rows[s_visIdx[p]]; s32 rowY = o.contentY + (p - s_scroll) * OV_LINE; + if (strcmp(r->key, "Video.CrosshairColor") == 0 && r->found) { + extern void portCrosshairPreview(s32 *, s32 *, s32 *); + char val[48]; + s32 red, green, blue; + valueText(s_visIdx[p], val, sizeof(val)); + portCrosshairPreview(&red, &green, &blue); + s32 x = o.valueR - bodyWidth(val) - 17; + gdl = fillRect(gdl, x, rowY, x + 10, rowY + 10, + red, green, blue, 255); + } if (r->kind == ROW_SLIDER && r->found) { double f = rowFractionAt(r, rowGet(r)); s32 by = rowY + 3; /* INSIDE the row's hit band, not the next row */ @@ -1684,13 +2093,15 @@ Gfx *optionsOverlayEmit(void) gdl = drawBody(gdl, o.left + 10, o.top + 8, "PC OPTIONS", 0xa0ffa0ff); gdl = drawBodyR(gdl, o.right - 14, o.top + 8, "X", 0xa0ffa0ff); - if (s_section < 0) { - gdl = drawMeta(gdl, o.labelX, o.sectionY, "SELECT A CATEGORY", 0x829e91ff); - } else { + if (s_section >= 0) { const struct Row *hdr = &rows[s_section]; - gdl = drawBody(gdl, o.left + 12, o.sectionY, "< BACK", 0xa0ffa0ff); + /* The pinned Back row is a real selection (s_visIdx[0]), not an + * always-bright title decoration. Give it the same selected/dimmed + * ink as content rows so Up from the first setting has feedback. */ + gdl = drawBody(gdl, o.left + 12, o.sectionY, "< BACK", + s_sel == 0 ? 0xa0ffa0ff : 0x66ca77ff); s32 titleX = o.left + 80; - gdl = drawBody(gdl, titleX, o.sectionY, hdr->label, 0xa0ffa0ff); + gdl = drawBody(gdl, titleX, o.sectionY, hdr->label, 0x829e91ff); if (hdr->saveScoped) { int f = watchSettingsActiveFolder(); char note[24]; @@ -1698,9 +2109,6 @@ Gfx *optionsOverlayEmit(void) else snprintf(note, sizeof(note), "(Profile %d)", f + 1); gdl = drawBody(gdl, titleX + bodyWidth(hdr->label) + 5, o.sectionY, note, 0x829e91ff); - } else if (!strcmp(hdr->key, "__HdrInput")) { - gdl = drawBody(gdl, titleX + bodyWidth(hdr->label) + 5, - o.sectionY, "(50 = default)", 0x829e91ff); } } @@ -1717,11 +2125,12 @@ Gfx *optionsOverlayEmit(void) valueText(s_visIdx[p], val, sizeof(val)); s32 valueW = bodyWidth(val); s32 labelEnd = r->kind == ROW_SLIDER ? bx0 : o.valueR - valueW - 7; + if (strcmp(r->key, "Video.CrosshairColor") == 0) labelEnd -= 17; if (r->restart) labelEnd = bx0 - 6; gdl = drawBodyFit(gdl, o.labelX, rowY, labelEnd - o.labelX, r->label, ink); - /* Only GAMEPLAY mixes profile and global values. Its four profile - * toggles have space for a real, subdued word; AUDIO is wholly + /* GAMEPLAY and HUD mix profile and global values. Their profile + * toggles have space for a subdued word; AUDIO is wholly * profile-scoped and says so in its section title. */ if (r->saveScoped && sectionIsMixedScope(s_section)) { s32 tagX = o.labelX + bodyWidth(r->label) + 4; @@ -1742,10 +2151,52 @@ Gfx *optionsOverlayEmit(void) gdl = drawBodyR(gdl, o.valueR, rowY, val, valueInk); } } + /* Keep the help inside the card at 320x240. The old single long line + * started after CONTROLS: and ran under / beyond the right edge. Reserve + * two lines in overlayLayout so the footer never covers the last row. */ + const char *help1, *help2; + if (optionsBindingCaptureActive()) { + help1 = "PRESS KEY/MOUSE 1-5"; + help2 = "B/ESC CANCEL DEL CLEAR"; + } else if (s_section < 0) { + help1 = "A/ENTER SELECT"; + help2 = "B/F10 CLOSE"; + } else if (!strcmp(rows[s_section].key, "__HdrMoveKeys") || + !strcmp(rows[s_section].key, "__HdrActionKeys")) { + help1 = "KEY/MOUSE ONLY LEFT/RIGHT SLOT"; + help2 = "ENTER BIND B/ESC BACK"; + } else { + help1 = "A SELECT LEFT/RIGHT CHANGE"; + help2 = "B/ESC BACK F10 CLOSE"; + } + s32 helpX = o.left + 16 + metaWidth("CONTROLS:"); + s32 helpRight = o.right - 10; + static int footerProbe = -1; + if (footerProbe < 0) footerProbe = getenv("GE_FOOTERPROBE") != NULL; + if (footerProbe) { + static int reported; + if (!reported++) { + static const char *const lines[][2] = { + { "PRESS KEY/MOUSE 1-5", "ESC CANCEL DEL CLEAR" }, + { "A/ENTER SELECT", "B/F10 CLOSE" }, + { "KEY/MOUSE ONLY LEFT/RIGHT SLOT", "ENTER BIND B/ESC BACK" }, + { "A SELECT LEFT/RIGHT CHANGE", "B/ESC BACK F10 CLOSE" }, + }; + for (size_t k = 0; k < sizeof(lines) / sizeof(lines[0]); k++) { + s32 a = bodyWidth(lines[k][0]), b = bodyWidth(lines[k][1]); + s32 avail1 = helpRight - helpX; + s32 avail2 = helpRight - (o.left + 16); + sysLogPrintf(a > avail1 || b > avail2 ? LOG_ERROR : LOG_INFO, + "footerprobe: variant %d widths %d/%d available %d/%d %s", + (int)k, a, b, avail1, avail2, + a > avail1 || b > avail2 ? "OVERFLOW" : "FIT"); + } + } + } gdl = drawMeta(gdl, o.left + 11, o.footerY, "CONTROLS:", 0xa6baaaff); - gdl = drawBody(gdl, o.left + 16 + metaWidth("CONTROLS:"), o.footerY, - s_section < 0 ? "ENTER SELECT F10 CLOSE" : - "RIGHT: CHANGE ESC BACK F10 CLOSE", 0x829e91ff); + gdl = drawBody(gdl, helpX, o.footerY, help1, 0x829e91ff); + gdl = drawBody(gdl, o.left + 16, o.footerY + OV_FOOTER_LINE, + help2, 0x829e91ff); if (s_visN - (s_section >= 0 ? 1 : 0) > o.maxRows) gdl = drawBodyR(gdl, o.right - 10, o.sectionY, s_scroll > (s_section >= 0 ? 1 : 0) ? "^ v" : "v", @@ -1790,6 +2241,7 @@ int optionsRowIsShown(int i) { struct Row *r = rowAt(i); return r && r->found && !(r->hidePtr && *r->hidePtr) && + (!r->showPtr || *r->showPtr == r->shownValue) && (r->kind != ROW_BOND_FILE || current_menu == MENU_PC_OPTIONS); } @@ -1805,6 +2257,17 @@ int optionsRowIsSlider(int i) return r && r->kind == ROW_SLIDER && rowHi(r) > rowLo(r); } +int optionsRowIsBind(int i) +{ + struct Row *r = rowAt(i); + return r && r->kind == ROW_BIND; +} + +void optionsRowBeginBind(int i) +{ + bindingBegin(rowAt(i)); +} + /* D353: the explicit Bond-file chooser row (front options screen only). */ int optionsRowIsBondChooser(int i) { @@ -1894,7 +2357,7 @@ void optionsRowAdjust(int i, int dir) /* second file's bytes unchanged (scope isolation). */ /* ------------------------------------------------------------------------ */ -#define PROBE_MAX_SECTIONS 8 +#define PROBE_MAX_SECTIONS 8 /* reset-owning sections including HUD and bind leaves */ static int probeSectionIdxs[PROBE_MAX_SECTIONS]; static int probeSectionN = 0; static int probeResSelBefore = -2; @@ -2059,7 +2522,7 @@ void optionsResetProbeDispatchStage(void) } int q = watchSettingsQueueCount(); if (q < 6) { - sysLogPrintf(LOG_WARNING, "wsresetprobe: stage dispatch: queue depth %d, want >= 6 (4 GAMEPLAY + 2 AUDIO watch rows)", q); + sysLogPrintf(LOG_WARNING, "wsresetprobe: stage dispatch: queue depth %d, want >= 6 (2 GAMEPLAY + 2 HUD + 2 AUDIO watch rows)", q); } else { sysLogPrintf(LOG_INFO, "wsresetprobe: stage dispatch: %d command(s) queued (game thread drains next tick)", q); }