CLOSES #785, CLOSES #807, CLOSES #811, CLOSES #814

This commit is contained in:
bryanthaboi
2026-08-04 15:13:06 -04:00
parent 8fbe819493
commit 0fa8206321
15 changed files with 862 additions and 37 deletions
+26
View File
@@ -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 the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`. `(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 `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 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. for a tool to offer a fresh-session action before gameplay begins.
+43 -11
View File
@@ -609,7 +609,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
-- Android's synthesized mouse twin so Import cannot double-fire (#553). -- Android's synthesized mouse twin so Import cannot double-fire (#553).
return Importer:touchpressed(id, x, y, dx, dy, pressure) return Importer:touchpressed(id, x, y, dx, dy, pressure)
end end
Game:touchpressed(id, x, y) Game:touchpressed(id, x, y, dx, dy, pressure)
end end
function love.touchmoved(id, x, y, dx, dy, pressure) 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 if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure) return Importer:touchmoved(id, x, y, dx, dy, pressure)
end end
Game:touchmoved(id, x, y) Game:touchmoved(id, x, y, dx, dy, pressure)
end end
function love.touchreleased(id, x, y, dx, dy, pressure) 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 if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure) return Importer:touchreleased(id, x, y, dx, dy, pressure)
end end
Game:touchreleased(id, x, y) Game:touchreleased(id, x, y, dx, dy, pressure)
end end
function love.wheelmoved(x, y) 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 if istouch and love.system.getOS() == "Android" then return end
return EditorApp.mousepressed(x, y, button) return EditorApp.mousepressed(x, y, button)
end end
if mouseTouch and Game and button == 1 then if mouseTouch then
Game:touchpressed("mouse", x, y) -- 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 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 end
function love.mousereleased(x, y, button) function love.mousereleased(x, y, button, istouch)
if TouchEditor then if TouchEditor then
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button) return TouchEditor.mousereleased(x, y, button)
@@ -684,20 +691,24 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button) return EditorApp.mousereleased(x, y, button)
end end
if mouseTouch and Game and button == 1 then if mouseTouch then
Game:touchreleased("mouse", x, y) if Game and button == 1 then Game:touchreleased("mouse", x, y) end
return
end end
if Game then Game:mousereleased(x, y, button, istouch) end
end end
function love.mousemoved(x, y) function love.mousemoved(x, y, dx, dy, istouch)
if TouchEditor then if TouchEditor then
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y) return TouchEditor.mousemoved(x, y)
end end
if editorMode or Importer then return end if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then if mouseTouch then
Game:touchmoved("mouse", x, y) if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
return
end end
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
end end
function love.textinput(text) function love.textinput(text)
@@ -708,10 +719,31 @@ function love.textinput(text)
end end
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() function love.quit()
if editorMode and EditorApp.quit then if editorMode and EditorApp.quit then
return EditorApp.quit() -- return true to abort quit return EditorApp.quit() -- return true to abort quit
end 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() pcall(function()
require("src.core.DiscordPresence").shutdown() require("src.core.DiscordPresence").shutdown()
end) end)
+15 -7
View File
@@ -418,8 +418,11 @@ function StatBox:draw()
Font.drawBox(9, 2, 11, 10) Font.drawBox(9, 2, 11, 10)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local s = self.mon.stats local s = self.mon.stats
local rows = { { "ATTACK", s.attack }, { "DEFENSE", s.defense }, -- labels through Strings so a mod catalog translates them (#811)
{ "SPEED", s.speed }, { "SPECIAL", s.special } } 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 for i, r in ipairs(rows) do
Font.draw(r[1], 88, 24 + (i - 1) * 16) Font.draw(r[1], 88, 24 + (i - 1) * 16)
Font.draw(("%3d"):format(r[2]), 128, 32 + (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). -- tutorial passes failThrow -- it stands in for that event (#636).
function BattleState:makeOldManDemo(name, failThrow) function BattleState:makeOldManDemo(name, failThrow)
self.demo = true self.demo = true
self.demoName = name or "OLD MAN" self.demoName = name or Strings("OLD MAN")
self.demoFails = failThrow and true or false self.demoFails = failThrow and true or false
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU -- 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" -- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild). -- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true 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.enemy.name))
self:act(function() self:act(function()
self.enemySendingOut = false self.enemySendingOut = false
@@ -2147,7 +2150,7 @@ function BattleState:oldManThrow()
self.phase = "messages" self.phase = "messages"
self.afterQueue = "finish" self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo 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() self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss") require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall) -- ItemUseBall's beat before the toss chain (like throwBall)
@@ -5509,8 +5512,13 @@ function BattleState:drawTextArea()
local def = self.data.moves[mv.id] local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8) Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
end end
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED, -- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
40, 96 + self.moveIndex * 8) -- (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 if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8) Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
end end
+19 -9
View File
@@ -26,9 +26,14 @@ local function displayName(b)
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779 return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end 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 = { local STAT_LABEL = {
attack = "ATTACK", defense = "DEFENSE", speed = "SPEED", attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
special = "SPECIAL", accuracy = "ACCURACY", evasion = "EVADE", 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 who.hazeStatReset = nil
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line -- 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 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 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 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 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 end
MoveEffects.changeStage = changeStage MoveEffects.changeStage = changeStage
@@ -494,7 +500,9 @@ MoveEffects.full = {
chooseDamage = function(ctx) chooseDamage = function(ctx)
-- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616) -- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616)
local dmg = fixedDamageFor(ctx) 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() return dmg, plainInfo()
end, end,
}, },
@@ -510,7 +518,7 @@ MoveEffects.full = {
local blocked = immuneMsg(ctx) local blocked = immuneMsg(ctx)
if blocked then return false, blocked end if blocked then return false, blocked end
if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then 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 end
return true return true
end, end,
@@ -535,7 +543,9 @@ MoveEffects.full = {
DREAM_EATER_EFFECT = { DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage) -- only works on sleeping targets (checked before damage)
gate = function(ctx) 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 return true
end, end,
afterDamage = drainHalf("_DreamWasEatenText", Strings.source("%s's\ndream was eaten!")), afterDamage = drainHalf("_DreamWasEatenText", Strings.source("%s's\ndream was eaten!")),
+4 -1
View File
@@ -244,7 +244,10 @@ end
local function drawMoveMenu(battle) local function drawMoveMenu(battle)
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex) 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 col = (battle.moveSwapIndex - 1) % 2
local row = math.floor((battle.moveSwapIndex - 1) / 2) local row = math.floor((battle.moveSwapIndex - 1) / 2)
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16) Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
+114 -4
View File
@@ -799,6 +799,7 @@ end
function Game:focus(f) function Game:focus(f)
Input:reset() Input:reset()
TouchControls:reset() TouchControls:reset()
self:cancelPointers()
end end
function Game:visible(v) function Game:visible(v)
@@ -807,12 +808,14 @@ function Game:visible(v)
else else
Input:reset() Input:reset()
TouchControls:reset() TouchControls:reset()
self:cancelPointers()
end end
end end
function Game:onResume() function Game:onResume()
Input:reset() Input:reset()
TouchControls:reset() TouchControls:reset()
self:cancelPointers()
-- Chip music may survive NX suspend as a duplicate stream; stop it and let -- 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). -- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music. -- Desktop/mobile window-visible flips must not kill overworld music.
@@ -828,6 +831,11 @@ end
function Game:recoverInput(event, joystick) function Game:recoverInput(event, joystick)
Input:reset() Input:reset()
TouchControls: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") local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then if SwitchDiagnostics.isEnabled() then
if joystick then if joystick then
@@ -850,16 +858,118 @@ function Game:joystickremoved(joystick)
TouchControls:joystickremoved() TouchControls:joystickremoved()
end end
function Game:touchpressed(id, x, y) -- Gameplay pointer seam (#807). TouchControls keeps first refusal: a
TouchControls:touchpressed(id, x, y) -- 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 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) 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 end
function Game:touchreleased(id, x, y) function Game:touchreleased(id, x, y, dx, dy, pressure)
TouchControls:touchreleased(id, x, y) 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 end
-- Point the loader's mod.save backing at this save's modData so per-mod -- Point the loader's mod.save backing at this save's modData so per-mod
+15
View File
@@ -186,6 +186,21 @@ function Input:overlayReleased(btn)
release(self, btn, "touch:" .. btn) release(self, btn, "touch:" .. btn)
end end
-- Programmatic mod input (#807). mod.input taps and holds land here under
-- loader-issued "mod:<id>:<n>" 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) function Input:gamepadpressed(joystick, button)
local btn = self.padBindings[button] local btn = self.padBindings[button]
if btn then if btn then
+8 -1
View File
@@ -423,11 +423,17 @@ local function setDpad(self, touch, dir)
if dir then pressBtn(self, dir) end if dir then pressBtn(self, dir) end
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) function TouchControls:touchpressed(id, x, y)
-- preview mode is layout-edit only: never press GB buttons -- preview mode is layout-edit only: never press GB buttons
if self.preview then return end if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) 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 -- 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 if self.controllerHidden then
self.controllerHidden = false self.controllerHidden = false
return return
@@ -437,7 +443,7 @@ function TouchControls:touchpressed(id, x, y)
if inCircle(L[btn], x, y, SLOP[btn]) then if inCircle(L[btn], x, y, SLOP[btn]) then
self.touches[id] = { control = btn } self.touches[id] = { control = btn }
pressBtn(self, btn) pressBtn(self, btn)
return return true
end end
end end
-- square hit zone a bit past the cross art; one owning finger at a time -- 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 } local touch = { control = "dpad", dir = nil }
self.touches[id] = touch self.touches[id] = touch
setDpad(self, touch, dpadDir(dz, x, y)) setDpad(self, touch, dpadDir(dz, x, y))
return true
end end
end end
+6
View File
@@ -31,6 +31,12 @@ function HotReload.run(game, opts)
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local data = game.data local data = game.data
if data and data.reloadGenerated then data:reloadGenerated() end 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) local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil)
loader.game = game loader.game = game
-- mod.save keeps pointing at the live slot across the reload -- mod.save keeps pointing at the live slot across the reload
+79
View File
@@ -138,6 +138,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {}, exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {},
fs = (opts and opts.fs) or (love and love.filesystem), fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev, dev = dev,
}, Loader) }, Loader)
@@ -527,6 +528,44 @@ function Loader:_registerCommand(modId, verb, fn)
return self.content.commands:register(verb, fn, modId) return self.content.commands:register(verb, fn, modId)
end 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) function Loader:_api(mod)
local loader = self local loader = self
local modId = mod.manifest.id local modId = mod.manifest.id
@@ -559,6 +598,45 @@ function Loader:_api(mod)
hooks = { wrap = function(_, name, callback, priority) hooks = { wrap = function(_, name, callback, priority)
return loader.hooks:wrap(name, callback, priority, modId) return loader.hooks:wrap(name, callback, priority, modId)
end }, 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:<id>:<n>" 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 -- the widget toolkit facade (12 4.5) is one shared surface, not
-- per-mod state; each widget inside it loads on first touch -- per-mod state; each widget inside it loads on first touch
ui = ModUI, ui = ModUI,
@@ -714,6 +792,7 @@ function Loader:_rollback(modId)
end end
self.events:removeOwner(modId) self.events:removeOwner(modId)
self.hooks:removeOwner(modId) self.hooks:removeOwner(modId)
self:releaseModInput(modId)
self.exports[modId] = nil self.exports[modId] = nil
self.optionSchemas[modId] = nil self.optionSchemas[modId] = nil
self.migrations[modId] = nil self.migrations[modId] = nil
+5 -2
View File
@@ -237,8 +237,11 @@ function ListMenu:draw()
if i == self.index then if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by -- hollowIndex: a chosen row keeps the hollow '▷' left behind by
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's -- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
-- auto A-press, home/list_menu.asm:89-91) -- auto A-press, home/list_menu.asm:89-91). A swap-marked row does
Font.drawCode((self.swapIndex == i or self.hollowIndex == i) -- 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) and Theme.cursorHollow or Theme.cursor, 8, y)
end end
if self.swapIndex == i and i ~= self.index then if self.swapIndex == i and i ~= self.index then
+4 -2
View File
@@ -776,8 +776,10 @@ function PartyMenu:draw()
if i == self.index then if i == self.index then
Font.drawCode(Theme.cursor, 0, cursorY) Font.drawCode(Theme.cursor, 0, cursorY)
end end
if i == self.swapFrom or i == self.softboiledFrom then -- the unfilled swap arrow; the filled cursor replaces it in the tilemap
Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow -- 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
end end
if self.swapFrom then if self.swapFrom then
@@ -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
@@ -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
+327
View File
@@ -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")