diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md index 83998c0f..de671ddc 100644 --- a/docs/dev/QOL-INVENTORY.md +++ b/docs/dev/QOL-INVENTORY.md @@ -28,7 +28,7 @@ ScreenShakeIntensity. Debug: FrameDump, InputLog. | Feature | PD location | Class | Effort | Notes / priority | |---|---|---|---|---| -| **Key rebinding** (config-string binds per controller/action) | `input.c:69,627-675,1548` (`bindStrs`, `inputParseBindString`) | P | M | **High.** Explicit Phase 4 debt ("rebinding UI" — note PD has *no* UI either; config-file driven). Port-only in `port/src/input.c`. | +| **Key rebinding** _(DONE M-83, D214)_ | `port/src/input.c` `[Bind]` section | P | M | Keyboard only, 12 actions, `Input.Bind.* = Key,Key` (SDL scancode names), file-driven like PD. Defaults = prior FPS layout (verified). Per-controller pad rebinding + a UI still open. | | **SkipIntro** | `main.c:139` (STAGE_TITLE → STAGE_CITRAINING) | P | S | **High.** GE analogue: stage-override path already exists (`boss.c:385-393`, `g_StageNum = LEVELID_TITLE` at :98); wire a flag + arg. 10-min check on what feeds `tokenFindLevel`. | | **DisplayFPS** _(DONE M-83, D213)_ | config `Video.DisplayFPS` | P | S | Top-right readout, config-only; ~0.5 s sample window. | | **Anisotropic filtering** _(DONE M-83, D212)_ | `Video.Anisotropy` (1–16, dflt 4) | P | S | fast3d already had the GL hook + a hardcoded 4×; M-83 exposed it as config. MipmapFilter / TextureFilter2D still not surfaced. | diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 1675116c..5d0d078d 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -56,6 +56,7 @@ D184,PARTIAL,F10 port-layer options overlay —,"LANDED, port-only, no #ifdef PO D211,PARTIAL,"PC FOV slider — Video.FovScale (M-83 QoL wave, port-only, WIDESCREEN-FOV-PLAN.md Phase 4).","LANDED, port-only, no #ifdef PORT." D212,PARTIAL,Anisotropic-filtering config wiring —,"LANDED, port-only. Build clean." D213,PARTIAL,On-screen FPS readout —,"LANDED, port-only. Build clean; bunker1 no-op confirmed." +D214,PARTIAL,"Keyboard rebinding — [Bind] config section (M-83, port-only, PD parity, Phase 4 debt).","LANDED, port-only, no #ifdef PORT." D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)" D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure,resolved D88.4,RESOLVED,propDefs polymorphic record stream not byteswapped → setupDoor crash,resolved (d88_propdefs.py); layout audit M-20 → D132 diff --git a/docs/dev/findings.md b/docs/dev/findings.md index c5fd96d5..b61e9e12 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -430,6 +430,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D211 | **PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** fast3d applies a multiplicative half-angle scale to every *perspective* projection matrix as it is loaded (`gfx_apply_fov_scale()` in `gfx_pc.cpp gfx_sp_matrix`, on the `G_MTX_PROJECTION | G_MTX_LOAD` path only — GE always reloads its view projection via `guPerspectiveF` at `fr.c:725`, so a relative `G_MTX_MUL` onto an already-scaled matrix cannot compound). Columns 0 and 1 are scaled by the same factor `tan(half_orig)/tan(half_adj)` → H and V FOV widen uniformly, aspect ratio exactly preserved (no stretch), and because the game rewrites fovy every frame for aim-zoom the scale composes with zoom for free. Ortho matrices (`P[3][3]==1`) are left untouched so HUD/menu 2D is unaffected. `Video.FovScale` = percent of the original vertical FOV, 50–150, **default 100 ⇒ `g_fov_scale == 1.0f` ⇒ guarded early-return, zero FP ops on the matrix ⇒ golden dumps byte-identical**. F10 overlay row "FOV scale %". Math checked offline (`s=1.25 → fovy 60°→75°`, aspect held at 1.3333). | **LANDED, port-only, no `#ifdef PORT`.** Build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **Owed:** an interactive eyeball at FovScale ≠ 100 (world widens, HUD unmoved, aim-zoom still composes). | | D212 | **Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** The fast3d GL backend already implemented `set_anisotropy_level` / `get_max_anisotropy_level` and applied `GL_TEXTURE_MAX_ANISOTROPY` on mipmapped tiles with a hardcoded `current_anisotropy_level = 4`; there was no way to configure it. Added the `gfx_set_anisotropy_level()` extern-C wrapper in `gfx_pc.cpp` (clamps to `[1, GL max]`, `reset_texture_state()` so live changes take), declared in `gfx_api.h`, and `Video.Anisotropy` (1–16) in `port/src/video.c` wired through `videoApplyImageOptions()` on init + live-config. **Default 4 = the value fast3d already applied ⇒ no visual delta at defaults.** F10 overlay row "Anisotropic". | **LANDED, port-only.** Build clean. **Owed:** eyeball at 16× on a grazing surface (Depot roof / Dam walkway) vs 1×. | | D213 | **On-screen FPS readout — `Video.DisplayFPS` (M-83 QoL wave, port-only, PD parity).** `port/src/optionsoverlay.c` samples frame time once per emitted frame (`fpsTick()` via `sysGetMicroseconds()`, ~0.5 s window) and, when enabled, emits a small top-right green "N FPS" DL even while the F10 panel is closed. Config-only knob (kept off the 13-row panel, which is at its documented layout limit — matches PD, whose `DisplayFPS` is also file-only). **Default 0 ⇒ `optionsOverlayEmit()` still returns NULL when the panel is closed ⇒ golden dumps byte-identical.** | **LANDED, port-only.** Build clean; `bunker1` no-op confirmed. **Owed:** eyeball that the counter reads a sane ~30 and doesn't overlap the HUD. | +| D214 | **Keyboard rebinding — `[Bind]` config section (M-83, port-only, PD parity, Phase 4 debt).** `port/src/input.c` gains 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each a comma-separated list of up to 4 SDL scancode names (`Input.Bind.Forward = W,Up` …). `inputRebuildBinds()` (called from `inputInit()`, after `configLoad()`) parses them via `SDL_GetScancodeFromName`; unknown names log a warning and are skipped. The old hardcoded `keyDown(ks, SDL_SCANCODE_*)` block in `inputComputePad` is replaced by `actHeld(ks, IA_*)`. Mouse buttons (fire = LMB, aim = RMB) stay hardwired; the mouse-wheel weapon-cycle A-pulse is untouched. **Defaults reproduce the previous FPS layout exactly — verified: all 20 default key names round-trip to the identical `SDL_Scancode` the old code used (`SDL_GetScancodeFromName` test, 0 mismatches).** Config-file only (no in-overlay text entry — matches PD, whose rebinding is also file-driven); a fresh `ge007.ini` gets the full `[Bind]` block with the defaults visible. | **LANDED, port-only, no `#ifdef PORT`.** Build clean. Default binds byte-equivalent to the prior mapping (scancode test). **Owed:** interactive check that a *non-default* bind (e.g. `Fire = Left Alt`) actually takes, and that ESC still cancels menus (it's in the default `Cancel` bind + a separate `video.c` path). | | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | | D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved | diff --git a/port/src/input.c b/port/src/input.c index aa725afa..680af48b 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -399,6 +399,8 @@ static unsigned scriptApply(unsigned button) return m; } +static void inputRebuildBinds(void); /* D214; defined below with keyDown() */ + int inputInit(void) { if (!SDL_WasInit(SDL_INIT_GAMECONTROLLER)) { @@ -413,6 +415,8 @@ int inputInit(void) } inputOpenPads(); + inputRebuildBinds(); /* D214: parse [Bind] now that configLoad() has run */ + /* Relative mouse mode for mouse-look. In click-to-lock mode we start * released and wait for a click in the window (video.c -> inputNotifyClick). */ mouseGrabbed = mouseEnabled && !mouseCaptureMode; @@ -486,7 +490,79 @@ static int scaleAxis(int v) static int keyDown(const Uint8 *ks, SDL_Scancode sc) { - return ks && ks[sc]; + return ks && sc != SDL_SCANCODE_UNKNOWN && ks[sc]; +} + +/* ------------------------------------------------------------------------ + * D214 — keyboard rebinding (PD parity: config-string driven, no UI). + * + * Each gameplay action maps to a comma-separated list of SDL scancode names + * (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. + * ---------------------------------------------------------------------- */ +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, IA_COUNT +}; + +static const struct { const char *key; const char *def; } kBindDefs[IA_COUNT] = { + [IA_FORWARD] = { "Input.Bind.Forward", "W,Up" }, + [IA_BACK] = { "Input.Bind.Back", "S,Down" }, + [IA_STRAFE_L] = { "Input.Bind.StrafeLeft", "A" }, + [IA_STRAFE_R] = { "Input.Bind.StrafeRight", "D" }, + [IA_TURN_L] = { "Input.Bind.TurnLeft", "Left" }, + [IA_TURN_R] = { "Input.Bind.TurnRight", "Right" }, + [IA_FIRE] = { "Input.Bind.Fire", "Left Ctrl" }, + [IA_AIM] = { "Input.Bind.Aim", "Left Shift" }, + [IA_ACTION] = { "Input.Bind.Action", "Space,Z,E" }, + [IA_CANCEL] = { "Input.Bind.Cancel", "X,R,F,Escape" }, + [IA_LEAN_L] = { "Input.Bind.LeanLeft", "Q" }, + [IA_START] = { "Input.Bind.Start", "Return,Tab" }, +}; + +#define BIND_MAX_KEYS 4 +static char g_bindStr[IA_COUNT][64]; +static SDL_Scancode g_bind[IA_COUNT][BIND_MAX_KEYS]; + +static void inputRebuildBinds(void) +{ + for (int a = 0; a < IA_COUNT; a++) { + for (int k = 0; k < BIND_MAX_KEYS; k++) { + g_bind[a][k] = SDL_SCANCODE_UNKNOWN; + } + char buf[64]; + strncpy(buf, g_bindStr[a][0] ? g_bindStr[a] : kBindDefs[a].def, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + + int n = 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); + if (sc == SDL_SCANCODE_UNKNOWN) { + sysLogPrintf(LOG_WARNING, "input: %s: unknown key name '%s'", + kBindDefs[a].key, tok); + continue; + } + g_bind[a][n++] = sc; + } + if (n == 0) { + sysLogPrintf(LOG_WARNING, "input: %s has no valid keys; action unbound", + kBindDefs[a].key); + } + } +} + +static int actHeld(const Uint8 *ks, int act) +{ + for (int k = 0; k < BIND_MAX_KEYS; k++) { + if (keyDown(ks, g_bind[act][k])) return 1; + } + return 0; } /* Fill button mask + stick for controller idx. Returns the 16-bit mask. */ @@ -538,32 +614,29 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) /* GE default control (1.1): stick Y = move fwd/back, stick X = turn, * C-left/right = sidestep, C-up/down = look. FPS layout: W/S move, * A/D strafe (C-buttons), mouse X turns (stick X), mouse Y looks. */ - if (keyDown(ks, SDL_SCANCODE_W) || keyDown(ks, SDL_SCANCODE_UP)) sy = STICK_MAX; - if (keyDown(ks, SDL_SCANCODE_S) || keyDown(ks, SDL_SCANCODE_DOWN)) sy = -STICK_MAX; - if (keyDown(ks, SDL_SCANCODE_A)) button |= GE_CONT_C; /* strafe left */ - if (keyDown(ks, SDL_SCANCODE_D)) button |= GE_CONT_F; /* strafe right */ - if (keyDown(ks, SDL_SCANCODE_LEFT)) sx = -STICK_MAX; /* keyboard turn */ - if (keyDown(ks, SDL_SCANCODE_RIGHT)) sx = STICK_MAX; + if (actHeld(ks, IA_FORWARD)) sy = STICK_MAX; + if (actHeld(ks, IA_BACK)) sy = -STICK_MAX; + if (actHeld(ks, IA_STRAFE_L)) button |= GE_CONT_C; /* strafe left */ + if (actHeld(ks, IA_STRAFE_R)) button |= GE_CONT_F; /* strafe right */ + if (actHeld(ks, IA_TURN_L)) sx = -STICK_MAX; /* keyboard turn */ + if (actHeld(ks, IA_TURN_R)) sx = STICK_MAX; - if ((mb & SDL_BUTTON(SDL_BUTTON_LEFT)) || keyDown(ks, SDL_SCANCODE_LCTRL)) + if ((mb & SDL_BUTTON(SDL_BUTTON_LEFT)) || actHeld(ks, IA_FIRE)) button |= GE_CONT_G; - int aimHeld = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) || - keyDown(ks, SDL_SCANCODE_LSHIFT); + int aimHeld = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) || actHeld(ks, IA_AIM); if (aimHeld) button |= GE_CONT_R; - if (keyDown(ks, SDL_SCANCODE_SPACE) || keyDown(ks, SDL_SCANCODE_Z) || - keyDown(ks, SDL_SCANCODE_E)) + if (actHeld(ks, IA_ACTION)) button |= GE_CONT_A; if (wheelPulse > 0) { /* mouse-wheel weapon cycle -> A pulse */ button |= GE_CONT_A; wheelPulse--; } - if (keyDown(ks, SDL_SCANCODE_X) || keyDown(ks, SDL_SCANCODE_R) || - keyDown(ks, SDL_SCANCODE_F) || keyDown(ks, SDL_SCANCODE_ESCAPE)) /* D145: ESC = B (back/cancel) */ + if (actHeld(ks, IA_CANCEL)) /* D145: Escape is in the default Cancel bind */ button |= GE_CONT_B; - if (keyDown(ks, SDL_SCANCODE_Q)) + if (actHeld(ks, IA_LEAN_L)) button |= GE_CONT_L; - if (keyDown(ks, SDL_SCANCODE_RETURN) || keyDown(ks, SDL_SCANCODE_TAB)) + if (actHeld(ks, IA_START)) button |= GE_CONT_START; /* Mouse-look. Mode-dependent (see the tuning-constants comment): @@ -952,4 +1025,12 @@ PD_CONSTRUCTOR static void inputConfigInit(void) configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); configRegisterInt("Input.PadTriggerPct", &padTriggerPct, 1, 99); configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 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(). */ + for (int a = 0; a < IA_COUNT; a++) { + strncpy(g_bindStr[a], kBindDefs[a].def, sizeof(g_bindStr[a]) - 1); + configRegisterString(kBindDefs[a].key, g_bindStr[a], sizeof(g_bindStr[a])); + } }