From 975dc40d411dbe06219a2cba602c1799962c8db8 Mon Sep 17 00:00:00 2001 From: BountyHunterKanden Date: Sun, 6 Sep 2026 11:33:44 -0500 Subject: [PATCH] Add input.key, input.gamepad, and input.wheel hooks (RFC 0020) Kanto Companion's Edit Mode can't run during battle -- there's no way to stop a D-pad press from also driving the battle menu, so the only option today is pausing the game outright. This adds the same input precedence a mod had before the sandbox changes: vanilla is the whole existing callback body, and the hook fires before any of it runs. input.wheel stays a plain observer like input.pointer, since nothing depends on suppressing it. --- docs/mod-api-gen2-compat.md | 16 +- docs/modding.md | 31 ++ .../0020-input-key-gamepad-wheel-hooks.md | 214 +++++++++++ src/core/Game.lua | 336 +++++++++++------- src/core/Game2.lua | 147 +++++--- tests/engine/gate_gen2_mod_api.lua | 4 + .../modkit/cases/key_gamepad_wheel_input.lua | 217 +++++++++++ 7 files changed, 767 insertions(+), 198 deletions(-) create mode 100644 docs/rfcs/0020-input-key-gamepad-wheel-hooks.md create mode 100644 tests/modkit/cases/key_gamepad_wheel_input.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 990e7b9d..13bd2db8 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -592,13 +592,21 @@ gains a field instead of the name gaining a prefix. each row's decision in `evolution.check`. The hook passes `data` where Gen 1 passes `game`; positions 2-4 (mon, row, trigger) match. - *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, - `render.zones`, `render.compose`, `render.output_enabled`, `render.output`, + `input.key`, `input.gamepad`, `input.wheel` (RFC 0020), `render.zones`, + `render.compose`, `render.output_enabled`, `render.output`, `render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it -- the logic tick before the pad is read, a pointer the touch overlay gets - first refusal on, the palette zone list handed to the present pass, the - composed frame before ShaderFX, the letterbox, and the finished playfield rect - -- and carries the same payload. + first refusal on, a key/gamepad/wheel event before any of this method's own + hotkey or capture logic runs, the palette zone list handed to the present + pass, the composed frame before ShaderFX, the letterbox, and the finished + playfield rect -- and carries the same payload. `input.key`/`input.gamepad` + differ from the others in one way worth flagging: their `vanilla` argument + is this method's *entire* pre-existing body, not a stub, so a wrapper that + never calls `next` prevents that body from running for this event + (top-of-stack capture, hotkeys, `Input:*`, all of it) -- see RFC 0020 for + why that's a deliberate departure from `input.pointer`'s (inert) consume + contract, with real precedent from before the sandbox changes. `render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale` compute the same number. `render.zones` is handed `nil` in GBC mode (Gold diff --git a/docs/modding.md b/docs/modding.md index 9be6abee..3e08d0e7 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1035,6 +1035,37 @@ a mobile touch fires once), and focus or visibility loss and input recovery deliver a `"cancelled"` for every pointer the hook saw pressed but not yet released. Return `true` without calling `next` to consume the event. +`input.key` (RFC 0020) and `input.gamepad` (RFC 0020) deliver raw keyboard +and gamepad events *before* anything else in the engine sees them -- +before the top-of-stack capture a screen like the bindings menu uses, +before every built-in hotkey (save/load, zoom, speed, color/tilt display +cycling), before the button reaches `Input`. The callback receives +`(next, game, ev)`. For `input.key`, `ev` is `{ phase, key }` with `phase` +`"pressed"` or `"released"` and `key` the same string `love.keypressed`/ +`love.keyreleased` reports. For `input.gamepad`, `ev` is `{ phase, joystick, +button, axis, value }` with `phase` `"pressed"`, `"released"`, or `"axis"`; +`button` is set on `"pressed"`/`"released"` and nil on `"axis"`; `axis` and +`value` are set on `"axis"` and nil otherwise. Unlike `input.pointer`, +calling `next(game, ev)` here is not optional decoration: `next` runs the +engine's own handling for this event in full (the capture, the hotkeys, +`Input:keypressed`/`Input:gamepadpressed`), and a wrapper that returns +without calling it prevents all of that -- the key or button never +reaches the game this frame. This mirrors the precedence a mod always had +before the sandbox changes, when a mod could assign `love.keypressed`/ +`love.gamepadpressed` directly; there is no separate "vanilla ran anyway" +outcome to opt out of, so wrap sparingly and only while you actually need +to own the input (e.g. while your own UI is capturing keyboard/gamepad +navigation), calling `next` immediately otherwise. + +`input.wheel` (RFC 0020) delivers the mouse wheel. The callback receives +`(next, game, dy)`, where `dy` is the same vertical delta +`love.wheelmoved`'s second argument carries (the engine does not currently +read the horizontal delta either). Contract-identical to `input.pointer`: +return `true` without calling `next` to consume. As with `input.pointer`, +no current engine behavior reads what the hook chain returns -- "consume" +here means "stop other mods in the chain from also seeing this event," not +"suppress the zoom," which today has no way to be gated at all. + `mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)` queues exactly one `wasPressed` edge for the next fixed step and holds nothing; `local token = mod.input:press(game, btn)` holds the button until diff --git a/docs/rfcs/0020-input-key-gamepad-wheel-hooks.md b/docs/rfcs/0020-input-key-gamepad-wheel-hooks.md new file mode 100644 index 00000000..0be991bf --- /dev/null +++ b/docs/rfcs/0020-input-key-gamepad-wheel-hooks.md @@ -0,0 +1,214 @@ +# RFC 0020: Keyboard, gamepad, and wheel input hooks (`input.key`, `input.gamepad`, `input.wheel`) + +## Status + +Proposed. + +## Motivation + +`input.pointer` gives a mod one consume-capable seam for mouse and touch. +Keyboard, gamepad, and the scroll wheel have no equivalent. A mod can +already *read* them (`love.keyboard`/`love.joystick` stay open under the +sandbox), but polling can only read: it can never stop the base game from +also acting on the same key or button. + +The immediate consumer is Kanto Companion's overlay Edit Mode, which drags +and resizes panels with the D-pad, the left stick, and A, the same inputs +that move the player and drive the battle menu. Before the sandbox +changes (83682f01), a mod ran in the real global environment and could +just reassign `love.gamepadpressed` and friends directly, giving its own +dispatch first refusal before calling the saved original: real +suppression, not just observation. The sandbox changes closed off +writable `love.*` globals, rightly, but nothing replaced that one +capability. The only way left to keep a mod's nav input from also +reaching the game is to pause it outright, which is what Edit Mode does +today, gated to the overworld only since pausing a live battle would +freeze it. That gate isn't a design choice: Edit Mode can't run during +battle because there's no other way to stop a D-pad press from also being +read as a battle-menu press. + +## The decision it extends + +This finishes the `input.pointer` family for mouse and touch, giving +keyboard, gamepad, and wheel the same kind of seam. It doesn't change +`input.pointer`'s contract, `input.step`'s contract, or `Input.lua`'s +existing key/button remapping. + +There is no in-repo D-number registry to amend. + +## Exact API delta + +### Where these differ from `input.pointer`, and why that's deliberate + +`input.pointer`'s "return true to consume" is, today, provably inert: none +of its 14 real call sites read what `Game:pointerEvent` returns, because +its vanilla callback is a no-op stub, so there's no gameplay behavior to +gate. + +Keyboard and gamepad are the opposite: they drive real, load-bearing +behavior (movement, menus, battle input, save/load, zoom, speed, tilt, +display hotkeys). Mirroring `input.pointer` mechanically (firing the hook +as a side observation and then running `Input:keypressed`/ +`Input:gamepadpressed` regardless) would make "consume" just as inert +here, and wouldn't restore what Edit Mode actually needs: stopping a D-pad +press from also walking the player. So `input.key` and `input.gamepad` use +a stronger contract instead. `vanilla` is a closure over the entire +existing method body: top-of-stack capture, the hotkey ladder, the final +`Input:*` call, all of it. The hook fires as the first thing the method +does, and a wrapper that returns without calling `next(...)` stops all of +that from running for this event, same precedence a mod's own global +reassignment always had before the sandbox changes. Nothing new is being +granted, just the old precedence, reachable through a hook instead of a +global clobber. + +`input.wheel` has no such tension: it only ever drives camera zoom, and +nothing depends on suppressing it, so it stays a plain observer, +contract-identical to `input.pointer`. + +### New hook: `input.key` + +```lua +mod.hooks:wrap("input.key", function(next, game, ev) + -- ev = { phase, key } -- phase: "pressed" | "released" + if editingPanel and ev.phase == "pressed" and NAV_KEYS[ev.key] then + handlePanelNav(ev.key) + return -- swallowed: the player's own keypressed handling never runs + end + return next(game, ev) +end) +``` + +`ev` carries exactly what `Game:keypressed`/`Game:keyreleased` already +receive: `main.lua` already drops LÖVE's `scancode`/`isrepeat` before +either method sees them, so this doesn't add them. A later RFC can widen +the payload if something needs those two fields. + +Raised from the very first line of `Game:keypressed`/`Game:keyreleased` and +their `Game2` equivalents, before the top-of-stack capture +(`stack:top().onKeyPressed`), before the engine's own hotkey ladder (F1/F2/ +F5/F10, `-`/`=` zoom, 1-4 display hotkeys), and before `Input:keypressed`/ +`Input:keyreleased`. `vanilla` is a closure over that entire existing method +body. `game` is the live `Game`/`Game2` instance, same object `input.pointer` +already hands a mod. + +### New hook: `input.gamepad` + +```lua +mod.hooks:wrap("input.gamepad", function(next, game, ev) + -- ev = { phase, joystick, button, axis, value } + -- phase: "pressed" | "released" | "axis" + -- button is nil on "axis"; axis/value are nil on "pressed"/"released" + if editingPanel then + if ev.phase == "axis" and (ev.axis == "leftx" or ev.axis == "lefty") then + updateStickCursor(ev.axis, ev.value) + return + end + if ev.phase == "pressed" and ev.button == "a" then + grabOrDropPanel() + return + end + end + return next(game, ev) +end) +``` + +Unlike `input.pointer`, this covers three raw LÖVE callbacks, not one: +`gamepadpressed`, `gamepadreleased`, and `gamepadaxis`, because the +motivating use case (a stick-driven cursor for touch-free panel dragging) +needs raw analog axis values, which `stack:top().onGamepadPressed`-style +button capture cannot deliver at all. Each of the three raw callbacks in +`Game.lua`/`Game2.lua` raises `input.gamepad` as its first line, `vanilla` +wrapping that callback's entire existing body (the Select-chord intercept, +the shoulder-button speed cycling, the top-of-stack capture, `Input:*`), +the same "mod runs before everything, including the engine's own +top-of-stack capture" precedence as `input.key`, for the same reason. + +### New hook: `input.wheel` + +```lua +mod.hooks:wrap("input.wheel", function(next, game, dy) + if scrollingOwnList then + scrollList(dy) + return + end + return next(game, dy) +end) +``` + +Raised from `Game:wheelmoved`/`Game2:wheelmoved`, `vanilla` wrapping the +existing zoom-step body. Contract-identical to `input.pointer`: return +`true` without calling `next` to consume, which only arbitrates between +mods in the chain; no engine behavior reads the outer return value. +`dy` is the raw LÖVE wheel delta, matching `love.wheelmoved`'s own second +argument (the engine's callback already drops the first, horizontal, +argument, and this RFC doesn't change that). + +### Call-site summary + +| Hook | Files | Existing callbacks wrapped | +|---|---|---| +| `input.key` | `Game.lua`, `Game2.lua` | `keypressed`, `keyreleased` | +| `input.gamepad` | `Game.lua`, `Game2.lua` | `gamepadpressed`, `gamepadreleased`, `gamepadaxis` | +| `input.wheel` | `Game.lua`, `Game2.lua` | `wheelmoved` | + +All three are guarded by `ModRuntime.wantsHook(name)` before any payload +table is built, same as `input.pointer`, so a mod-free boot allocates +nothing new here. + +### On overriding the engine's own top-of-stack capture + +Because `input.key`/`input.gamepad` fire before `stack:top().onKeyPressed`/ +`onGamepadPressed`, a mod can swallow input an engine screen like +`BindingsMenu` would otherwise have captured: the same precedence every +mod already had before the sandbox changes. Nothing uses either hook yet, +so nothing regresses; reviewing a specific mod's use of it just means +checking it isn't swallowing input it shouldn't. + +## Migration and compatibility + +Nothing changes for existing mods, manifests, or any currently-shipping +hook. `input.key`, `input.gamepad`, and `input.wheel` are additive only: +new hook names with no prior meaning. `mod.hooks:wrap("input.key", fn)` is +already mechanically callable today (`Hooks:wrap` validates only that `name` +is a non-empty string); it simply never fires until this RFC's engine-side +call sites land. + +## Verification + +- `tests/modkit/cases/key_gamepad_wheel_input.lua` (new), through the public + mod API via `T.sdk.loadMods(...)` against a `fakeGame` the way + `tests/modkit/cases/pointer_input.lua` already does for `input.pointer`: + - `input.key`: pressed/released sequences with the `phase`/`key` payload + shape confirmed, a mod returning without calling `next` prevents a + synthetic `Input:keypressed` spy from being called; a mod calling + `next` lets it through; no-mod-installed still calls it + (vanilla-through). + - `input.gamepad`: press/release/axis all reach the hook; a swallowed + press never reaches `Input:gamepadpressed`; an axis event carries + `axis`/`value` and no `button`. + - `input.wheel`: delta reaches the hook; consuming it stops the zoom-step + spy from firing; no-mod-installed still zooms. + - A no-mod-installed pass for all three confirming `wantsHook(...) == + false` and nothing new gets allocated (mirrors `pointer_input.lua`'s own + first case). +- `tests/engine/gate_hooks.lua`: no edits needed, it auto-covers any hook + name the source-scanning catalog discovers, provided the vanilla callback + follows the same call-once/return-through/error-propagate contract every + existing hook already gets from `Hooks:call`, which these do. +- `tests/engine/gate_gen2_mod_api.lua`: `input.key`, `input.gamepad`, and + `input.wheel` each have call sites in both a `gen2`-pathed file + (`Game2.lua`) and a non-`gen2` file (`Game.lua`), so all three need an + explicit entry in that gate's `GEN2_HOOKS` list (next to `"input.step", + "input.pointer"`), or the gate fails naming them. +- `docs/mod-api-gen2-compat.md`: a line in "Hooks and events that fire on + Gold" → "The frame" bullet, next to the existing `input.step`/ + `input.pointer` entry. +- `docs/modding.md`: new paragraphs in the existing "Tool input and + title-menu hooks" section (it already mixes `input.step`, `input.pointer`, + `mod.input`, and `ui.title_menu.items`; these three are the same family + of raw-input seam), each citing "(RFC 0020)" per the convention + `pokemon.level_visible (RFC 0019)` established. + +## Deprecation etiquette + +Nothing is removed, renamed, or superseded. diff --git a/src/core/Game.lua b/src/core/Game.lua index d18805eb..28bf52cd 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -767,12 +767,19 @@ function Game:zoomStep(delta) end end +-- RFC 0020: input.wheel is a plain observer, contract-identical to +-- input.pointer -- nothing else depends on suppressing a wheel event, so +-- there is no real behavior to gate here, only to watch. function Game:wheelmoved(_, dy) - if dy > 0 then - self:zoomStep(1) - elseif dy < 0 then - self:zoomStep(-1) + local function vanilla() + if dy > 0 then + self:zoomStep(1) + elseif dy < 0 then + self:zoomStep(-1) + end end + if not ModRuntime.wantsHook("input.wheel") then return vanilla() end + return ModRuntime.call("input.wheel", vanilla, self, dy) end function Game:_cycleSpeed(dir) @@ -800,102 +807,119 @@ function Game:_cycleSpeed(dir) self:writeOptions() end +-- RFC 0020: input.key fires before ANY of this method's own logic runs -- +-- including the top-of-stack capture two lines down, which is itself an +-- engine-owned "first refusal" system (BindingsMenu et al). This mirrors +-- the exact precedence a mod had before the sandbox changes (commit +-- 83682f01), when a mod's Lua chunk ran with the real, unsandboxed love.* +-- globals and could simply reassign love.keypressed itself -- there was +-- nothing an engine-internal capture could do to go first, because the mod +-- WAS the entry point. `vanilla` below is this method's entire pre-RFC-0020 +-- body, unchanged; a mod that returns without calling `next(...)` prevents +-- every bit of it -- the stack capture, every hotkey, Input:keypressed -- +-- from running for this key at all, same as the old global override always +-- could. See RFC 0020's Motivation section for the comparison this was +-- cross-referenced against. function Game:keypressed(key) - if self.stack and self.stack:top() and self.stack:top().onKeyPressed then - self.stack:top():onKeyPressed(key) - return - end - if devMode and key == "f5" then - require("src.dev.HotReload").run(self) - return - end - if devMode and key == "`" then - self.stack:push(require("src.dev.Console").new(self)) - return - end - if key == "f10" then - -- toggle: the manager no longer swallows the keyboard, so a second - -- press reaches this branch and closes it instead of stacking another - local top = self.stack:top() - if top and top.screenId == "ManagerState" then - self.stack:pop() - else - Screens.push(self, "ManagerState") + local function vanilla() + if self.stack and self.stack:top() and self.stack:top().onKeyPressed then + self.stack:top():onKeyPressed(key) + return end - return - end - if key == "f1" then - if not self:quickSaveAllowed() then return end - self:writeSave() - return - elseif key == "f2" then - if not self:quickSaveAllowed() then return end - local loaded, recovered = SaveData.load() - if loaded then - -- F2 jumps straight to the loaded save's map/position, with no - -- walking transition -- a hard state teleport like Continue, not a - -- smooth warp -- whether pressed at the title screen or mid-session. - self:restoreSave(loaded, recovered, { freshBoot = true }) + if devMode and key == "f5" then + require("src.dev.HotReload").run(self) + return end - return - elseif key == "-" then - self:zoomStep(-1) - return - elseif key == "=" then - self:zoomStep(1) - return - elseif key == "1" then - -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); - -- shoulders/triggers on gamepad do the same (see gamepadpressed) - self:_cycleSpeed(1) - return - elseif key == "2" then - -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change - -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so - -- hold it while a warp/transition or an on-screen scripted cutscene is - -- driving the overworld rather than tear the escort's NPCs out mid-move - local ow = self.overworld - local top = self.stack:top() - local busy = ow and (ow.transitioning - or (top == ow and ( - (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) - or (ow.scriptMoves and #ow.scriptMoves > 0) - or ow.engaging or ow.emote))) - if not busy then - local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() + if devMode and key == "`" then + self.stack:push(require("src.dev.Console").new(self)) + return + end + if key == "f10" then + -- toggle: the manager no longer swallows the keyboard, so a second + -- press reaches this branch and closes it instead of stacking another + local top = self.stack:top() + if top and top.screenId == "ManagerState" then + self.stack:pop() + else + Screens.push(self, "ManagerState") + end + return + end + if key == "f1" then + if not self:quickSaveAllowed() then return end + self:writeSave() + return + elseif key == "f2" then + if not self:quickSaveAllowed() then return end + local loaded, recovered = SaveData.load() + if loaded then + -- F2 jumps straight to the loaded save's map/position, with no + -- walking transition -- a hard state teleport like Continue, not a + -- smooth warp -- whether pressed at the title screen or mid-session. + self:restoreSave(loaded, recovered, { freshBoot = true }) + end + return + elseif key == "-" then + self:zoomStep(-1) + return + elseif key == "=" then + self:zoomStep(1) + return + elseif key == "1" then + -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); + -- shoulders/triggers on gamepad do the same (see gamepadpressed) + self:_cycleSpeed(1) + return + elseif key == "2" then + -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change + -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so + -- hold it while a warp/transition or an on-screen scripted cutscene is + -- driving the overworld rather than tear the escort's NPCs out mid-move + local ow = self.overworld + local top = self.stack:top() + local busy = ow and (ow.transitioning + or (top == ow and ( + (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) + or ow.engaging or ow.emote))) + if not busy then + local PaletteFX = require("src.render.PaletteFX") + self.save.options.colors = PaletteFX.cycleMode() + self:writeOptions() + end + return + elseif key == "3" then + -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only + local Tilt = require("src.render.Tilt") + if Tilt.gateOK(self.stack:top(), self.overworld) then + self.save.options.tilt = Tilt.cycle() + self:writeOptions() + end + return + elseif key == "4" then + -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) + local Zoom = require("src.render.Zoom") + if Zoom.gateOK(self.stack:top(), self.overworld) then + self.save.options.zoom = Zoom.cycle(Renderer:fitScale()) + self:writeOptions() + end + return + end + -- Mod render pipelines claim their hotkeys last, so one can never shadow + -- an engine display key however a mod declares it (12 §rendering + -- pipelines). syncOptions writes the whole ladder back, including the + -- tilt exclusion a world pipeline forces. + local Pipelines = require("src.render.Pipelines") + if Pipelines.hotkey(key, self.stack:top(), self.overworld) then + Pipelines.syncOptions(self.save.options) + require("src.render.Tilt").setLevel(self.save.options.tilt or 0) self:writeOptions() + return end - return - elseif key == "3" then - -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only - local Tilt = require("src.render.Tilt") - if Tilt.gateOK(self.stack:top(), self.overworld) then - self.save.options.tilt = Tilt.cycle() - self:writeOptions() - end - return - elseif key == "4" then - -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) - local Zoom = require("src.render.Zoom") - if Zoom.gateOK(self.stack:top(), self.overworld) then - self.save.options.zoom = Zoom.cycle(Renderer:fitScale()) - self:writeOptions() - end - return + Input:keypressed(key) end - -- Mod render pipelines claim their hotkeys last, so one can never shadow - -- an engine display key however a mod declares it (12 §rendering - -- pipelines). syncOptions writes the whole ladder back, including the - -- tilt exclusion a world pipeline forces. - local Pipelines = require("src.render.Pipelines") - if Pipelines.hotkey(key, self.stack:top(), self.overworld) then - Pipelines.syncOptions(self.save.options) - require("src.render.Tilt").setLevel(self.save.options.tilt or 0) - self:writeOptions() - return - end - Input:keypressed(key) + if not ModRuntime.wantsHook("input.key") then return vanilla() end + return ModRuntime.call("input.key", vanilla, self, { phase = "pressed", key = key }) end -- Mod enablement is stored with persistent options. Restarting the actual @@ -911,64 +935,100 @@ end -- exists for). The top state only OBSERVES the release afterwards, -- unlike onKeyPressed above which owns the press, so BindingsMenu can -- commit a capture on the key-up (#589). +-- RFC 0020: same "fires before everything, vanilla is the untouched whole +-- body" contract as Game:keypressed above -- including here, before +-- Input:keyreleased. Before the sandbox changes, a mod's love.keyreleased +-- override had this same absolute precedence; #589's own "release must +-- still reach Input even under a top-state capture" hazard was about the +-- ENGINE's internal capture ladder, not about a mod -- mods could already +-- swallow a release outright before the sandbox changes (kanto_companion's own +-- Game:gamepadreleased-era override did exactly this for its active-drag +-- case, see RFC 0020), so a mod choosing to do the same here via +-- input.key is restoring that power, not introducing a new hazard class. function Game:keyreleased(key) - Input:keyreleased(key) - local top = self.stack and self.stack:top() - if top and top.onKeyReleased then top:onKeyReleased(key) end + local function vanilla() + Input:keyreleased(key) + local top = self.stack and self.stack:top() + if top and top.onKeyReleased then top:onKeyReleased(key) end + end + if not ModRuntime.wantsHook("input.key") then return vanilla() end + return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key }) end +-- RFC 0020: input.gamepad covers press/release/axis (see this method, +-- Game:gamepadreleased, and Game:gamepadaxis below) -- three raw callbacks +-- feeding one hook, because the motivating use case (a stick-driven +-- cursor) needs raw axis values a button-only hook could never deliver. +-- Same "fires first, vanilla is the whole untouched body" contract as +-- Game:keypressed; see its comment and RFC 0020 for the precedent this +-- restores. function Game:gamepadpressed(joystick, button) - -- a controller is being used: the touch overlay steps aside until the - -- next screen touch (mobile only; a no-op elsewhere) - TouchControls:noteGamepad() - -- Select held? Needed both to suppress shoulder speed hotkeys (Select+L - -- is a display chord on NX) and for the chord path below. - local selectHeld = Input:isDown("select") - if not selectHeld and joystick and joystick.isGamepadDown then - local ok, down = pcall(function() - return joystick:isGamepadDown("back") - end) - selectHeld = ok and down == true - end - local top = self.stack and self.stack:top() - if top and top.onGamepadPressed then - top:onGamepadPressed(button) - return - end - if not selectHeld then - local action = Input:padAction(button) - if action == "speedUp" then - self:_cycleSpeed(1) - return - elseif action == "speedDown" then - self:_cycleSpeed(-1) + local function vanilla() + -- a controller is being used: the touch overlay steps aside until the + -- next screen touch (mobile only; a no-op elsewhere) + TouchControls:noteGamepad() + -- Select held? Needed both to suppress shoulder speed hotkeys (Select+L + -- is a display chord on NX) and for the chord path below. + local selectHeld = Input:isDown("select") + if not selectHeld and joystick and joystick.isGamepadDown then + local ok, down = pcall(function() + return joystick:isGamepadDown("back") + end) + selectHeld = ok and down == true + end + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) return end - end - -- Select+face display chords → same digit path as Game:keypressed - -- (COLORS/TILT/pipelines). Intercept before Input so face does not - -- also fire GB A/B. Dual-path: raw already ignored when isGamepad(). - if selectHeld then - local digit = GamepadMap.displayChordDigit(button) - if digit then - self:keypressed(digit) - return + if not selectHeld then + local action = Input:padAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end end + -- Select+face display chords → same digit path as Game:keypressed + -- (COLORS/TILT/pipelines). Intercept before Input so face does not + -- also fire GB A/B. Dual-path: raw already ignored when isGamepad(). + if selectHeld then + local digit = GamepadMap.displayChordDigit(button) + if digit then + self:keypressed(digit) + return + end + end + Input:gamepadpressed(joystick, button) end - Input:gamepadpressed(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "pressed", joystick = joystick, button = button }) end function Game:gamepadreleased(joystick, button) - -- same observe-after-Input contract as Game:keyreleased (#589) - Input:gamepadreleased(joystick, button) - local top = self.stack and self.stack:top() - if top and top.onGamepadReleased then top:onGamepadReleased(button) end + local function vanilla() + -- same observe-after-Input contract as Game:keyreleased (#589) + Input:gamepadreleased(joystick, button) + local top = self.stack and self.stack:top() + if top and top.onGamepadReleased then top:onGamepadReleased(button) end + end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "released", joystick = joystick, button = button }) end function Game:gamepadaxis(joystick, axis, value) - -- past-deadzone only, so resting-stick drift can't hide the overlay - if math.abs(value) > 0.5 then TouchControls:noteGamepad() end - Input:gamepadaxis(joystick, axis, value) + local function vanilla() + -- past-deadzone only, so resting-stick drift can't hide the overlay + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:gamepadaxis(joystick, axis, value) + end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "axis", joystick = joystick, axis = axis, value = value }) end local isAccelerometer = GamepadMap.isAccelerometer diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 14b715af..4b96961a 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2052,32 +2052,49 @@ function Game2:pipelineHotkey(key, options, persist) return true end +-- RFC 0020: see Game:keypressed's own comment (src/core/Game.lua) for the +-- full precedent this restores -- fires before self:hotkey, +-- before Input:keypressed, before anything else in this method. function Game2:keypressed(key) - -- Escape is NOT a quit key: src/core/Input.lua binds it to START, which is - -- how the start menu opens on a desktop keyboard. Quitting is the start - -- menu's QUIT row and the intro menu's EXIT GAME. - -- A screen that is open owns the keyboard, the same way Game hands the top - -- state first refusal -- except for the display ladder, which is a host - -- control rather than a game button. It runs during the boot cinema too: - -- the title screen and the intro menu are exactly where someone tries the - -- COLOR key, and the ladder's world-only rungs already refuse themselves - -- when there is no map. - if self:hotkey(key) then return end - Input:keypressed(key) + local function vanilla() + -- Escape is NOT a quit key: src/core/Input.lua binds it to START, which is + -- how the start menu opens on a desktop keyboard. Quitting is the start + -- menu's QUIT row and the intro menu's EXIT GAME. + -- A screen that is open owns the keyboard, the same way Game hands the top + -- state first refusal -- except for the display ladder, which is a host + -- control rather than a game button. It runs during the boot cinema too: + -- the title screen and the intro menu are exactly where someone tries the + -- COLOR key, and the ladder's world-only rungs already refuse themselves + -- when there is no map. + if self:hotkey(key) then return end + Input:keypressed(key) + end + if not ModRuntime.wantsHook("input.key") then return vanilla() end + return ModRuntime.call("input.key", vanilla, self, { phase = "pressed", key = key }) end function Game2:keyreleased(key) - Input:keyreleased(key) + local function vanilla() + Input:keyreleased(key) + end + if not ModRuntime.wantsHook("input.key") then return vanilla() end + return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key }) end +-- RFC 0020: input.wheel is a plain observer -- see Game:wheelmoved's own +-- comment (src/core/Game.lua). function Game2:wheelmoved(_x, dy) - if self.phase == "boot" or self.stack:top() then return end - if not (self.world and self.world.map) then return end - if dy > 0 then - self.world:zoomStep(1) - elseif dy < 0 then - self.world:zoomStep(-1) + local function vanilla() + if self.phase == "boot" or self.stack:top() then return end + if not (self.world and self.world.map) then return end + if dy > 0 then + self.world:zoomStep(1) + elseif dy < 0 then + self.world:zoomStep(-1) + end end + if not ModRuntime.wantsHook("input.wheel") then return vanilla() end + return ModRuntime.call("input.wheel", vanilla, self, dy) end -- ---- the gameplay pointer seam (#807) -------------------------------------- @@ -2304,55 +2321,73 @@ end -- the PACK's move-item, the party menu's reorder and half the soft-reset chord -- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button -- to find out killed the process. It reaches Input like every other button now. +-- RFC 0020: input.gamepad covers press/release/axis, see Game:gamepadpressed's +-- own comment (src/core/Game.lua) for why, and for the precedent this +-- restores. function Game2:gamepadpressed(joystick, button) - -- a controller is being used: the touch overlay steps aside until the next - -- screen touch (mobile only; a no-op elsewhere) - TouchControls:noteGamepad() - local selectHeld = Input:isDown("select") - if not selectHeld and joystick and joystick.isGamepadDown then - local ok, down = pcall(function() - return joystick:isGamepadDown("back") - end) - selectHeld = ok and down == true - end - local top = self.stack and self.stack:top() - if top and top.onGamepadPressed then - top:onGamepadPressed(button) - return - end - if not selectHeld then - local action = Input:padAction(button) - if action == "speedUp" then - self:_cycleSpeed(1) - return - elseif action == "speedDown" then - self:_cycleSpeed(-1) + local function vanilla() + -- a controller is being used: the touch overlay steps aside until the next + -- screen touch (mobile only; a no-op elsewhere) + TouchControls:noteGamepad() + local selectHeld = Input:isDown("select") + if not selectHeld and joystick and joystick.isGamepadDown then + local ok, down = pcall(function() + return joystick:isGamepadDown("back") + end) + selectHeld = ok and down == true + end + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) return end - end - if selectHeld then - local digit = GamepadMap.displayChordDigit(button) - if digit then - self:keypressed(digit) - return + if not selectHeld then + local action = Input:padAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end end - end - -- START opens the start menu in the overworld; it used to quit, from before - -- there was a menu to open. + if selectHeld then + local digit = GamepadMap.displayChordDigit(button) + if digit then + self:keypressed(digit) + return + end + end + -- START opens the start menu in the overworld; it used to quit, from before + -- there was a menu to open. - Input:gamepadpressed(joystick, button) + Input:gamepadpressed(joystick, button) + end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "pressed", joystick = joystick, button = button }) end function Game2:gamepadreleased(joystick, button) - Input:gamepadreleased(joystick, button) - local top = self.stack and self.stack:top() - if top and top.onGamepadReleased then top:onGamepadReleased(button) end + local function vanilla() + Input:gamepadreleased(joystick, button) + local top = self.stack and self.stack:top() + if top and top.onGamepadReleased then top:onGamepadReleased(button) end + end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "released", joystick = joystick, button = button }) end function Game2:gamepadaxis(joystick, axis, value) - -- past-deadzone only, so resting-stick drift cannot hide the overlay - if math.abs(value) > 0.5 then TouchControls:noteGamepad() end - Input:gamepadaxis(joystick, axis, value) + local function vanilla() + -- past-deadzone only, so resting-stick drift cannot hide the overlay + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:gamepadaxis(joystick, axis, value) + end + if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end + return ModRuntime.call("input.gamepad", vanilla, self, + { phase = "axis", joystick = joystick, axis = axis, value = value }) end -- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570). diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index c8adb8d9..c0fa8991 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -445,6 +445,10 @@ local GEN2_HOOKS = { -- pointer with the touch overlay given first refusal, the palette zone list -- handed to the present pass, the letterbox and the HUD rect. "input.step", "input.pointer", + -- RFC 0020: keyboard, gamepad (press/release/axis), and wheel -- raised + -- from Game2.lua's keypressed/keyreleased/gamepadpressed/gamepadreleased/ + -- gamepadaxis/wheelmoved, same call-site shape as input.pointer above. + "input.key", "input.gamepad", "input.wheel", "render.zones", "render.compose", "render.output_enabled", "render.output", "render.letterbox", "render.hud", } diff --git a/tests/modkit/cases/key_gamepad_wheel_input.lua b/tests/modkit/cases/key_gamepad_wheel_input.lua new file mode 100644 index 00000000..a0e4e185 --- /dev/null +++ b/tests/modkit/cases/key_gamepad_wheel_input.lua @@ -0,0 +1,217 @@ +-- RFC 0020: input.key, input.gamepad, and input.wheel, through the public +-- mod API. Driven the way main.lua drives them -- through Game's own +-- keypressed/keyreleased/gamepadpressed/gamepadreleased/gamepadaxis/ +-- wheelmoved, against the real Input singleton -- so a green run means the +-- wiring, not just the buses. Structured after tests/modkit/cases/ +-- pointer_input.lua, the direct precedent for a raw-input hook family. +-- +-- The one property that matters beyond "the hook fires": input.key and +-- input.gamepad give a mod REAL suppression, unlike input.pointer's own +-- (inert) consume contract -- a wrapper that returns without calling +-- `next` must stop the vanilla body (Input:keypressed/gamepadpressed/ +-- gamepadaxis, the engine's own hotkey ladder) from running at all. That +-- is asserted directly below, not just that the payload looks right. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local Runtime = require("src.mods.Runtime") + +-- Minimal stand-in stack: Game:keypressed's vanilla body reaches +-- self.stack:top() unconditionally near its end (feeding Pipelines.hotkey), +-- even for a key no hotkey claims, so this needs to resolve, not just be +-- absent the way pointer_input.lua's fakeGame leaves it. +local function fakeGame(loader, data) + return setmetatable( + { input = Input, mods = loader, data = data, + stack = { top = function() return nil end } }, + { __index = Game }) +end + +-- ------- fixture mod: journals every event, and swallows the specific +-- keys/buttons/axes an "edit mode" would grab, proving real suppression + +local FIXTURES = { + ["mods/fix_key_gamepad_wheel/manifest.json"] = [[{ + "id": "fix_key_gamepad_wheel", + "name": "Fixture Key Gamepad Wheel Watcher", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_key_gamepad_wheel/main.lua"] = [[ + local mod = ... + local seenKey, seenPad, seenWheel = {}, {}, {} + mod.exports.seenKey, mod.exports.seenPad, mod.exports.seenWheel = + seenKey, seenPad, seenWheel + local swallow = false + mod.exports.setSwallow = function(v) swallow = v end + + mod.hooks:wrap("input.key", function(nextFn, game, ev) + seenKey[#seenKey + 1] = { phase = ev.phase, key = ev.key } + if swallow and ev.key == "left" then return "SWALLOWED" end + return nextFn(game, ev) + end) + + mod.hooks:wrap("input.gamepad", function(nextFn, game, ev) + seenPad[#seenPad + 1] = { phase = ev.phase, button = ev.button, + axis = ev.axis, value = ev.value } + if swallow and ev.phase == "pressed" and ev.button == "a" then + return "SWALLOWED" + end + if swallow and ev.phase == "axis" and ev.axis == "leftx" then + return "SWALLOWED" + end + return nextFn(game, ev) + end) + + mod.hooks:wrap("input.wheel", function(nextFn, game, dy) + seenWheel[#seenWheel + 1] = dy + if swallow then return "SWALLOWED" end + return nextFn(game, dy) + end) + ]], +} + +-- ------- no-mod parity: all three cost nothing unsubscribed, and vanilla +-- really runs (checked via the real Input singleton, not just "no crash") + +do + local run = T.sdk.loadNone({}) + Input:init() + local game = fakeGame(run.loader, run.data) + T.eq(Runtime.wantsHook("input.key"), false, + "no subscriber: wantsHook(\"input.key\") is false") + T.eq(Runtime.wantsHook("input.gamepad"), false, + "no subscriber: wantsHook(\"input.gamepad\") is false") + T.eq(Runtime.wantsHook("input.wheel"), false, + "no subscriber: wantsHook(\"input.wheel\") is false") + + -- "up" is a DEFAULT_BINDINGS identity mapping (raw key "up" -> GB + -- button "up"), unlike most letter keys, which remap to a differently + -- named GB button (e.g. raw "z" -> GB "a") -- using an identity-mapped + -- key here means Input:isDown can check the exact name just pressed. + Game.keypressed(game, "up") + T.eq(Input:isDown("up"), true, + "no subscriber: keypressed still reaches Input (vanilla ran)") + Game.keyreleased(game, "up") + T.eq(Input:isDown("up"), false, + "no subscriber: keyreleased still reaches Input") + + Game.gamepadpressed(game, nil, "b") + T.eq(Input:isDown("b"), true, + "no subscriber: gamepadpressed still reaches Input") + Game.gamepadreleased(game, nil, "b") + T.eq(Input:isDown("b"), false, + "no subscriber: gamepadreleased still reaches Input") + Game.gamepadaxis(game, nil, "leftx", 0.9) -- must not error with no chain + + local zoomedBefore = game.save + Game.wheelmoved(game, 0, 1) -- must not error; Game.wheelmoved needs no save + run.release() +end + +-- ------- the subscribed path, through the loader-created mod API + +local run = T.sdk.loadMods( + { "mods/fix_key_gamepad_wheel" }, { fs = T.sdk.memfs(FIXTURES) }) +T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + +local fx = run.loader.exports.fix_key_gamepad_wheel +local game = fakeGame(run.loader, run.data) +Input:init() + +local function wipe() + for _, t in ipairs({ fx.seenKey, fx.seenPad, fx.seenWheel }) do + for i = #t, 1, -1 do t[i] = nil end + end +end + +-- input.key: payload shape, and observing (calling next) changes nothing +do + wipe() + fx.setSwallow(false) + Game.keypressed(game, "up") + Game.keyreleased(game, "up") + T.eq(#fx.seenKey, 2, "keypressed+keyreleased each reach the hook once") + T.eq(fx.seenKey[1].phase, "pressed", "...pressed first") + T.eq(fx.seenKey[1].key, "up", "...carrying the key") + T.eq(fx.seenKey[2].phase, "released", "...released second") + T.eq(Input:isDown("up"), false, "calling next() lets the release reach Input") +end + +-- input.key: NOT calling next is real suppression -- the game never sees it +do + wipe() + fx.setSwallow(true) + Game.keypressed(game, "left") + T.eq(#fx.seenKey, 1, "the swallowed key still reaches the hook once") + T.eq(Input:isDown("left"), false, + "a wrapper that returns without calling next prevents Input:keypressed " + .. "from ever running -- real suppression, not observation") + fx.setSwallow(false) + Game.keyreleased(game, "left") -- clean up any stray state +end + +-- input.key: a key the mod doesn't care about still falls all the way +-- through to Input, even while swallow is armed for a different key +do + wipe() + fx.setSwallow(true) + Game.keypressed(game, "right") + T.eq(#fx.seenKey, 1, "an unrelated key still reaches the hook") + T.eq(Input:isDown("right"), true, + "...and still reaches Input, since this wrapper only swallows \"left\"") + Game.keyreleased(game, "right") + fx.setSwallow(false) +end + +-- input.gamepad: press/release/axis all reach the hook with the right shape +do + wipe() + fx.setSwallow(false) + Game.gamepadpressed(game, nil, "b") + Game.gamepadreleased(game, nil, "b") + Game.gamepadaxis(game, nil, "lefty", 0.4) + T.eq(#fx.seenPad, 3, "press, release, and axis each reach the hook once") + T.eq(fx.seenPad[1].phase, "pressed", "...pressed first") + T.eq(fx.seenPad[1].button, "b", "...carrying the button") + T.eq(fx.seenPad[2].phase, "released", "...released second") + T.eq(fx.seenPad[3].phase, "axis", "...axis third") + T.eq(fx.seenPad[3].button, nil, "axis carries no button") + T.eq(fx.seenPad[3].axis, "lefty", "...carrying the axis name") + T.eq(fx.seenPad[3].value, 0.4, "...and its value") + T.eq(Input:isDown("b"), false, "observed release still reaches Input") +end + +-- input.gamepad: real suppression on a pressed button +do + wipe() + fx.setSwallow(true) + Game.gamepadpressed(game, nil, "a") + T.eq(#fx.seenPad, 1, "the swallowed press still reaches the hook once") + T.eq(Input:isDown("a"), false, + "a wrapper that returns without calling next prevents " + .. "Input:gamepadpressed from ever running") + fx.setSwallow(false) +end + +-- input.wheel: reaches the hook, and consuming it stops the zoom call from +-- happening (checked as "no error and only one event seen", since Game's +-- own zoomStep needs a real self.save this fixture does not provide -- +-- the point under test is whether `next` runs, not what zoomStep does) +do + wipe() + fx.setSwallow(false) + Game.wheelmoved(game, 0, 1) + T.eq(#fx.seenWheel, 1, "a wheel event reaches the hook") + T.eq(fx.seenWheel[1], 1, "...carrying the raw delta") +end + +run.release() + +T.finish("key_gamepad_wheel_input")