CLOSES #505, CLOSES #510, CLOSES #513

This commit is contained in:
bryanthaboi
2026-07-31 09:08:56 -04:00
parent 71bdbb7e48
commit 6796804593
6 changed files with 539 additions and 3 deletions
+11
View File
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if result == "bicycle" then
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
-- runs, which is why it precedes list:close() here.
if game.save.forcedBike then
showMessages(game, { Strings("You can't get off\nhere.") })
return
end
list:close()
local ow = game.overworld
local Music = require("src.core.Music")
+22 -2
View File
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end
-- A rebind reaches Input only when this screen closes (#510). The menu
-- steers by the live map, so applying "B = Z" the instant it was captured
-- turned the player's next confirm press into a cancel and shut the
-- screen mid-swap. options.bindings is still written immediately, and
-- Game:applyOptions re-applies it on load, so a close that skips this
-- hook still ends up with the saved map.
self.onCancel = function() self:commitBindings() end
return self
end
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
-- the live map here, on close, and nowhere else in this screen.
function BindingsMenu:commitBindings()
local game = self.game
local opts = game and game.save and game.save.options
if opts then Input:applyBindings(opts.bindings) end
end
-- the capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed
function BindingsMenu:beginCapture(item)
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
self.onGamepadPressed = BindingsMenu.capturePad
end
-- Escape is the capture's way out, so it is never captured: every other
-- key is bindable, which otherwise leaves an armed row with no exit but
-- to bind something (#510). Escape stays START in Input's default map,
-- which no rebind removes, so reserving it costs the player nothing.
function BindingsMenu:captureKey(key)
if key == "escape" then return self:storeBinding("key", nil) end
self:storeBinding("key", key)
end
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
@@ -112,9 +131,10 @@ end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 4)
Font.drawBox(1, 6, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
love.graphics.setColor(1, 1, 1, 1)
end
end
+27 -1
View File
@@ -3,6 +3,7 @@
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
-- map scripts (data/scripts/), marts, nurses or extracted text.
local Assets = require("src.render.Assets")
local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
@@ -3932,6 +3933,31 @@ function OverworldState:draw()
self:drawUI()
end
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
-- and it resolves through Assets so a mod's emotes.png override still wins.
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
-- bubble's corners out, so carry it through untouched.
local function obpEmoteImage(path)
if not (love.image and love.image.newImageData) then
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
end
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, _, _, a)
local v = 0
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
end -- OBJ color 3 -> shade 3
return v, v, v, a
end)
return love.graphics.newImage(id)
end
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
-- World zones are rectangles in flat world-canvas space (the current map's
-- base fills the view; neighbour maps stack on top), so the last zone that
@@ -4171,7 +4197,7 @@ function OverworldState:drawWorld()
local drawn = false
if bubble and bubble.path then
local ok, img = pcall(function()
self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path)
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
return self.emoteImg
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
+215
View File
@@ -0,0 +1,215 @@
-- Manual check that the BICYCLE refuses to come off on the Cycling Road (#513).
-- pokered gates this in StartMenu_Item .useOrTossItem (engine/menus/
-- start_sub_menus.asm): with BIT_ALWAYS_ON_BIKE set it prints
-- _CannotGetOffHereText and `jp ItemMenuLoop`, so the bag stays open and
-- ItemUseBicycle never runs. Do not add POKEPORT_SPEED: it scales only the
-- logic clock, and the menu/text ordering under test is what is being judged.
-- POKEPORT_DRIVER=tests/drivers/bike_dismount_bug513_test.lua POKEPORT_IDENTITY=bug513 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
-- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,10) and (17,11) are
-- the two cells you land on walking out of the Route 16 gate's south door,
-- and they are what arms BIT_ALWAYS_ON_BIKE. Landing there by warp is the
-- real path, and setMap runs checkForcedMovement on entry, so a teleport
-- onto the cell arms the flag exactly like the warp does.
local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11
-- the walking exit from the stretch (FieldDefaults forcedMovement.clearMaps,
-- from scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`)
local GATE_MAP = "ROUTE_16_GATE_1F"
-- pokered data/maps/objects/Route17.asm: the topmost bikers stand at
-- (11,16), (12,19) and (4,18), so the y 8-14 stretch is the open top of the
-- road with nobody's line of sight crossing it. Route 17 is 20x144 cells,
-- and the bike rolls south on its own, so start high enough that the roll
-- below cannot carry the player into BIKER2's row.
local ROAD_MAP, ROAD_X, ROAD_Y = "ROUTE_17", 11, 9
local cannot = game.data.text._CannotGetOffHereText
check("_CannotGetOffHereText was extracted",
type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil)
local fm = game.data.field.forcedMovement
local forced = fm and fm.tiles and fm.tiles[FORCE_MAP]
check("the Route 16 forced-bike cells are in field.forcedMovement",
type(forced) == "table" and #forced > 0)
check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil)
game.save.player.name = "SEBAS"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.inventory.BICYCLE = 1
game.save.forcedBike = nil
game.save.onBike = false
-- arm the flag the way the game does, by arriving on the forced cell
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
U.wait(20)
check("stepping off the Route 16 gate mounts the BICYCLE", game.save.onBike == true)
check("and sets the forced-bike flag", game.save.forcedBike == true)
-- the release half: the gate map has to clear it again, or the fix would
-- trap the player on the bike forever, which reads on screen exactly like
-- the refusal working
-- pokered data/maps/objects/Route16Gate1F.asm: the warps sit on x 0 and 7,
-- the guard on (4,5), so (3,6) is plain floor inside the gate
U.teleport(game, GATE_MAP, 3, 6, "up")
U.wait(20)
check("walking into the Route 16 gate clears the flag again",
game.save.forcedBike == nil or game.save.forcedBike == false)
-- back out onto the road, then down to Route 17 where the report's
-- screenshot was taken
game.save.onBike = false
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
U.wait(20)
U.teleport(game, ROAD_MAP, ROAD_X, ROAD_Y, "down")
-- Route 17 is a slopeMap: with no button held the bike rolls south every
-- frame the player is standing still (handleInput's simulated PAD_DOWN),
-- and OverworldLoop only looks at START between steps. Holding B is the
-- brake the Route 17 sign describes, so hold it while waiting, exactly
-- like a player coming to a stop before opening the menu (#255).
local function brake(frames)
for _ = 1, frames do
game.input.state.b = true
coroutine.yield()
end
game.input.state.b = false
end
brake(30)
local ow = game.overworld
local function freeCell(x, y)
if not (ow and ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then
return false
end
if ow:npcAtCell(x, y) then return false end
for _, n in ipairs(ow.npcs or {}) do
if math.abs(n.cellX - x) + math.abs(n.cellY - y) <= 3 then return false end
end
return true
end
if ow and not freeCell(ROAD_X, ROAD_Y) then
-- a map edit or a mod moved the road: take any open cell in the top
-- stretch rather than parking the player inside the guard rail
local found
for y = 8, 30 do
for x = 0, 19 do
if freeCell(x, y) then found = { x, y } break end
end
if found then break end
end
if found then
U.log(("(%d,%d) is blocked, standing at"):format(ROAD_X, ROAD_Y),
found[1], found[2])
U.teleport(game, ROAD_MAP, found[1], found[2], "down")
brake(30)
ow = game.overworld
else
check("found an open cell on the Route 17 top stretch", false)
end
end
check("on Route 17, still riding, flag still armed",
game.overworld and game.overworld.map.id == ROAD_MAP
and game.save.onBike == true and game.save.forcedBike == true)
-- open START -> ITEM -> BICYCLE -> USE for real, row by row, so a menu
-- that reorders itself shows up as a FAIL instead of a silent misclick
local function stepTo(state, wants, what)
for _ = 1, 20 do
if wants(state) then return true end
U.tap(game, "down")
U.wait(6)
end
check("found " .. what .. " in the menu", false)
return false
end
-- brake into a standstill first, then press START on a frame where the
-- player is between no steps at all, or handleInput drops it
for _ = 1, 90 do
game.input.state.b = true
if not (game.overworld and game.overworld.player.moving) then break end
coroutine.yield()
end
table.insert(game.input.pressQueue, "start")
U.wait(1)
game.input.state.start = false
game.input.state.b = false
U.wait(20)
local menu = game.stack:top()
local Strings = require("src.core.Strings")
local ITEM = Strings("ITEM")
if check("START opened a menu", menu and menu.items ~= nil) then
if stepTo(menu, function(m) return m.items[m.index].label == ITEM end, "ITEM") then
U.tap(game, "a")
U.wait(20)
end
end
local bag = game.stack:top()
local isBag = bag and bag.items and bag.items[1] and bag.items[1].value ~= nil
if check("the bag opened", isBag) then
if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end,
"BICYCLE") then
U.tap(game, "a") -- USE / TOSS submenu
U.wait(20)
U.tap(game, "a") -- USE is the first row
U.wait(30)
end
end
local top = game.stack:top()
local box = getmetatable(top) == TextBox and top or nil
if check("using the BICYCLE printed something", box ~= nil) then
local lines = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
local said = table.concat(lines, " / ")
U.log("the box reads:", said)
check("it is the refusal, not the dismount message",
said:find("get off", 1, true) ~= nil
and said:find("BICYCLE", 1, true) == nil)
end
check("the player is still on the BICYCLE", game.save.onBike == true)
-- pokered jumps back to ItemMenuLoop rather than closing the menu, so the
-- bag list must still be underneath the box
local bagStillUp = false
for _, s in ipairs(game.stack.states) do
if s == bag then bagStillUp = true end
end
check("the bag list is still open under the box", bagStillUp)
U.wait(90) -- let the box finish typing, or the shot catches half a word
local shotPath = SHOT_DIR .. "/bug513_bike_refusal.png"
check("screenshot reached disk", U.shot(game, shotPath))
U.log("")
if ok then
U.log("the BICYCLE has already been used once on Route 17; the box on")
U.log("screen is that refusal. it should read \"You can't get off / here.\"")
U.log("and B should drop you back into the bag list with the sprite still")
U.log("on the bike, never onto your feet. shot saved to " .. shotPath)
U.log("press B twice and try it again as often as you like. the road")
U.log("rolls you south whenever you let go, so hold A or B to stop.")
U.log("then ride north off Route 17 and into the Route 16 gate, where")
U.log("USE should print \"SEBAS got off the BICYCLE.\" and put you on")
U.log("foot -- if it refuses in there too, the guard never releases the")
U.log("flag and the fix went a step too far.")
else
U.log("a check above failed, so nothing on screen is worth reading yet.")
end
while true do
coroutine.yield()
end
end
+148
View File
@@ -0,0 +1,148 @@
-- Manual check on the "!" bubble a trainer pops when he spots you (#505).
-- The sheet is OBJ art, so it displays through OBP0, and GBPalNormal
-- (pokered home/palettes.asm:20-26, `ld a, %11010000 ; 3100`) shows OBJ
-- color 1 as shade 0. The draw site blitted the raw png instead, leaving
-- the bubble's interior at its pre-OBP0 grey. Do not add POKEPORT_SPEED:
-- the sight-line freeze and the 60-frame bubble hold are being watched.
-- POKEPORT_DRIVER=tests/drivers/emote_bubble_bug505_test.lua POKEPORT_IDENTITY=bug505 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Assets = require("src.render.Assets")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local failed = 0
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failed = failed + 1 end
return ok
end
-- extraction, asset resolution and a renamed crop all look like the bug on
-- screen (grey bubble, or no bubble at all), so separate them here first
local bubble = game.data.field and game.data.field.emotionBubbles
check("field data carries the emote sheet",
type(bubble) == "table" and type(bubble.path) == "string")
local crop = bubble and bubble.bubbles and bubble.bubbles[1]
check("crop 1 is EXCLAMATION_BUBBLE 16x16 at the sheet origin",
crop ~= nil and crop.name == "EXCLAMATION_BUBBLE"
and crop.w == 16 and crop.h == 16 and crop.x == 0 and crop.y == 0)
-- the source art is the pre-OBP0 art and is supposed to look grey: the
-- body is OBJ color 1, which the extractor writes as DMG shade 1 (170),
-- and OBJ color 0 (the corners) carries the sheet's tRNS alpha. If this
-- ever reads white the extractor changed and the bake below is moot.
local BODY_X, BODY_Y = 4, 5 -- inside the outline, left of the "!" stem
local EDGE_X, EDGE_Y = 1, 4 -- the black outline column
if bubble and bubble.path then
local ok, src = pcall(Assets.imageData, bubble.path)
check("the sheet resolves and decodes: " .. bubble.path, ok and src ~= nil)
if ok and src then
local r = select(1, src:getPixel(BODY_X, BODY_Y))
local _, _, _, ca = src:getPixel(0, 0)
U.log(("source art body pixel %.3f, corner alpha %.3f"):format(r, ca))
check("source body is the shade 1 grey the bug showed on screen",
math.abs(r - 170 / 255) < 0.04)
check("source corner is keyed out by tRNS", ca < 0.5)
end
end
-- pokered data/maps/objects/Route3.asm:22 -- the Bug Catcher stands at
-- (10, 6) with range RIGHT, so his line runs east along row 6 and (12, 6)
-- is the detection tile. Start one cell outside it and walk in.
local MAP, TRAINER = "ROUTE_3", "ROUTE3_YOUNGSTER1"
local stand = { x = 13, y = 6, facing = "left", walk = "left" }
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
local ow = game.overworld
local npc
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == TRAINER then npc = n end
end
check("the Bug Catcher loaded on " .. MAP, npc ~= nil)
-- a map edit that moves or re-aims him would park us facing empty grass,
-- so re-derive the approach from where he actually is and which way he
-- looks: stand three cells down his line and walk back along it
if npc and (npc.cellX ~= 10 or npc.cellY ~= 6 or npc.facing ~= "right") then
local away = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
local back = { up = "down", down = "up", left = "right", right = "left" }
local d = away[npc.facing] or away.right
local cx, cy = npc.cellX + d[1] * 3, npc.cellY + d[2] * 3
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("he is not at (10, 6) facing right any more, approaching from",
cx, cy)
stand = { x = cx, y = cy, facing = back[npc.facing] or "left",
walk = back[npc.facing] or "left" }
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
ow = game.overworld
end
end
-- walk into the line one frame at a time and stop the moment the bubble
-- is up, so the shot lands well inside the 60-frame hold
local spotted = false
for _ = 1, 90 do
U.hold(game, stand.walk, 1)
if ow.emote and ow.emote.npc and ow.emote.bubble ~= false then
spotted = true
break
end
end
check("walking in triggered the bubble", spotted)
U.log("player at", ow.player.cellX, ow.player.cellY,
"bubble frames left:", ow.emote and ow.emote.frames)
local shotPath = DIR .. "/bug505_emote.png"
local wrote = U.shot(game, shotPath)
check("the window rendered and the screenshot reached disk", wrote)
-- what the human sees is one 16x16 blit of ow.emoteImg, so read that
-- image back rather than hunting pixels in the framebuffer: draw it to a
-- scratch canvas and sample it. Canvas readback outside love.draw is not
-- guaranteed on every driver, hence the pcall.
local img = ow.emoteImg
check("the draw site built its emote image", img ~= nil)
if img then
local ok, body, edge, corner = pcall(function()
local canvas = love.graphics.newCanvas(img:getDimensions())
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 0, 0)
love.graphics.setCanvas()
local id = canvas:newImageData()
local b = select(1, id:getPixel(BODY_X, BODY_Y))
local e = select(1, id:getPixel(EDGE_X, EDGE_Y))
local _, _, _, a = id:getPixel(0, 0)
return b, e, a
end)
if ok then
U.log(("baked body %.3f, outline %.3f, corner alpha %.3f")
:format(body, edge, corner))
check("the bubble body came out white, not the 170 grey of #505",
body > 0.9)
check("the outline is still black", edge < 0.1)
check("the corners are still keyed out", corner < 0.5)
else
U.log("could not read the baked image back:", tostring(body))
U.log("judge the colour off the screenshot alone")
end
end
if failed > 0 then
U.log(failed, "check(s) failed above; fix those before eyeballing anything")
end
-- put him back on the board so the moment can be replayed by hand
U.wait(20)
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
U.log("the bubble has already been triggered once and saved to " .. shotPath)
U.log("its interior should read pure white with a black outline and the")
U.log("grass showing through the corners; #505 was a flat mid grey inside.")
U.log("hold " .. stand.walk .. " to walk into his line and pop it again.")
while true do
coroutine.yield()
end
end
+116
View File
@@ -0,0 +1,116 @@
-- CONTROLS rebinding: a captured key must not reach the live map while the
-- screen that captured it is still steering by that map (#510). Swapping A
-- and B used to close the screen mid-swap, because Input:applyBindings ran
-- inside BindingsMenu:storeBinding and turned the player's next confirm
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
-- luajit tests/engine/rebind_capture_bug510.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local BindingsMenu = require("src.ui.BindingsMenu")
-- the two doubles BindingsMenu touches: a stack it can pop itself off and
-- an input whose queue is one fixed step of edges
local function newGame()
local game = { save = { options = {} }, wroteOptions = 0 }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
return game
end
local function press(state, btn)
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
end
local function openMenu(game)
local bm = BindingsMenu.new(game)
game.stack:push(bm)
return bm
end
-- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B
local ROW_A, ROW_B = 5, 6
-- The mechanism, pinned so a future "just apply it immediately" revert
-- fails here: a rebind overwrites whatever the key used to do, so B = Z
-- costs Z its default A action.
Input:init()
eq(Input.keyBindings["z"], "a", "Z presses A in the default map")
Input:applyBindings({ b = { key = "z" } })
eq(Input.keyBindings["z"], "b",
"applying B = Z takes Z away from A, so a live apply would flip confirm "
.. "into cancel")
Input:init()
-- The reporter's flow: arm B, bind Z to it, then keep using the menu.
local game = newGame()
local bm = openMenu(game)
press(bm, "a") -- open the row the cursor starts on to prove arming
eq(bm.capture, bm.items[1], "A on a row arms the capture")
bm:onKeyPressed("escape")
check(bm.capture == nil, "escape disarms an armed capture")
check(game.save.options.bindings == nil,
"escaping a capture writes no binding")
eq(game.wroteOptions, 0, "and does not touch options on disk")
bm.index = ROW_B
press(bm, "a")
bm:onKeyPressed("z")
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
eq(game.wroteOptions, 1, "the choice persists immediately")
eq(Input.keyBindings["z"], "a",
"but the live map still reads Z as A while the screen is open (#510)")
eq(#game.stack.states, 1, "capturing Z does not close the screen")
-- the next Z the player presses is still confirm, so the A row can be armed
bm.index = ROW_A
press(bm, "a")
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
bm:onKeyPressed("x")
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
-- closing commits both halves at once, through ListMenu's onCancel
press(bm, "b")
eq(#game.stack.states, 0, "B closes the rebind screen")
eq(Input.keyBindings["z"], "b", "closing puts the swap live: Z is B")
eq(Input.keyBindings["x"], "a", "and X is A")
-- A close that never runs the hook still ends up correct, because
-- Game:applyOptions re-applies save.options.bindings on load.
Input:init()
Input:applyBindings(game.save.options.bindings)
eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close")
-- pad captures ride the same deferral
Input:init()
local padGame = newGame()
local padBm = openMenu(padGame)
padBm.index = ROW_B
press(padBm, "a")
padBm:onGamepadPressed("y")
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
press(padBm, "b")
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
Input:init()
T.finish("rebind_capture_bug510")