From 0fa8206321c783de2da01c0ef282a4f3ab465db3 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 4 Aug 2026 15:13:06 -0400 Subject: [PATCH] CLOSES #785, CLOSES #807, CLOSES #811, CLOSES #814 --- docs/modding.md | 26 ++ main.lua | 54 ++- src/battle/BattleState.lua | 22 +- src/battle/MoveEffects.lua | 28 +- src/battle/WideBattle.lua | 5 +- src/core/Game.lua | 118 ++++++- src/core/Input.lua | 15 + src/core/TouchControls.lua | 9 +- src/dev/HotReload.lua | 6 + src/mods/Loader.lua | 79 +++++ src/ui/ListMenu.lua | 7 +- src/ui/PartyMenu.lua | 6 +- .../battle_menu_default_bug770_test.lua | 101 ++++++ tests/drivers/move_swap_arrow_bug814_test.lua | 96 +++++ tests/modkit/cases/pointer_input.lua | 327 ++++++++++++++++++ 15 files changed, 862 insertions(+), 37 deletions(-) create mode 100644 tests/drivers/battle_menu_default_bug770_test.lua create mode 100644 tests/drivers/move_swap_arrow_bug814_test.lua create mode 100644 tests/modkit/cases/pointer_input.lua diff --git a/docs/modding.md b/docs/modding.md index 5aa0bec0..69035af3 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -174,6 +174,32 @@ It runs immediately before queued button edges are promoted, so input added by the wrapper is visible during that same fixed step. The callback receives `(next, game, dt)` and must call `next(game, dt)`. +`input.pointer` delivers uncaptured gameplay pointer events -- touches and +real mouse input alike. The callback receives `(next, game, ev)` where `ev` +is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is +`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"` +or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates +are LOVE window units, the same space `render.hud`'s viewport and the touch +overlay lay out in. The on-screen touch controls keep first refusal: a +pointer that begins on a virtual control belongs to the pad for its whole +lifecycle and never reaches the hook, while one that begins outside stays +visible even if it later crosses a control. A real mouse reaches the hook +without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so +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. + +`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 +`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`, +`b`, `start` and `select`. Every press is its own input source inside the +engine's multi-source bookkeeping, so releasing a token never clears a hold +the keyboard, a controller, the touch overlay or another mod still owns; +`release` is idempotent and refuses tokens taken by another mod. +Outstanding tokens are released automatically on entry-chunk rollback, hot +reload and input recovery. + `ui.title_menu.items` receives `(next, game, items)` and follows the same decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place for a tool to offer a fresh-session action before gameplay begins. diff --git a/main.lua b/main.lua index 07f98b45..2bef7b16 100644 --- a/main.lua +++ b/main.lua @@ -609,7 +609,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) -- Android's synthesized mouse twin so Import cannot double-fire (#553). return Importer:touchpressed(id, x, y, dx, dy, pressure) end - Game:touchpressed(id, x, y) + Game:touchpressed(id, x, y, dx, dy, pressure) end function love.touchmoved(id, x, y, dx, dy, pressure) @@ -621,7 +621,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) if Importer then return Importer:touchmoved(id, x, y, dx, dy, pressure) end - Game:touchmoved(id, x, y) + Game:touchmoved(id, x, y, dx, dy, pressure) end function love.touchreleased(id, x, y, dx, dy, pressure) @@ -633,7 +633,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) if Importer then return Importer:touchreleased(id, x, y, dx, dy, pressure) end - Game:touchreleased(id, x, y) + Game:touchreleased(id, x, y, dx, dy, pressure) end function love.wheelmoved(x, y) @@ -670,12 +670,19 @@ function love.mousepressed(x, y, button, istouch) if istouch and love.system.getOS() == "Android" then return end return EditorApp.mousepressed(x, y, button) end - if mouseTouch and Game and button == 1 then - Game:touchpressed("mouse", x, y) + if mouseTouch then + -- the mouse is standing in for a finger: the touch path owns it, and + -- feeding the same press back in as a mouse pointer would double it + if Game and button == 1 then Game:touchpressed("mouse", x, y) end + return end + -- #807: a real mouse reaches gameplay as a pointer event for mods; Game + -- drops synthesized istouch twins so a mobile touch that already arrived + -- through love.touchpressed cannot fire twice + if Game then Game:mousepressed(x, y, button, istouch) end end -function love.mousereleased(x, y, button) +function love.mousereleased(x, y, button, istouch) if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) @@ -684,20 +691,24 @@ function love.mousereleased(x, y, button) if editorMode and EditorApp.mousereleased then return EditorApp.mousereleased(x, y, button) end - if mouseTouch and Game and button == 1 then - Game:touchreleased("mouse", x, y) + if mouseTouch then + if Game and button == 1 then Game:touchreleased("mouse", x, y) end + return end + if Game then Game:mousereleased(x, y, button, istouch) end end -function love.mousemoved(x, y) +function love.mousemoved(x, y, dx, dy, istouch) if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousemoved(x, y) end if editorMode or Importer then return end - if mouseTouch and Game and love.mouse.isDown(1) then - Game:touchmoved("mouse", x, y) + if mouseTouch then + if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end + return end + if Game then Game:mousemoved(x, y, dx, dy, istouch) end end function love.textinput(text) @@ -708,10 +719,31 @@ function love.textinput(text) end end +-- #785: set once love.quit has routed a window close into HostShell.restart, +-- so the follow-up quit event the restart itself raises (quit("restart") on +-- desktop; AppImage and Android relaunch the process instead, #575) falls +-- through to the normal shutdown below instead of restarting forever. +local quitToLauncher = false + function love.quit() if editorMode and EditorApp.quit then return EditorApp.quit() -- return true to abort quit end + -- Closing the window of a running game returns to the launcher instead of + -- exiting the app, so testing a mod does not need a relaunch every time + -- (#785). Game is only non-nil once bootGame ran; Importer non-nil means + -- the launcher (or its import) owns the window and its close still quits. + -- Scripted and headless runs (autopilot, frame driver, import-only, ROM + -- path import) keep the plain exit so they terminate as before. Nothing + -- is saved here on purpose: a window close never wrote the save, and the + -- restart path must be no worse than that, not quietly better. + local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") + or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM") + if Game and not Importer and not quitToLauncher and not scripted then + quitToLauncher = true + require("src.core.HostShell").restart() + return true -- abort this quit; the restart lands back in the launcher + end pcall(function() require("src.core.DiscordPresence").shutdown() end) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5a2a9bc2..7ed4412b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -418,8 +418,11 @@ function StatBox:draw() Font.drawBox(9, 2, 11, 10) love.graphics.setColor(0, 0, 0, 1) local s = self.mon.stats - local rows = { { "ATTACK", s.attack }, { "DEFENSE", s.defense }, - { "SPEED", s.speed }, { "SPECIAL", s.special } } + -- labels through Strings so a mod catalog translates them (#811) + local rows = { { Strings("ATTACK"), s.attack }, + { Strings("DEFENSE"), s.defense }, + { Strings("SPEED"), s.speed }, + { Strings("SPECIAL"), s.special } } for i, r in ipairs(rows) do Font.draw(r[1], 88, 24 + (i - 1) * 16) Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16) @@ -793,7 +796,7 @@ end -- tutorial passes failThrow -- it stands in for that event (#636). function BattleState:makeOldManDemo(name, failThrow) self.demo = true - self.demoName = name or "OLD MAN" + self.demoName = name or Strings("OLD MAN") self.demoFails = failThrow and true or false -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: -- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU @@ -1566,7 +1569,7 @@ function BattleState:enter() -- out of the ball after "X sent out Y!" (not the wild "already there" -- intro that LinkBattle previously inherited from newWild). self.enemySendingOut = true - self:say(Strings("%s sent\nout %s!", self.opponentName or "FOE", + self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"), self.enemy.name)) self:act(function() self.enemySendingOut = false @@ -2147,7 +2150,7 @@ function BattleState:oldManThrow() self.phase = "messages" self.afterQueue = "finish" self.result = "run" -- nothing is kept; wBattleResult only ends the demo - self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN")) + self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or Strings("OLD MAN"))) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") -- ItemUseBall's beat before the toss chain (like throwBall) @@ -5509,8 +5512,13 @@ function BattleState:drawTextArea() local def = self.data.moves[mv.id] Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8) end - Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED, - 40, 96 + self.moveIndex * 8) + -- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row + -- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the + -- filled arrow into the tilemap over it whenever the cursor sits there + -- (home/window.asm:184-185), so the current row is always filled. Only + -- one glyph may land per cell -- drawCode blits black-on-transparent, so + -- stacking 0xED over 0xEC would merge the two arrows (#814). + Font.drawCode(0xED, 40, 96 + self.moveIndex * 8) if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8) end diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5d2321ef..93c24079 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -26,9 +26,14 @@ local function displayName(b) return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779 end +-- Stat names as printed (data/battle/stat_mod_names.asm +-- StatModTextStrings). Strings.source, not Strings: this table is built +-- at require time, before Strings.load has a catalog, so changeStage +-- looks each label up at use time (#811). local STAT_LABEL = { - attack = "ATTACK", defense = "DEFENSE", speed = "SPEED", - special = "SPECIAL", accuracy = "ACCURACY", evasion = "EVADE", + attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"), + speed = Strings.source("SPEED"), special = Strings.source("SPECIAL"), + accuracy = Strings.source("ACCURACY"), evasion = Strings.source("EVADE"), } -- --------------------------------------------------------------------- @@ -54,14 +59,15 @@ local function changeStage(battle, who, stat, delta, fromEnemy) who.hazeStatReset = nil -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- two-stage variants scroll "greatly" onto a third line + local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811) if delta >= 2 then - return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s\ngreatly rose!", displayName(who), label) } elseif delta == 1 then - return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s rose!", displayName(who), label) } elseif delta == -1 then - return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s fell!", displayName(who), label) } end - return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s\ngreatly fell!", displayName(who), label) } end MoveEffects.changeStage = changeStage @@ -494,7 +500,9 @@ MoveEffects.full = { chooseDamage = function(ctx) -- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616) local dmg = fixedDamageFor(ctx) - if not dmg then return nil, "But, it failed!" end + if not dmg then + return nil, romText(ctx.battle.data, "_ButItFailedText", "But, it failed!") + end return dmg, plainInfo() end, }, @@ -510,7 +518,7 @@ MoveEffects.full = { local blocked = immuneMsg(ctx) if blocked then return false, blocked end if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then - return false, "But, it failed!" + return false, romText(ctx.battle.data, "_ButItFailedText", "But, it failed!") end return true end, @@ -535,7 +543,9 @@ MoveEffects.full = { DREAM_EATER_EFFECT = { -- only works on sleeping targets (checked before damage) gate = function(ctx) - if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end + if ctx.target.mon.status ~= "SLP" then + return false, romText(ctx.battle.data, "_ButItFailedText", "But, it failed!") + end return true end, afterDamage = drainHalf("_DreamWasEatenText", Strings.source("%s's\ndream was eaten!")), diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index ad87b326..72b122c7 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -244,7 +244,10 @@ end local function drawMoveMenu(battle) drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex) - if battle.moveSwapIndex then + -- The filled cursor replaces the hollow swap marker when they share a row + -- (PlaceMenuCursor's tilemap write, home/window.asm:184-185); drawCode blits + -- black-on-transparent, so skip the 0xEC instead of stacking glyphs (#814). + if battle.moveSwapIndex and battle.moveSwapIndex ~= battle.moveIndex then local col = (battle.moveSwapIndex - 1) % 2 local row = math.floor((battle.moveSwapIndex - 1) / 2) Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16) diff --git a/src/core/Game.lua b/src/core/Game.lua index 0cd17769..d38db05a 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -799,6 +799,7 @@ end function Game:focus(f) Input:reset() TouchControls:reset() + self:cancelPointers() end function Game:visible(v) @@ -807,12 +808,14 @@ function Game:visible(v) else Input:reset() TouchControls:reset() + self:cancelPointers() end end function Game:onResume() Input:reset() TouchControls:reset() + self:cancelPointers() -- Chip music may survive NX suspend as a duplicate stream; stop it and let -- the active screen re-cue on the next frame (hardware audio check: T19). -- Desktop/mobile window-visible flips must not kill overworld music. @@ -828,6 +831,11 @@ end function Game:recoverInput(event, joystick) Input:reset() TouchControls:reset() + -- reset just dropped every source, mod holds included: retire the mods' + -- outstanding press tokens so nothing stale can be released later, and + -- tell subscribers their live pointers died (#807) + if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end + self:cancelPointers() local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") if SwitchDiagnostics.isEnabled() then if joystick then @@ -850,16 +858,118 @@ function Game:joystickremoved(joystick) TouchControls:joystickremoved() end -function Game:touchpressed(id, x, y) - TouchControls:touchpressed(id, x, y) +-- Gameplay pointer seam (#807). TouchControls keeps first refusal: a +-- pointer that begins on a virtual control belongs to the pad for its +-- whole lifecycle and never reaches mods, while one that begins outside +-- stays mod-visible even if it later wanders across a control +-- (TouchControls only tracks ids it captured at press). Everything a +-- subscriber costs -- the per-pointer records in self.modPointers, the +-- payload tables -- sits behind wantsHook, so a mod-free boot allocates +-- nothing here. + +-- vanilla for input.pointer: nobody consumed the event +local function pointerUnclaimed() return false end + +-- coordinates are LOVE window units, the same space render.hud's viewport +-- and the touch overlay lay out in +function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) + return ModRuntime.call("input.pointer", pointerUnclaimed, self, { + phase = phase, source = source, id = id, x = x, y = y, + dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, + }) end -function Game:touchmoved(id, x, y) +function Game:touchpressed(id, x, y, dx, dy, pressure) + if TouchControls:touchpressed(id, x, y) then return end + if not ModRuntime.wantsHook("input.pointer") then return end + -- POKEPORT_TOUCH routes the mouse through here as a stand-in finger + -- under the id "mouse" (see main.lua); mods still see its true source + local source = id == "mouse" and "mouse" or "touch" + self.modPointers = self.modPointers or {} + self.modPointers[id] = { source = source, x = x, y = y, + pressure = pressure } + self:pointerEvent("pressed", source, id, x, y, dx, dy, pressure) +end + +function Game:touchmoved(id, x, y, dx, dy, pressure) TouchControls:touchmoved(id, x, y) + local p = self.modPointers and self.modPointers[id] + if not p then return end + -- the POKEPORT_TOUCH mouse path carries no deltas; derive them from the + -- pointer's last seen position so drags read the same either way + if dx == nil then dx, dy = x - p.x, y - p.y end + p.x, p.y = x, y + if pressure ~= nil then p.pressure = pressure end + if ModRuntime.wantsHook("input.pointer") then + self:pointerEvent("moved", p.source, id, x, y, dx, dy, pressure) + end end -function Game:touchreleased(id, x, y) +function Game:touchreleased(id, x, y, dx, dy, pressure) TouchControls:touchreleased(id, x, y) + local p = self.modPointers and self.modPointers[id] + if not p then return end + self.modPointers[id] = nil + if ModRuntime.wantsHook("input.pointer") then + self:pointerEvent("released", p.source, id, x, y, dx, dy, pressure) + end +end + +-- A real mouse without POKEPORT_TOUCH (#807). Gameplay itself has no +-- mouse verbs, so the pointer hook is the only consumer and everything is +-- behind the wantsHook gate. A synthesized istouch twin is dropped +-- unconditionally: the same contact already arrived through +-- Game:touchpressed, and forwarding both would fire a mobile touch twice. +function Game:mousepressed(x, y, button, istouch) + if istouch then return end + if not ModRuntime.wantsHook("input.pointer") then return end + self.modPointers = self.modPointers or {} + local p = self.modPointers.mouse + if p then + p.held, p.x, p.y = (p.held or 1) + 1, x, y + else + self.modPointers.mouse = { source = "mouse", x = x, y = y, + held = 1, button = button } + end + self:pointerEvent("pressed", "mouse", "mouse", x, y, 0, 0, nil, button) +end + +-- hover moves are delivered too (button = nil); only pressed pointers are +-- tracked, because only they owe a released/cancelled later +function Game:mousemoved(x, y, dx, dy, istouch) + if istouch then return end + local p = self.modPointers and self.modPointers.mouse + if p then p.x, p.y = x, y end + if not ModRuntime.wantsHook("input.pointer") then return end + self:pointerEvent("moved", "mouse", "mouse", x, y, dx, dy, nil, nil) +end + +function Game:mousereleased(x, y, button, istouch) + if istouch then return end + local p = self.modPointers and self.modPointers.mouse + if not p then return end + p.held = (p.held or 1) - 1 + if p.held <= 0 then self.modPointers.mouse = nil end + if ModRuntime.wantsHook("input.pointer") then + self:pointerEvent("released", "mouse", "mouse", x, y, 0, 0, nil, button) + end +end + +-- Focus/visibility loss and input recovery swallow pointer releases the +-- same way they swallow key-ups (the hazard Input:reset exists for): +-- every mod-visible pointer gets a "cancelled" instead of leaving +-- subscribers waiting on a "released" that can never arrive (#807). +-- Cleared even when the subscriber is already gone, so no stale record +-- outlives its mod. +function Game:cancelPointers() + local pointers = self.modPointers + if not pointers then return end + self.modPointers = nil + if not ModRuntime.wantsHook("input.pointer") then return end + for id, p in pairs(pointers) do + self:pointerEvent("cancelled", p.source, id, p.x, p.y, 0, 0, + p.pressure, p.button) + end end -- Point the loader's mod.save backing at this save's modData so per-mod diff --git a/src/core/Input.lua b/src/core/Input.lua index b450994d..a576e15d 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -186,6 +186,21 @@ function Input:overlayReleased(btn) release(self, btn, "touch:" .. btn) end +-- Programmatic mod input (#807). mod.input taps and holds land here under +-- loader-issued "mod::" source names, riding the same per-source +-- bookkeeping as every physical path above, so releasing one can never +-- clear a hold a key, stick, hat, the overlay, or another mod still owns. +-- A tap is a sourcePress immediately followed by its sourceRelease: the +-- queued edge survives into the next step, and the emptied source map +-- keeps the hold from being revived (see Input:step's sources == {} rule). +function Input:sourcePress(btn, source) + press(self, btn, source) +end + +function Input:sourceRelease(btn, source) + release(self, btn, source) +end + function Input:gamepadpressed(joystick, button) local btn = self.padBindings[button] if btn then diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 4a6dae2c..efdfee22 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -423,11 +423,17 @@ local function setDpad(self, touch, dir) if dir then pressBtn(self, dir) end end +-- Returns true when this touch was captured by a virtual control -- the +-- pad's first refusal on the gameplay pointer seam (#807). Capture is +-- decided here, at press, and rides self.touches[id] for the touch's +-- whole lifecycle; an uncaptured touch is never tracked, so wandering +-- across a control later neither presses it nor hides the touch from mods. function TouchControls:touchpressed(id, x, y) -- preview mode is layout-edit only: never press GB buttons if self.preview then return end if not (self.active and self.enabled ~= false and self.img) then return end -- a controller hid the overlay; the first touch only brings it back + -- (uncaptured: it began on no control, so mods may still see it) if self.controllerHidden then self.controllerHidden = false return @@ -437,7 +443,7 @@ function TouchControls:touchpressed(id, x, y) if inCircle(L[btn], x, y, SLOP[btn]) then self.touches[id] = { control = btn } pressBtn(self, btn) - return + return true end end -- square hit zone a bit past the cross art; one owning finger at a time @@ -449,6 +455,7 @@ function TouchControls:touchpressed(id, x, y) local touch = { control = "dpad", dir = nil } self.touches[id] = touch setDpad(self, touch, dpadDir(dz, x, y)) + return true end end diff --git a/src/dev/HotReload.lua b/src/dev/HotReload.lua index 0a36309f..22264be0 100644 --- a/src/dev/HotReload.lua +++ b/src/dev/HotReload.lua @@ -31,6 +31,12 @@ function HotReload.run(game, opts) local Logger = require("src.core.Logger") local data = game.data if data and data.reloadGenerated then data:reloadGenerated() end + -- outstanding mod.input holds and mod-visible pointers belong to the + -- loader being torn down; retire them while its subscribers still + -- exist, or the fresh loader inherits phantom "mod:*" sources and + -- pointers nobody left alive can release (#807) + if game.mods and game.mods.releaseModInput then game.mods:releaseModInput() end + if game.cancelPointers then game:cancelPointers() end local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil) loader.game = game -- mod.save keeps pointing at the live slot across the reload diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba92fa95..aa6b803f 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -138,6 +138,7 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, + modInput = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, }, Loader) @@ -527,6 +528,44 @@ function Loader:_registerCommand(modId, verb, fn) return self.content.commands:register(verb, fn, modId) end +-- the GB buttons mod.input may drive (#807) +local GB_BUTTONS = { + up = true, down = true, left = true, right = true, + a = true, b = true, start = true, select = true, +} + +-- per-mod mod.input ledger (#807): seq numbers this mod's Input sources, +-- tokens maps each opaque press token to what release must undo. Living +-- on the loader (not the api closure) is what lets rollback, hot reload +-- and input recovery retire a mod's holds from outside the mod's own code. +function Loader:_modInput(modId) + local bucket = self.modInput[modId] + if not bucket then + bucket = { seq = 0, tokens = {} } + self.modInput[modId] = bucket + end + return bucket +end + +-- Release every outstanding mod.input hold: one mod's on entry-chunk +-- rollback, everyone's (no argument) on hot reload and input recovery +-- (#807). When Input:reset already dropped the sources these releases +-- are no-ops; the point is the stale tokens die with the code that took +-- them, so a later mod.input:release on one is refused instead of +-- touching a button someone else now holds. +function Loader:releaseModInput(modId) + if modId == nil then + for id in pairs(self.modInput) do self:releaseModInput(id) end + return + end + local bucket = self.modInput[modId] + if not bucket then return end + self.modInput[modId] = nil + for _, rec in pairs(bucket.tokens) do + rec.input:sourceRelease(rec.btn, rec.source) + end +end + function Loader:_api(mod) local loader = self local modId = mod.manifest.id @@ -559,6 +598,45 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, + -- source-safe scripted GB input (#807): tap queues exactly one + -- wasPressed edge for the next fixed step with no held state; press + -- holds until release. Every call is its own "mod::" source in + -- game.input, so releasing a token can never drop a button the + -- keyboard, a pad, the touch overlay, or another mod still holds. + input = { + tap = function(_, game, btn) + local input = game and game.input + assert(input, "mod.input needs the live game (see game.ready)") + assert(GB_BUTTONS[btn], "unknown GB button: " .. tostring(btn)) + local bucket = loader:_modInput(modId) + bucket.seq = bucket.seq + 1 + local source = "mod:" .. modId .. ":" .. bucket.seq + input:sourcePress(btn, source) + input:sourceRelease(btn, source) + end, + press = function(_, game, btn) + local input = game and game.input + assert(input, "mod.input needs the live game (see game.ready)") + assert(GB_BUTTONS[btn], "unknown GB button: " .. tostring(btn)) + local bucket = loader:_modInput(modId) + bucket.seq = bucket.seq + 1 + local source = "mod:" .. modId .. ":" .. bucket.seq + input:sourcePress(btn, source) + local token = {} + bucket.tokens[token] = { input = input, btn = btn, source = source } + return token + end, + -- idempotent, and a token another mod took is simply not in this + -- ledger, so cross-mod release is refused by construction + release = function(_, token) + local bucket = loader.modInput[modId] + local rec = bucket and bucket.tokens[token] + if not rec then return false end + bucket.tokens[token] = nil + rec.input:sourceRelease(rec.btn, rec.source) + return true + end, + }, -- the widget toolkit facade (12 4.5) is one shared surface, not -- per-mod state; each widget inside it loads on first touch ui = ModUI, @@ -714,6 +792,7 @@ function Loader:_rollback(modId) end self.events:removeOwner(modId) self.hooks:removeOwner(modId) + self:releaseModInput(modId) self.exports[modId] = nil self.optionSchemas[modId] = nil self.migrations[modId] = nil diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 2f41dec5..5c1f0eaa 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -237,8 +237,11 @@ function ListMenu:draw() if i == self.index then -- hollowIndex: a chosen row keeps the hollow '▷' left behind by -- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's - -- auto A-press, home/list_menu.asm:89-91) - Font.drawCode((self.swapIndex == i or self.hollowIndex == i) + -- auto A-press, home/list_menu.asm:89-91). A swap-marked row does + -- NOT stay hollow under the cursor: PlaceMenuCursor writes '▶' + -- into the tilemap over the '▷' whenever the cursor sits there + -- (home/window.asm:184-185) and restores it on the way out (#814) + Font.drawCode(self.hollowIndex == i and Theme.cursorHollow or Theme.cursor, 8, y) end if self.swapIndex == i and i ~= self.index then diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 3d618c72..9882c67e 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -776,8 +776,10 @@ function PartyMenu:draw() if i == self.index then Font.drawCode(Theme.cursor, 0, cursorY) end - if i == self.swapFrom or i == self.softboiledFrom then - Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow + -- the unfilled swap arrow; the filled cursor replaces it in the tilemap + -- when they share a row (PlaceMenuCursor, home/window.asm:184-185) (#814) + if (i == self.swapFrom or i == self.softboiledFrom) and i ~= self.index then + Font.drawCode(Theme.cursorHollow, 0, cursorY) end end if self.swapFrom then diff --git a/tests/drivers/battle_menu_default_bug770_test.lua b/tests/drivers/battle_menu_default_bug770_test.lua new file mode 100644 index 00000000..349cb06a --- /dev/null +++ b/tests/drivers/battle_menu_default_bug770_test.lua @@ -0,0 +1,101 @@ +-- Driver: the FIGHT/PKMN/ITEM/RUN cursor after a voluntary switch (#770). +-- SendOutMon (pokered engine/battle/core.asm:1733-1735) zeroes the saved +-- battle-menu byte AND the move-list byte behind it on every player +-- send-out, so the reopened menu starts on FIGHT; the #737 sendOutMonCursors +-- reset already covers this and the driver is the proof. Judge WITHOUT +-- POKEPORT_SPEED: fast-forward makes the reopened menu impossible to read. +-- POKEPORT_DRIVER=tests/drivers/battle_menu_default_bug770_test.lua POKEPORT_IDENTITY=bug770 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local PartyMenu = require("src.ui.PartyMenu") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- :L60 pair so the foe's free move after the switch cannot faint anyone + -- and force the ChooseNextMon path instead of the voluntary one + check("CHARIZARD resolves in the species table", + game.data.pokemon.CHARIZARD ~= nil) + check("SNORLAX resolves in the species table", + game.data.pokemon.SNORLAX ~= nil) + game.save.player.name = "bryan" + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 60), + Pokemon.new(game.data, "SNORLAX", 60), + } + check("party has two healthy mons", + #game.save.party == 2 + and game.save.party[1].hp > 0 and game.save.party[2].hp > 0) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + check("overworld is up to push the battle from", game.overworld ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + local function mashUntil(cond, max) + for _ = 1, max or 120 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return cond() + end + + U.wait(220) -- the send-out intro plays before the menu is reachable + check("the wild battle reached its action menu", + mashUntil(function() return battle.phase == "menu" end)) + check("cursor starts on FIGHT", battle.menuIndex == 1) + + -- park the cursor on PKMN first: with it off FIGHT, "still 1 afterwards" + -- proves a reset happened rather than nothing ever moving + U.tap(game, "right") + U.wait(4) + check("cursor moved to PKMN", battle.menuIndex == 2) + U.tap(game, "a") + U.wait(10) + local menu = game.stack:top() + check("A on PKMN opened the party menu", getmetatable(menu) == PartyMenu) + + -- steer onto slot 2; #768 seeds the cursor from partyMenuSavedIndex so + -- the start slot is not fixed, but down wraps and must land on 2 + for _ = 1, 6 do + if getmetatable(menu) ~= PartyMenu or menu.index == 2 then break end + U.tap(game, "down") + U.wait(4) + end + check("party cursor sits on slot 2", + getmetatable(menu) == PartyMenu and menu.index == 2) + U.tap(game, "a") -- SWITCH / STATS / CANCEL, SWITCH preselected + U.wait(6) + check("the SWITCH submenu is open", + getmetatable(menu) == PartyMenu and menu.submenu ~= nil + and menu.subIndex == 1) + U.tap(game, "a") -- SWITCH -> resolveSwitch; the foe takes a free move + U.wait(10) + + check("the turn resolved back to the action menu", + mashUntil(function() return battle.phase == "menu" end, 300)) + check("the second mon is the one out now", + battle.player.mon == game.save.party[2]) + check("menuIndex reset to FIGHT after the send-out (#770)", + battle.menuIndex == 1) + check("moveIndex reset to the first slot too", battle.moveIndex == 1) + U.shot(game, DIR .. "/bug770_menu_after_switch.png") + U.log("captured", DIR .. "/bug770_menu_after_switch.png") + + U.log("The menu on screen just reopened after a PKMN switch; the cursor") + U.log("should be back on FIGHT. Switch again yourself: it must land on") + U.log("FIGHT every time -- reopening on PKMN is the old #770 bug.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/move_swap_arrow_bug814_test.lua b/tests/drivers/move_swap_arrow_bug814_test.lua new file mode 100644 index 00000000..07cc76a1 --- /dev/null +++ b/tests/drivers/move_swap_arrow_bug814_test.lua @@ -0,0 +1,96 @@ +-- Driver: the swap cursor in the FIGHT move list (#814). pokered +-- SelectMenuItem parks the hollow arrow 0xEC on the marked row (engine/battle/ +-- core.asm:2600-2607) and HandleMenuInput's PlaceMenuCursor writes the filled +-- arrow 0xED into the tilemap over it whenever the cursor sits there +-- (home/window.asm:184-185), so the row under the cursor is always filled. +-- Font.drawCode blits black-on-transparent, so drawing both glyphs on one cell +-- merged the two arrows. Eye check, no SPEED. +-- POKEPORT_DRIVER=tests/drivers/move_swap_arrow_bug814_test.lua \ +-- POKEPORT_IDENTITY=bug814 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.player.name = "bryan" + -- CHARIZARD at 50 knows four moves, so marking row 1 and parking the cursor + -- on row 2 leaves both arrows on screen at once + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + local lead = game.save.party[1] + check("the lead knows at least 3 moves (needs rows to scroll between)", + #lead.moves >= 3) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 60) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 6) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + U.wait(220) -- the send-out intro plays before the menu is reachable + check("reached the FIGHT/PKMN/ITEM/RUN menu", + tapUntil(function() return battle.phase == "menu" end, 120)) + check("cursor starts on FIGHT", battle.menuIndex == 1) + + U.tap(game, "a") + U.wait(20) + check("the FIGHT move list is open (#814 lives on this screen)", + battle.phase == "moveSelect") + check("cursor starts on move 1", battle.moveIndex == 1) + + -- SELECT marks the move under the cursor for swapping + U.tap(game, "select") + U.wait(10) + check("SELECT marked move 1 (moveSwapIndex == 1)", + battle.moveSwapIndex == 1) + + -- cursor down to row 2: hollow arrow stays on row 1, filled follows to row 2 + U.tap(game, "down") + U.wait(10) + check("cursor moved to move 2", battle.moveIndex == 2) + check("the mark stayed on move 1", battle.moveSwapIndex == 1) + check("split-arrow screenshot reached disk", + U.shot(game, DIR .. "/bug814_marked_row1_cursor_row2.png")) + U.log("captured", DIR .. "/bug814_marked_row1_cursor_row2.png") + + -- cursor back up onto the marked row: this is the shot the fix is judged + -- on -- the shared cell must show only the filled arrow + U.tap(game, "up") + U.wait(10) + check("cursor is back on move 1", battle.moveIndex == 1) + check("the mark is still on move 1", battle.moveSwapIndex == 1) + check("cursor-on-marked-row screenshot reached disk", + U.shot(game, DIR .. "/bug814_cursor_on_marked_row.png")) + U.log("captured", DIR .. "/bug814_cursor_on_marked_row.png") + + -- ---- hand off ---------------------------------------------------------- + U.log("Move 1 is marked for swap and the cursor sits on it. That row wants") + U.log("one solid black arrow only; a hollow outline there, or a smudge of") + U.log("both shapes, is the bug (#814). Scroll DOWN and the hollow arrow") + U.log("should reappear on row 1 while the solid one follows the cursor.") + + while true do + coroutine.yield() + end +end diff --git a/tests/modkit/cases/pointer_input.lua b/tests/modkit/cases/pointer_input.lua new file mode 100644 index 00000000..b4e9d33d --- /dev/null +++ b/tests/modkit/cases/pointer_input.lua @@ -0,0 +1,327 @@ +-- T4: the gameplay pointer seam and source-safe mod input (#807), through +-- the public mod API. +-- +-- Two seams under test. "input.pointer" delivers uncaptured gameplay +-- touch and mouse events to hook subscribers, with the on-screen touch +-- controls keeping first refusal. mod.input taps and holds GB buttons as +-- per-mod Input sources, so no mod can clear a hold it does not own. +-- Both are driven the way main.lua drives them -- through Game's own +-- handlers, against the real Input and TouchControls singletons -- so a +-- green run means the wiring, not just the buses. + +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 TouchControls = require("src.core.TouchControls") +local Runtime = require("src.mods.Runtime") + +-- a Game stand-in that resolves the real methods (Game is the boot +-- singleton, so its handlers expect to be their own self) +local function fakeGame(loader, data) + return setmetatable({ input = Input, mods = loader, data = data }, + { __index = Game }) +end + +-- ------- fixture mods + +-- the watcher journals every pointer event it sees and exports its own +-- mod.input facade, so the suite drives exactly the surface a mod +-- compiles against; the peer exists to prove cross-mod token refusal +local FIXTURES = { + ["mods/fix_pointer_watcher/manifest.json"] = [[{ + "id": "fix_pointer_watcher", + "name": "Fixture Pointer Watcher", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_pointer_watcher/main.lua"] = [[ + local mod = ... + local seen = {} + mod.exports.seen = seen + mod.hooks:wrap("input.pointer", function(nextFn, game, ev) + seen[#seen + 1] = { phase = ev.phase, source = ev.source, id = ev.id, + x = ev.x, y = ev.y, dx = ev.dx, dy = ev.dy, + pressure = ev.pressure, button = ev.button } + return nextFn(game, ev) + end) + mod.exports.input = mod.input + ]], + ["mods/fix_pointer_peer/manifest.json"] = [[{ + "id": "fix_pointer_peer", + "name": "Fixture Pointer Peer", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_pointer_peer/main.lua"] = [[ + local mod = ... + mod.exports.input = mod.input + ]], +} + +-- ------- no-mod parity: the pointer path costs nothing unsubscribed + +do + local run = T.sdk.loadNone({}) + Input:init() + TouchControls:init() + local game = fakeGame(run.loader, run.data) + T.eq(Runtime.wantsHook("input.pointer"), false, + "no subscriber: wantsHook(\"input.pointer\") is false") + Game.touchpressed(game, 1, 50, 60, 0, 0, 1) + Game.touchmoved(game, 1, 55, 66, 5, 6, 1) + Game.touchreleased(game, 1, 55, 66, 0, 0, 1) + Game.mousepressed(game, 10, 10, 1, false) + Game.mousemoved(game, 12, 12, 2, 2, false) + Game.mousereleased(game, 12, 12, 1, false) + Game.cancelPointers(game) + T.eq(game.modPointers, nil, + "no subscriber: the pointer path allocates no tracking state") + run.release() +end + +-- ------- the subscribed path, through the loader-created mod API + +local run = T.sdk.loadMods( + { "mods/fix_pointer_watcher", "mods/fix_pointer_peer" }, + { fs = T.sdk.memfs(FIXTURES) }) +T.eq(#run.errors, 0, + "the fixture mods load clean (" .. tostring(run.errors[1]) .. ")") + +local watcher = run.loader.exports.fix_pointer_watcher +local peer = run.loader.exports.fix_pointer_peer +local seen = watcher.seen +local game = fakeGame(run.loader, run.data) + +Input:init() +TouchControls:init() + +local function wipe() + for i = #seen, 1, -1 do seen[i] = nil end +end + +-- one pressed/moved/released sequence per outside touch +do + wipe() + Game.touchpressed(game, 7, 100, 120, 0, 0, 0.5) + Game.touchmoved(game, 7, 110, 125, 10, 5, 0.5) + Game.touchreleased(game, 7, 110, 125, 0, 0, 0.5) + T.eq(#seen, 3, "an outside touch is one pressed/moved/released sequence") + T.eq(seen[1].phase, "pressed", "the sequence begins with pressed") + T.eq(seen[1].source, "touch", "a finger reports source touch") + T.eq(seen[1].id, 7, "the touch id rides the payload") + T.eq(seen[1].x, 100, "coordinates are the LOVE window units handed in") + T.eq(seen[1].pressure, 0.5, "pressure rides the payload when present") + T.eq(seen[2].phase, "moved", "then moved") + T.eq(seen[2].dx, 10, "moved carries the event deltas") + T.eq(seen[3].phase, "released", "then released") + T.eq(game.modPointers[7], nil, "a released pointer leaves no record") + + -- press+release inside one logic tick still yields both events: the + -- seam is event-driven, not sampled at the step boundary + wipe() + Game.touchpressed(game, 8, 10, 10, 0, 0, 1) + Game.touchreleased(game, 8, 10, 10, 0, 0, 1) + T.eq(#seen, 2, "press+release inside one tick delivers both events") + T.eq(seen[1].phase, "pressed", "...pressed first") + T.eq(seen[2].phase, "released", "...released second") +end + +-- multi-id drags stay per-pointer +do + wipe() + Game.touchpressed(game, "t1", 40, 40, 0, 0, 1) + Game.touchpressed(game, "t2", 200, 90, 0, 0, 1) + Game.touchmoved(game, "t1", 45, 44, 5, 4, 1) + Game.touchmoved(game, "t2", 210, 80, 10, -10, 1) + Game.touchreleased(game, "t2", 210, 80, 0, 0, 1) + Game.touchreleased(game, "t1", 45, 44, 0, 0, 1) + T.eq(#seen, 6, "two concurrent touches interleave without cross-talk") + T.eq(seen[3].id, "t1", "each moved names its own pointer") + T.eq(seen[4].id, "t2", "...and only its own pointer") + T.eq(seen[5].id, "t2", "release order follows the fingers, not the presses") + T.eq(seen[5].phase, "released", "t2 released first") + T.eq(seen[6].id, "t1", "t1 released second") + T.eq(next(game.modPointers), nil, "all records are gone after both lifts") +end + +-- the virtual pad keeps first refusal +do + -- force the overlay live the way a phone would have it; img is only + -- tested for truthiness on the input path (soft_reset_bug563 idiom) + TouchControls.active, TouchControls.enabled = true, true + TouchControls.img = { stub = true } + local L = TouchControls:layout() + + wipe() + Game.touchpressed(game, 21, L.a.cx, L.a.cy) + T.eq(Input:isDown("a"), true, "the overlay captured the touch and holds A") + Game.touchmoved(game, 21, L.a.cx + 4, L.a.cy + 4) + Game.touchreleased(game, 21, L.a.cx + 4, L.a.cy + 4) + T.eq(Input:isDown("a"), false, "lifting the finger releases A") + T.eq(#seen, 0, "a touch that begins on a virtual control never reaches mods") + + -- begins outside, wanders across A: stays mod-visible, never presses A + wipe() + Game.touchpressed(game, 22, 5, 5) + Game.touchmoved(game, 22, L.a.cx, L.a.cy) + T.eq(Input:isDown("a"), false, "crossing a control mid-drag never presses it") + Game.touchreleased(game, 22, L.a.cx, L.a.cy) + T.eq(#seen, 3, "a touch that begins outside stays mod-visible throughout") + T.eq(seen[2].dx, L.a.cx - 5, "deltas derive from the last seen position " + .. "when the event carries none") + + TouchControls.active = false + TouchControls.img = nil + TouchControls:reset() +end + +-- real mouse events, and the synthesized istouch twins that must not fire +do + wipe() + Game.mousepressed(game, 30, 30, 1, true) + Game.mousemoved(game, 31, 31, 1, 1, true) + Game.mousereleased(game, 31, 31, 1, true) + T.eq(#seen, 0, "synthesized istouch mouse twins never reach the hook") + + Game.mousepressed(game, 30, 30, 1, false) + Game.mousemoved(game, 34, 32, 4, 2, false) + Game.mousereleased(game, 34, 32, 1, false) + T.eq(#seen, 3, "a real mouse reaches the hook without POKEPORT_TOUCH") + T.eq(seen[1].source, "mouse", "the mouse reports source mouse") + T.eq(seen[1].id, "mouse", "...under the fixed id mouse") + T.eq(seen[1].button, 1, "pressed carries the button") + T.eq(seen[2].dx, 4, "moved carries the event deltas") + T.eq(seen[3].phase, "released", "the lifecycle closes with released") + T.eq(game.modPointers.mouse, nil, "the mouse record is gone after release") +end + +-- focus loss cancels every live mod-visible pointer +do + wipe() + Game.touchpressed(game, 40, 70, 80, 0, 0, 1) + Game.focus(game, false) + T.eq(#seen, 2, "focus loss follows the pressed with exactly one more event") + T.eq(seen[2].phase, "cancelled", "...a cancelled") + T.eq(seen[2].id, 40, "...for the pointer that was alive") + T.eq(seen[2].x, 70, "...at its last seen position") + T.eq(game.modPointers, nil, "cancel clears the tracking state") +end + +-- ------- mod.input: tap is one edge, no held state + +do + Input:init() + watcher.input:tap(game, "a") + T.eq(Input:isDown("a"), false, "tap holds nothing before the step") + Input:step() + T.eq(Input:wasPressed("a"), true, "tap queues exactly one wasPressed edge") + T.eq(Input:isDown("a"), false, "the edge carries no held state") + Input:step() + T.eq(Input:wasPressed("a"), false, "the edge lasts exactly one step") + T.eq(Input:isDown("a"), false, "and still nothing is held") +end + +-- mod.input: a press token holds across steps and respects other sources + +do + Input:init() + local token = watcher.input:press(game, "b") + Input:step() + T.eq(Input:wasPressed("b"), true, "press queues the edge") + T.eq(Input:isDown("b"), true, "and holds the button") + Input:step() + T.eq(Input:isDown("b"), true, "the hold survives steps until release") + -- the keyboard joins on the same button (X is a default B binding) + Input:keypressed("x") + Input:step() + T.eq(watcher.input:release(token), true, "release honors this mod's token") + T.eq(Input:isDown("b"), true, + "releasing the mod token leaves the keyboard's hold alone") + T.eq(watcher.input:release(token), false, "release is idempotent") + T.eq(Input:isDown("b"), true, "a second release changes nothing") + Input:keyreleased("x") + T.eq(Input:isDown("b"), false, "the keyboard's own release ends the hold") +end + +-- mod.input: one mod cannot release another mod's press + +do + Input:init() + local token = watcher.input:press(game, "start") + T.eq(peer.input:release(token), false, + "a mod cannot release another mod's token") + T.eq(Input:isDown("start"), true, "the refused release drops nothing") + T.eq(watcher.input:release(token), true, "the owning mod still can") + T.eq(Input:isDown("start"), false, "and then the button is up") + T.raises(function() watcher.input:tap(game, "quit") end, + "unknown GB button", "unknown buttons are refused") +end + +-- mod.input: input recovery retires outstanding tokens + +do + Input:init() + local token = watcher.input:press(game, "a") + T.eq(Input:isDown("a"), true, "the hold is live before recovery") + Game.recoverInput(game, "joystickadded") + T.eq(Input:isDown("a"), false, + "input recovery drops mod holds with every other source") + T.eq(watcher.input:release(token), false, + "recovery retires the outstanding token") +end + +-- ------- cleanup: entry-chunk rollback releases what the chunk pressed + +do + local BROKEN = { + ["mods/fix_pointer_broken/manifest.json"] = [[{ + "id": "fix_pointer_broken", + "name": "Fixture Pointer Broken", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_pointer_broken/main.lua"] = [[ + local mod = ... + mod.input:press(FIX_POINTER_GAME, "select") + error("entry chunk exploded") + ]], + } + Input:init() + _G.FIX_POINTER_GAME = { input = Input } + local broken = T.sdk.loadMods({ "mods/fix_pointer_broken" }, + { fs = T.sdk.memfs(BROKEN) }) + _G.FIX_POINTER_GAME = nil + T.check(#broken.errors > 0, "the throwing entry chunk is reported") + T.eq(Input:isDown("select"), false, + "entry-chunk rollback releases the mod's outstanding holds") + broken.release() +end + +-- ------- cleanup: hot reload retires the old loader's holds (LAST: it +-- replaces the buses, so the watcher's subscription dies with it) + +do + Input:init() + local token = watcher.input:press(game, "up") + T.eq(Input:isDown("up"), true, "the hold is live before the reload") + -- a fresh fixture dataset for the reload's re-merge, with the inherited + -- Data:reloadGenerated shadowed out: it would read data/generated, and + -- this tier is ROM-free + local rdata = T.fixtures.fresh() + rawset(rdata, "reloadGenerated", function() end) + local rgame = fakeGame(run.loader, rdata) + require("src.dev.HotReload").run(rgame, { fs = T.sdk.memfs({}) }) + T.eq(Input:isDown("up"), false, "hot reload retires the old loader's holds") + T.eq(watcher.input:release(token), false, + "a token from before the reload is refused, not re-released") +end + +run.release() + +T.finish("pointer_input")