diff --git a/src/core/Game.lua b/src/core/Game.lua index d38db05a..11d86c5a 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -793,11 +793,13 @@ function Game:joystickhat(joystick, hat, direction) end -- Window focus/visibility flips: a release due while unfocused/hidden can --- be swallowed by the OS. Reset on both edges -- gaining focus with a --- physically held key won't re-fire keypressed, so trusting leftover --- state is worse than asking the player to re-press. +-- be swallowed by the OS. Reset on both edges; on the regain, reconcile +-- re-arms only what is still physically held -- a held key won't re-fire +-- keypressed by itself, and without the rebuild a spurious lifecycle event +-- parked the player until every direction was re-pressed (#799). function Game:focus(f) Input:reset() + if f then Input:reconcile() end TouchControls:reset() self:cancelPointers() end @@ -814,6 +816,7 @@ end function Game:onResume() Input:reset() + Input:reconcile() TouchControls:reset() self:cancelPointers() -- Chip music may survive NX suspend as a duplicate stream; stop it and let @@ -830,6 +833,10 @@ end function Game:recoverInput(event, joystick) Input:reset() + -- A hotplug can arrive with no hotplug (macOS Bluetooth re-enumeration), + -- and the blanket reset above also drops unrelated keyboard holds; put + -- back whatever is still physically down (#799). + Input:reconcile() TouchControls:reset() -- reset just dropped every source, mod holds included: retire the mods' -- outstanding press tokens so nothing stale can be released later, and diff --git a/src/core/Input.lua b/src/core/Input.lua index a576e15d..33543c97 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -301,6 +301,71 @@ function Input:joystickhat(joystick, hat, direction) self.hatDirs[hat] = dirs end +-- Lifecycle resets (focus/visibility flips, joystick add/remove, resume) +-- wipe held state because a release can be swallowed while the OS owns the +-- event stream. A direction the player is STILL holding never re-fires +-- keypressed/gamepadpressed after the wipe either, so a spurious reset -- +-- macOS re-enumerating a Bluetooth pad fires joystickadded with no hotplug, +-- and the blanket reset took unrelated keyboard holds down with it -- +-- parked the player in place until every direction was released and +-- pressed again (#799). Rebuild holds from the devices' ground truth +-- instead: only what is physically down right now comes back, so the +-- swallowed-release hazards the resets guard against stay cleared. +-- Deliberately separate from reset(): the soft-reset chord path in +-- Game:step needs the clean slate (re-arming A there would read it as a +-- title-menu choice). +function Input:reconcile() + local kb = love and love.keyboard + if kb and kb.isDown then + for key, btn in pairs(self.keyBindings) do + local ok, down = pcall(kb.isDown, key) + if ok and down then press(self, btn, "key:" .. key) end + end + end + local js = love and love.joystick + if not (js and js.getJoysticks) then return end + local ok, joysticks = pcall(js.getJoysticks) + if not ok or type(joysticks) ~= "table" then return end + for _, j in ipairs(joysticks) do + if GamepadMap.ignoreRawForJoystick(j) then + -- SDL-recognized pad: buttons + left stick, the gamepad surfaces + if j.isGamepadDown then + for button, btn in pairs(self.padBindings) do + local ok2, down = pcall(j.isGamepadDown, j, button) + if ok2 and down then press(self, btn, "pad:" .. button) end + end + end + if j.getGamepadAxis then + for _, axis in ipairs({ "leftx", "lefty" }) do + local ok2, v = pcall(j.getGamepadAxis, j, axis) + if ok2 and type(v) == "number" then self:gamepadaxis(j, axis, v) end + end + end + else + -- raw stick (#620/#632): the surfaces the joystick* events feed + if j.isDown then + for index, btn in pairs(self.joyBindings) do + local ok2, down = pcall(j.isDown, j, index) + if ok2 and down then press(self, btn, "joy:" .. index) end + end + end + if j.getAxis then + for _, axis in ipairs({ 1, 2 }) do + local ok2, v = pcall(j.getAxis, j, axis) + if ok2 and type(v) == "number" then self:joystickaxis(j, axis, v) end + end + end + if j.getHatCount and j.getHat then + local ok2, count = pcall(j.getHatCount, j) + for hat = 1, (ok2 and count) or 0 do + local ok3, dir = pcall(j.getHat, j, hat) + if ok3 and dir then self:joystickhat(j, hat, dir) end + end + end + end + end +end + function Input:isDown(btn) return self.state[btn] or false end diff --git a/tests/engine/input_hold_reconcile_test.lua b/tests/engine/input_hold_reconcile_test.lua new file mode 100644 index 00000000..7fc0fe5e --- /dev/null +++ b/tests/engine/input_hold_reconcile_test.lua @@ -0,0 +1,87 @@ +-- Held directions must survive lifecycle resets while physically held +-- (#799). Input state is event-driven, so any Input:reset (focus or +-- visibility flip, joystick add/remove, resume) wipes a hold that never +-- re-fires keypressed afterwards -- on macOS a Bluetooth controller +-- re-enumerating mid-walk fired joystickadded under a held key and parked +-- the player until the direction was released and pressed again. Game's +-- lifecycle handlers rebuild holds from device ground truth after each +-- reset; only what is physically down comes back, so the swallowed-release +-- hazards the resets guard against stay cleared. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check + +local Input = require("src.core.Input") +local Game = require("src.core.Game") + +local realIsDown = love.keyboard.isDown +local realJoystick = love.joystick +local function restore() + love.keyboard.isDown = realIsDown + love.joystick = realJoystick +end + +Input:init() + +-- Keyboard: direction still physically held across a spurious pad +-- re-enumeration must keep walking (the #799 report). +love.keyboard.isDown = function(key) return key == "up" end +Input:reset() +Input:keypressed("up") +Input:step() +check(Input:isDown("up"), "up held before joystick re-enumeration") +Game:joystickadded({ getName = function() return "Wireless Controller" end }) +check(Input:isDown("up"), "held key survives a spurious joystickadded") + +-- Same hold across a focus bounce (another reset source). +Game:focus(false) +check(not Input:isDown("up"), "focus loss still drops the hold") +Game:focus(true) +check(Input:isDown("up"), "held key re-arms on focus regain") + +-- A key released while unfocused (keyup swallowed by the OS, the hazard +-- the resets exist for) must NOT come back. +love.keyboard.isDown = function() return false end +Game:focus(false) +Game:focus(true) +check(not Input:isDown("up"), "swallowed release still clears the hold") + +-- Controller: held d-pad across a disconnect/reconnect bounce. +local pad = { + isGamepad = function() return true end, + isGamepadDown = function(_, button) return button == "dpleft" end, + getGamepadAxis = function() return 0 end, +} +love.joystick = { getJoysticks = function() return { pad } end } +love.keyboard.isDown = function() return false end +Input:reset() +Input:gamepadpressed(pad, "dpleft") +Input:step() +check(Input:isDown("left"), "d-pad held before reconnect") +Game:joystickremoved(pad) +Game:joystickadded(pad) +check(Input:isDown("left"), "held d-pad survives a reconnect bounce") + +-- Held stick across the same bounce (axis ground truth re-derived). +pad.isGamepadDown = function() return false end +pad.getGamepadAxis = function(_, axis) return axis == "leftx" and -0.9 or 0 end +Input:reset() +Input:gamepadaxis(pad, "leftx", -0.9) +Input:step() +check(Input:isDown("left"), "stick held before re-enumeration") +Game:joystickadded(pad) +check(Input:isDown("left"), "held stick survives re-enumeration") + +-- A pad that vanished for real reports nothing held: its stale hold must +-- stay cleared (the stuck-flag hazard reset-on-remove guards against). +love.joystick = { getJoysticks = function() return {} end } +Input:reset() +Input:gamepadpressed(pad, "dpleft") +Input:step() +Game:joystickremoved(pad) +check(not Input:isDown("left"), "vanished pad's hold stays cleared") + +restore() +T.finish()