Merge branch 'dev' into feat/switch-nx

Bring latest upstream fixes (Metal/iOS, encounter slide, second-screen seam) into the Switch NX feature branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 12:12:07 -03:00
84 changed files with 7435 additions and 265 deletions
+158
View File
@@ -0,0 +1,158 @@
-- Cycling COLORS during a battle must keep the battle song (#484). The
-- pack change rebuilds the live map's baked atlas through
-- OverworldState:reloadMap, and that used to re-enter the map: setMap's
-- PlayMapMusic put the route theme over the battle theme. ReloadMapData
-- (home/reload_tiles.asm) only re-reads the map view and the tileset
-- patterns; map music comes from LoadMapData alone (home/overworld.asm,
-- gated on BIT_NO_MAP_MUSIC), so a reload is not a map entry.
-- The other half matters as much: the hotkey must still work in a battle,
-- so a "fix" that gates key 2 off while BattleState is on top fails here.
-- luajit tests/engine/battle_colors_bug484.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 OverworldState = require("src.world.OverworldController")
-- OverworldController takes its collaborators as file-locals, and Game is
-- only bound when a real state enters; swapping the upvalues is how
-- tests/mod_world_tests.lua drives one of these methods with no dataset.
local function setUpvalue(fn, name, value)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then
debug.setupvalue(fn, i, value)
return true
end
i = i + 1
end
end
local emitted = {}
for _, up in ipairs({
{ "MapLoader", { invalidate = function() end,
invalidateAll = function() end } },
{ "Collision", { load = function() end } },
{ "Game", { data = {} } },
{ "Logger", { warn = function() end, error = function() end } },
{ "Runtime", { emit = function(name, payload)
emitted[#emitted + 1] = { name = name, payload = payload }
end } },
}) do
check(setUpvalue(OverworldState.reloadMap, up[1], up[2]),
"reloadMap still closes over " .. up[1])
end
-- A state stub with just what reloadMap reads: the live map, the player's
-- cell, and a setMap that records the opts instead of loading anything.
-- `shrank` makes the reloaded map report the player's cell out of bounds
-- once, which is the only way past the first setMap.
local function newState(mapId, shrank)
local calls, entered = {}, 0
return {
map = { id = mapId, inBounds = function() return true end },
player = { cellX = 5, cellY = 5, facing = "down" },
neighbors = {},
calls = calls,
setMap = function(self, id, x, y, facing, opts)
calls[#calls + 1] = { id = id, x = x, y = y, facing = facing, opts = opts }
entered = entered + 1
local ok = not (shrank and entered == 1)
self.map = { id = id, inBounds = function() return ok end }
end,
healPoint = function() return { map = "PALLET_TOWN", x = 3, y = 6 } end,
}
end
local live = newState("ROUTE_1")
OverworldState.reloadMap(live, "ROUTE_1", "colors")
eq(#live.calls, 1, "reloading the live map re-enters it exactly once")
local opts = live.calls[1].opts or {}
check(opts.keepMusic == true,
"the reload keeps whatever song is playing (#484): a COLORS cycle "
.. "during a battle must not start the route theme")
eq(opts.via, "reload", "and it is still tagged as a reload, not a warp")
check(opts.seamless == true, "with no transition wipe")
eq(live.calls[1].x, 5, "the player is put back on the cell they were on")
eq(live.calls[1].y, 5, "on both axes")
-- the escape hatch under it is a genuine map change, and PlayMapMusic
-- belongs there: the player is being sent to a heal point
local shrunk = newState("ROUTE_1", true)
OverworldState.reloadMap(shrunk, "ROUTE_1", "colors")
eq(#shrunk.calls, 2, "a map that shrank under the player sends them away")
eq(shrunk.calls[2].id, "PALLET_TOWN", "to their heal point")
check(not (shrunk.calls[2].opts or {}).keepMusic,
"and that one starts the destination's own music")
local other = newState("ROUTE_1")
OverworldState.reloadMap(other, "VIRIDIAN_CITY", "colors")
eq(#other.calls, 0, "reloading a map that is not live touches nothing")
check(emitted[#emitted] and emitted[#emitted].name == "map.reloaded",
"map.reloaded still fires either way")
-- The hotkey half, end to end through the real Game and PaletteFX: with a
-- battle on top the COLORS key is not gated off, and every press moves one
-- rung down the ladder.
local Game = require("src.core.Game")
local PaletteFX = require("src.render.PaletteFX")
local reloads = {}
local ow = {
map = { id = "ROUTE_1" },
reloadMap = function(_, id, reason)
reloads[#reloads + 1] = { id = id, reason = reason }
end,
}
Game.overworld = ow -- PaletteFX.setMode reaches the live overworld this way
local battle = { isOpaque = true } -- stands in for BattleState on the stack
local session = {
overworld = ow,
save = { options = {} },
stack = { states = { ow, battle },
top = function(self) return self.states[#self.states] end },
writeOptions = function() end,
}
local seen, start = {}, PaletteFX.mode
for _ = 1, #PaletteFX.MODES do
Game.keypressed(session, "2")
seen[#seen + 1] = PaletteFX.mode
end
eq(#reloads, #PaletteFX.MODES,
"every COLORS press in a battle still rebuilds the map's atlas")
eq(reloads[1].reason, "colors", "tagged as the COLORS cycle")
eq(reloads[1].id, "ROUTE_1", "on the map the player is standing on")
local distinct = {}
for _, mode in ipairs(seen) do distinct[mode] = true end
local count = 0
for _ in pairs(distinct) do count = count + 1 end
eq(count, #PaletteFX.MODES,
"the palette visibly cycles: one press per mode walks the whole ladder")
eq(PaletteFX.mode, start, "and lands back where it started")
eq(session.save.options.colors, PaletteFX.mode,
"the choice is written to options like it is in the overworld")
-- the pre-existing gate is unchanged: a script driving the overworld still
-- holds the key off, because reloadMap rebuilds the live NPC array
local busy = {
overworld = ow,
save = { options = {} },
stack = { states = { ow }, top = function(self) return self.states[1] end },
writeOptions = function() end,
}
ow.runner = { isRunning = function() return true end }
local before = PaletteFX.mode
local reloadsBefore = #reloads
Game.keypressed(busy, "2")
eq(PaletteFX.mode, before, "a running map script still holds COLORS off")
eq(#reloads, reloadsBefore, "and nothing reloads under it")
T.finish("battle_colors_bug484")
+128
View File
@@ -0,0 +1,128 @@
-- The Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
--
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60):
-- with the scope in the bag the battle still ENTERS disguised -- InitWildBattle
-- takes its .isGhost branch on wCurOpponent == RESTLESS_SOUL either way -- and
-- the scope only buys the unveil that is played over the disguise:
-- EnemyAppearedText, UnveiledGhostText, LoadEnemyMonData, MarowakAnim, then
-- WildMonAppearedText. The port used to hand the scope-carrying player a
-- battle that opened with MAROWAK already on screen and no unveil at all.
--
-- The story3 dispatch half (which branch the trigger picks, and that the ball
-- dodge survives the unveil) is tests/parity_marowak.lua and
-- tests/parity_marowak_ball.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
T.check(type(BattleState.makeUnveiledGhost) == "function",
"BattleState:makeUnveiledGhost exists")
T.check(BattleState.makeUnveiledGhost ~= BattleState.makeGhost,
"the unveiled battle is not just the scope-less ghost battle")
local REAL_SPRITE = { marker = "the MAROWAK front pic" }
local function newBattle()
return setmetatable({
kind = "wild",
queue = {},
frame = 0,
data = { text = {}, pokemon = {} },
enemy = { name = "MAROWAK", sprite = REAL_SPRITE,
mon = { species = "MAROWAK", level = 30 } },
}, BattleState)
end
-- ------------------------------------------------------- entering disguised
local b = newBattle()
b:makeUnveiledGhost()
T.eq(b.enemy.name, "GHOST",
"with the scope the battle still OPENS as the GHOST (#492)")
T.check(b.enemy.sprite ~= REAL_SPRITE,
"and wears the ghost pic, not the MAROWAK one")
T.check(b.scopeReveal == true, "the unveil is armed")
T.check(not b.ghost,
"but IsGhostBattle stays false: the mon can be attacked and flees normally")
T.eq(b.ghostReal.name, "MAROWAK", "the real nick is kept for LoadEnemyMonData")
T.eq(b.ghostReal.sprite, REAL_SPRITE, "...and the real pic with it")
local ghostSprite = b.enemy.sprite
-- ------------------------------------------------------------- the row order
-- .isMarowak prints over the "GHOST appeared!" box enter() already queued: the
-- unveil line, MarowakAnim (a wait row parked over the fx state machine), then
-- "Wild MAROWAK appeared!" under the restored name.
b:queueScopeReveal()
T.eq(#b.queue, 4, "the unveil is four queue rows")
T.check(type(b.queue[1].text) == "string"
and b.queue[1].text:find("SILPH SCOPE", 1, true) ~= nil,
"row 1 is UnveiledGhostText")
T.check(type(b.queue[2].fn) == "function", "row 2 starts MarowakAnim")
T.eq(b.queue[3].wait, BattleState.GHOST_REVEAL_FRAMES,
"row 3 holds the queue for the length of the animation")
T.check(type(b.queue[4].text) == "string"
and b.queue[4].text:find("MAROWAK", 1, true) ~= nil,
"row 4 is WildMonAppearedText under the real name")
T.check(b.queue[4].text:find("GHOST", 1, true) == nil,
"...and not under the disguise")
-- ------------------------------------------------------------ the animation
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm): FlashSprite8Times, the
-- rOBP1 fade-out, the pic swap, the fade-in. The whole point of #492 is that
-- the swap comes AFTER the ghost has faded off the paper, so every frame up to
-- there still shows the GHOST.
b.queue[2].fn()
T.check(b.ghostReveal ~= nil, "the fn arms the reveal state")
local FLASH = BattleState.GHOST_FLASH_FRAMES
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
local swapFrame, fades, stillGhost = nil, {}, true
for frame = 1, BattleState.GHOST_REVEAL_FRAMES do
b:updateFx()
local pf = b.picFx and b.picFx[b.enemy]
fades[frame] = pf and pf.fade
if not swapFrame and b.enemy.name ~= "GHOST" then swapFrame = frame end
if frame <= FLASH + FADE_OUT and b.enemy.name ~= "GHOST" then
stillGhost = false
end
end
T.check(stillGhost,
"the GHOST is on screen for the whole flash and fade-out (#492)")
T.eq(swapFrame, FLASH + FADE_OUT + 1,
"the pic and nick swap the frame after the ghost has faded out")
T.eq(b.enemy.name, "MAROWAK", "the real nick is back by the end")
T.eq(b.enemy.sprite, REAL_SPRITE, "and the real pic with it")
T.check(b.enemy.sprite ~= ghostSprite, "the ghost pic is gone")
-- FlashSprite8Times xors rOBP1 with $80 every 10 frames, so the body alternates
-- between two shades rather than sitting opaque
T.eq(fades[5], 1, "the flash opens on the ghost at full strength")
T.eq(fades[15], 0.5, "...drops a shade 10 frames in")
T.eq(fades[25], 1, "...and comes back")
T.check(fades[FLASH + FADE_OUT] == 0,
"the ghost is fully faded out when the swap lands")
T.check(fades[FLASH + FADE_OUT + 1] > 0
and fades[FLASH + FADE_OUT + 1] < 1,
"the MAROWAK fades IN rather than popping on")
T.eq(fades[BattleState.GHOST_REVEAL_FRAMES - 1], 1,
"and reaches full strength before the box turns")
T.check(b.ghostReveal == nil, "the reveal state clears itself")
T.check(b.scopeReveal == nil, "so does the flag that gated the intro cry")
local pf = b.picFx and b.picFx[b.enemy]
T.check(pf == nil or pf.fade == nil,
"no leftover alpha on the pic for the rest of the battle")
-- ------------------------------------------------ the scope-less ghost still
-- Nothing above may change the battle you get WITHOUT the scope: still a real
-- ghost battle, still no unveil queued.
local noScope = newBattle()
noScope:makeGhost()
T.check(noScope.ghost == true, "without the scope IsGhostBattle is true")
T.eq(noScope.enemy.name, "GHOST", "and the disguise is the same one")
T.check(noScope.scopeReveal == nil, "with no unveil armed")
T.eq(#noScope.queue, 0, "and no unveil rows queued")
T.finish("ghost unveil")
+156
View File
@@ -0,0 +1,156 @@
-- Menu button sounds (#570). HandleMenuInput_ (home/window.asm) replays
-- SFX_PRESS_AB whenever the watched keys it just returned on include
-- PAD_A | PAD_B, unless BIT_NO_MENU_BUTTON_SOUND is set in wMiscFlags;
-- DisplayListMenuID watches PAD_A | PAD_B | PAD_SELECT (home/list_menu.asm),
-- so SELECT is answered but never beeps; DisplayOptionMenu plays it only at
-- .exitMenu (engine/menus/main_menu.asm), i.e. B, START and A on CANCEL.
-- Silence is half the contract: a click the original does not make is as
-- wrong as one it makes and the port swallows.
-- luajit tests/engine/menu_click_bug570.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")
-- The UI modules reach for Sound lazily (require inside the press branch),
-- so seeding package.loaded before they load is enough to see every cue.
local played = {}
package.loaded["src.core.Sound"] = {
play = function(_, key) played[#played + 1] = key end,
}
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local OptionsMenu = require("src.ui.OptionsMenu")
local PartyMenu = require("src.ui.PartyMenu")
-- A stub game with the three things a menu touches: a stack it can pop
-- itself off, an input queue that is one fixed step of edges, and a
-- non-nil `data` (the beep helpers skip themselves when data is nil, which
-- is how the harness-driven screens stay silent).
local function newGame()
local game = { data = {}, save = { options = {}, party = {},
flags = {}, inventory = {} } }
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() end
return game
end
-- one fixed step with `btn` on its edge; returns the cues it produced
local function press(state, btn)
played = {}
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
return played
end
local function beeps(state, btn)
local cues = press(state, btn)
for _, key in ipairs(cues) do
if key ~= "Press_AB" then
check(false, "unexpected cue " .. tostring(key) .. " on " .. btn)
end
end
return #cues
end
-- ITEM and POKéDEX are both DisplayListMenuID lists, and cancelling either
-- was the silent half of the report.
local function newList(opts)
local game = newGame()
local list = ListMenu.new(game, "BAG",
{ { label = "POTION", value = "POTION" },
{ label = "ANTIDOTE", value = "ANTIDOTE" } }, opts)
game.stack:push(list)
return list, game
end
local list = newList({ onChoose = function() end })
eq(beeps(list, "a"), 1, "the first A on an item in ITEM clicks once (#570)")
eq(beeps(list, "b"), 1, "B out of a list clicks once (ITEM, POKéDEX cancel)")
local moves = newList({ onChoose = function() end,
onSelectKey = function() end })
eq(beeps(moves, "select"), 0,
"SELECT is watched by DisplayListMenuID but outside its PAD_A | PAD_B "
.. "sound test, so the swap key stays silent")
eq(beeps(moves, "up"), 0, "moving the cursor is silent")
eq(beeps(moves, "down"), 0, "moving the cursor is silent both ways")
-- Both PC sessions set BIT_NO_MENU_BUTTON_SOUND for their whole run
-- (engine/menus/pc.asm, engine/menus/players_pc.asm), so their lists are
-- the control case: the same code path, deliberately mute.
local pc = newList({ noSound = true, onChoose = function() end })
eq(beeps(pc, "a"), 0, "a PC list holds BIT_NO_MENU_BUTTON_SOUND: A is mute")
eq(beeps(pc, "b"), 0, "and so is backing out of it")
-- an emptied bag still answers A and B, and HandleMenuInput_ does not care
-- that the list has no rows
local emptyGame = newGame()
local empty = ListMenu.new(emptyGame, "BAG", {}, {})
emptyGame.stack:push(empty)
eq(beeps(empty, "a"), 1, "A out of an empty list still clicks")
-- OPTION: .exitMenu is the only PlaySound in DisplayOptionMenu
local function newOptions()
local game = newGame()
local om = OptionsMenu.new(game)
game.stack:push(om)
return om
end
local om = newOptions()
om.index = 1
eq(beeps(om, "a"), 0, "A on a setting row re-loops in the original: silent")
eq(beeps(om, "right"), 0, "and the Left/Right toggles are silent too")
eq(beeps(om, "left"), 0, "in both directions")
om.index = #om.rows + 1 -- CANCEL sits one past the hook-built rows
eq(beeps(om, "a"), 1, "A on CANCEL in OPTION clicks once (#570)")
eq(beeps(newOptions(), "b"), 1, "B out of OPTION clicks once (#570)")
eq(beeps(newOptions(), "start"), 1, "START leaves OPTION the same way")
-- POKéMON: HandlePartyMenuInput runs through HandleMenuInput_
local function newParty()
local game = newGame()
local pm = PartyMenu.new(game, {})
game.stack:push(pm)
return pm
end
eq(beeps(newParty(), "b"), 1, "B out of POKéMON clicks once (#570)")
eq(beeps(newParty(), "a"), 1, "and A in POKéMON clicks")
eq(beeps(newParty(), "down"), 0, "moving between slots is silent")
-- The start menu itself already beeped before the fix; pinned here because
-- an over-eager patch that beeps on every watched key would break it.
-- draw_start_menu.asm's mask includes PAD_START, and START is outside the
-- PAD_A | PAD_B sound test, so closing the menu with it is silent.
local function newStartMenu()
local game = newGame()
local m = Menu.new(game, { { label = "POKéDEX", onSelect = function() end },
{ label = "ITEM", onSelect = function() end } },
{ startCloses = true })
game.stack:push(m)
return m
end
eq(beeps(newStartMenu(), "a"), 1, "A on a start-menu row clicks")
eq(beeps(newStartMenu(), "b"), 1, "B closing the start menu clicks")
eq(beeps(newStartMenu(), "start"), 0,
"START closes the start menu silently: it is watched but not in the "
.. "PAD_A | PAD_B branch")
T.finish("menu_click_bug570")
+141
View File
@@ -0,0 +1,141 @@
-- Boxed-menu row geometry (#564 start menu, #572 the PC and Pokédex windows).
-- pokered's menu boxes hug their choices: the last one lands on the bottom
-- interior row, so a double-spaced menu whose box is n*2+2 tall starts at
-- ty+2 -- draw_start_menu.asm (TextBoxBorder at 10,0 b=$0e, then hlcoord
-- 12,2 with wTopMenuItemY 2), players_pc.asm (0,0 b=8 c=14, hlcoord 2,2).
-- The port drew every row one tile high, which parked the text against the
-- top border and left the bottom interior row blank.
-- ROM-free: builds the real screens over tests/fixture_data.
-- luajit tests/engine/menu_row_offset_bug564.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq, same = T.check, T.eq, T.same
local Data = T.fixtures.load()
local Font = require("src.render.Font")
local SaveData = require("src.core.SaveData")
local Screens = require("src.ui.Screens")
local StateStack = require("src.core.StateStack")
local Theme = require("src.ui.Theme")
local function newGame()
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local save = SaveData.newGame()
save.flags = save.flags or {}
save.flags.EVENT_GOT_POKEDEX = true
save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } }
save.inventory = { FIX_POTION = 3 }
return {
data = Data, save = save, stack = stack,
input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
},
}
end
-- Draw the menu with the font stubbed out and report, in tile rows, where
-- the labels, the ▶ cursor and the "more below" arrow actually landed.
local function layout(menu)
local realDraw, realCode = Font.draw, Font.drawCode
local out = { rows = {}, cols = {} }
Font.draw = function(_, x, y)
out.rows[#out.rows + 1] = y / 8
out.cols[#out.cols + 1] = x / 8
return 8
end
Font.drawCode = function(code, x, y)
if code == Theme.cursor then
out.cursor, out.cursorCol = y / 8, x / 8
elseif code == Theme.moreArrow then
out.arrow = y / 8
end
end
local ok, err = pcall(menu.draw, menu)
Font.draw, Font.drawCode = realDraw, realCode
if not ok then error(err, 0) end
return out
end
-- Every boxed menu owes the same two things to its border, and the second
-- is the one #564/#572 were about: a menu that starts at ty+1 leaves the
-- bottom interior row empty and the text crowded under the top edge.
local function boxRules(menu, label)
local at = layout(menu)
local first, last = at.rows[1], at.rows[#at.rows]
check(first and first >= menu.ty + 1 and last <= menu.ty + menu.th - 2,
label .. ": every choice sits inside the border (rows "
.. tostring(first) .. ".." .. tostring(last) .. ", interior "
.. (menu.ty + 1) .. ".." .. (menu.ty + menu.th - 2) .. ")")
eq(last, menu.ty + menu.th - 2,
label .. ": the last choice lands on the bottom interior row")
eq(at.cursor, at.rows[menu.index - menu.scroll],
label .. ": the cursor shares its row with the highlighted choice")
eq(at.cursorCol, menu.tx + 1, label .. ": the cursor sits one tile in")
return at
end
-- ---------------------------------------------------------------- #564
-- draw_start_menu.asm: box at (10,0) 14 rows tall, first choice at row 2,
-- seven double-spaced choices, so the last is on row 14 and the blank row
-- is the one under the top border.
local game = newGame()
local start = Screens.push(game, "StartMenu")
eq(#start.items, 7, "vanilla start menu has seven rows with the dex")
local at = boxRules(start, "start menu")
same(at.rows, { 2, 4, 6, 8, 10, 12, 14 }, "start menu rows match hlcoord 12,2")
eq(at.cols[1], start.tx + 2, "start menu labels sit two tiles in")
start.index = 3
eq(layout(start).cursor, 6, "cursor follows the third row down to row 6")
-- The "more below" glyph rides the bottom border, because the bottom
-- interior row now belongs to a choice (#564).
local Menu = require("src.ui.Menu")
local many = {}
for i = 1, 10 do many[i] = { label = "ROW" .. i } end
local scrolled = Menu.new(game, many,
{ tx = 9, ty = 0, tw = 11, maxVisible = 8 }) -- StartMenu's own opts
local sat = layout(scrolled)
eq(sat.arrow, scrolled.ty + scrolled.th - 1,
"the more-below arrow is on the border row")
check(sat.arrow > sat.rows[#sat.rows],
"the arrow is clear of the last visible choice")
-- ---------------------------------------------------------------- #572
-- players_pc.asm: TextBoxBorder (0,0) b=8 c=14 -> a 16x10 box whose four
-- choices start at hlcoord 2,2.
local pc = Screens.push(newGame(), "PlayerPC")
eq(pc.th, 10, "player's PC box is 10 tiles tall")
same(boxRules(pc, "player's PC").rows, { 2, 4, 6, 8 },
"player's PC rows match hlcoord 2,2")
-- The Pokédex side menu (pokedex.asm PokedexMenuItemsText): DATA / CRY /
-- AREA / QUIT, opened by choosing a seen species off the dex list.
local dexGame = newGame()
local dexList = Screens.push(dexGame, "PokedexMenu", {})
local entry = dexList.items[1]
eq(entry.value, "FIXMON_A", "the fixture dex list opens on a seen species")
dexList.onChoose(entry, dexList)
local side = dexGame.stack:top()
check(side ~= dexList, "choosing an entry pushes the side menu")
same(boxRules(side, "Pokédex side menu").rows, { 10, 12, 14, 16 },
"side menu rows fill its box")
-- The bag's USE / TOSS box is the one menu pokered draws without a blank
-- leading row: USE_TOSS_MENU_TEMPLATE (data/text_boxes.asm) is (13,10) to
-- (19,14) with the text at 15,11, so two double-spaced choices exactly
-- fill rows 11 and 13. #284 sized the port's box to that, which is why
-- the shared "first choice at ty+2" rule pushes TOSS onto the border here.
local bagGame = newGame()
local bag = Screens.push(bagGame, "BagMenu")
bag.onChoose(bag.items[1], bag)
local useToss = bagGame.stack:top()
eq(#useToss.items, 2, "the bag submenu is USE / TOSS")
boxRules(useToss, "bag USE/TOSS")
T.finish("menu_row_offset_bug564")
+94
View File
@@ -0,0 +1,94 @@
-- QUIT on a Pokédex entry closes the whole Pokédex (#571).
-- HandlePokedexSideMenu hands ShowPokedexMenu b=1 for QUIT and b=2 for B
-- (engine/menus/pokedex.asm); only b=2 loops back to .doPokemonListMenu,
-- b=1 falls through to .exitPokedex, which drops the dex and returns to
-- whoever opened it with wBattleAndStartSavedMenuItem intact. The port
-- popped the side menu and left the list up, so QUIT looked like B.
-- ROM-free: real StateStack and real screens over tests/fixture_data.
-- luajit tests/engine/pokedex_quit_bug571.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local Screens = require("src.ui.Screens")
local StateStack = require("src.core.StateStack")
local function newGame()
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local save = SaveData.newGame()
save.flags = save.flags or {}
save.flags.EVENT_GOT_POKEDEX = true
save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } }
return {
data = Data, save = save, stack = stack,
input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
},
}
end
local function press(game, btn)
game.input.queue = { [btn] = true }
game.stack:update(1 / 60)
game.input.queue = {}
end
-- what the stack is carrying, named the way Screens tags its screens
local function stackIds(game)
local ids = {}
for i, state in ipairs(game.stack.states) do
ids[i] = tostring(state.screenId or "sideMenu")
end
return table.concat(ids, " > ")
end
local function openSideMenu(game)
local start = Screens.push(game, "StartMenu")
eq(start.items[start.index].label, "POKéDEX",
"the start menu opens with the cursor on POKéDEX")
press(game, "a")
local list = game.stack:top()
eq(list.screenId, "PokedexMenu", "POKéDEX opens the dex list")
eq(list.items[list.index].value, "FIXMON_A",
"the cursor is on a seen species, so A opens the side menu")
press(game, "a")
local side = game.stack:top()
check(side ~= list, "A on the entry pushed the DATA/CRY/AREA/QUIT menu")
return list, side
end
-- ---------------------------------------------------------------- QUIT
local game = newGame()
local list, side = openSideMenu(game)
-- the start menu popped itself when POKéDEX was chosen (Menu pops before
-- onSelect unless the row is keepOpen), so this is dex list + side menu
eq(#game.stack.states, 2, "dex list and side menu are what is on the stack")
eq(side.items[#side.items].label, "QUIT", "QUIT is the last side-menu row")
side.index = #side.items
press(game, "a")
eq(#game.stack.states, 1,
"QUIT unwound the side menu and the dex list (" .. stackIds(game) .. ")")
local top = game.stack:top()
eq(top.screenId, "StartMenu", "QUIT lands back on the start menu")
check(top ~= list, "the dex list is not what is left on the stack")
eq(top.items[top.index].label, "POKéDEX",
"the start menu cursor is still on POKéDEX (wBattleAndStartSavedMenuItem)")
-- ---------------------------------------------------------------- B
-- The control case: B is b=2, which re-shows the list rather than leaving.
-- If QUIT and B ever behave the same again, one of these two fails.
local bGame = newGame()
local bList = select(1, openSideMenu(bGame))
press(bGame, "b")
eq(#bGame.stack.states, 1, "B off the side menu keeps the dex list up")
eq(bGame.stack:top(), bList, "B returns to the dex list, not the start menu")
T.finish("pokedex_quit_bug571")
+62
View File
@@ -0,0 +1,62 @@
-- Unit coverage for the render.compose seam (D14: a public-API test names
-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
-- it). render.compose lets a mod take over window composition: a wrap that
-- returns true without calling next owns the whole window; a wrap that calls
-- next lets the engine's normal single-window composite run and decorates it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local bus = Hooks.new()
Runtime.hooks = bus
-- the shape Renderer:endFrame hands the hook
local function fakeCtx()
return {
renderer = {}, worldCanvas = {}, uiCanvas = {},
worldActive = true, zones = {}, worldZones = nil,
ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
dpiX = 1, dpiY = 1, secondScreen = {},
}
end
-- takeover: a mod drawing its own layout returns true and never calls next,
-- so the engine's vanilla composite is skipped entirely
do
local vanillaRan, gotCtx = false, nil
bus:wrap("render.compose", function(next, renderer, ctx)
gotCtx = ctx
return true
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(handled, true, "a mod returning true signals full window takeover")
T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
"the hook receives the frame ctx (metrics, canvases, secondScreen)")
bus.chains["render.compose"] = nil
end
-- decorate: a mod calling next lets the engine composite run, and the
-- engine's not-handled return (false) flows back through the chain
do
local vanillaRan = false
bus:wrap("render.compose", function(next, renderer, ctx)
return next()
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(vanillaRan, true, "calling next runs the engine composite")
T.eq(handled, false, "the engine's not-handled return flows back through next")
bus.chains["render.compose"] = nil
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
T.finish("render_compose_seam")
+197
View File
@@ -0,0 +1,197 @@
-- Soft reset: A+B+SELECT+START held together drops the game back to the
-- title from anywhere, mid-battle included (#563). _Joypad
-- (engine/joypad.asm:6) tests the raw read with `cp PAD_BUTTONS` -- an
-- equality, so a d-pad direction in the mix cancels it -- and does so ahead
-- of the wJoyIgnore / BIT_DISABLE_JOYPAD masking further down, which is why
-- the combo still works where ordinary input is being thrown away.
-- TrySoftReset then decrements hSoftReset, seeded with 16 by Init
-- (home/init.asm:81), one poll at a time. The on-screen overlay's half is
-- here too: one finger only ever claims one control.
-- luajit tests/engine/soft_reset_bug563.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 TouchControls = require("src.core.TouchControls")
local Game = require("src.core.Game")
-- pokered's 16 polls; the port counts fixed steps, which are the same 60Hz
local HOLD_STEPS = 16
-- The keys the hand-off tells a player to press. Pinned because the combo
-- is only reachable if all four GB buttons have a key, and because the
-- letter A is bound to LEFT here -- a player reaching for "A" on the
-- keyboard adds a direction and cancels the chord instead of arming it.
Input:init()
eq(Input.keyBindings["z"], "a", "Z is the A button")
eq(Input.keyBindings["x"], "b", "X is the B button")
eq(Input.keyBindings["escape"], "start", "Escape is START")
eq(Input.keyBindings["tab"], "select", "Tab is SELECT")
eq(Input.keyBindings["a"], "left", "the letter A is LEFT, not the A button")
-- ---- the chord itself --------------------------------------------------
local function holdKeys(...)
Input:init()
for _, key in ipairs({ ... }) do Input:keypressed(key) end
end
holdKeys("z", "x", "escape", "tab")
check(Input:softResetHeld(), "all four buttons down arms the combo")
for _, missing in ipairs({ "z", "x", "escape", "tab" }) do
local keys = {}
for _, key in ipairs({ "z", "x", "escape", "tab" }) do
if key ~= missing then keys[#keys + 1] = key end
end
holdKeys(unpack(keys))
check(not Input:softResetHeld(),
"three of the four is not the combo (without " .. missing .. ")")
end
-- `cp PAD_BUTTONS` is an equality: any direction held alongside the four
-- takes the raw read off PAD_BUTTONS and TrySoftReset is never reached.
for _, dir in ipairs({ "up", "down", "left", "right" }) do
holdKeys("z", "x", "escape", "tab", dir)
check(not Input:softResetHeld(), dir .. " held alongside cancels the combo")
end
-- ---- the 16-step countdown ---------------------------------------------
-- one Game:step's worth of the chord; returns whether the reset fired
local function stepChord()
Input:step()
return Input:softResetStep()
end
holdKeys("z", "x", "escape", "tab")
for i = 1, HOLD_STEPS - 1 do
check(not stepChord(), "step " .. i .. " of the hold does not reset yet")
end
check(stepChord(), "the " .. HOLD_STEPS .. "th consecutive step resets")
-- hSoftReset is never re-seeded on release in the original, so its count
-- leaks across a session; the port re-arms instead, or a session's worth of
-- stray four-button presses would eventually add up to a reset.
holdKeys("z", "x", "escape", "tab")
for _ = 1, HOLD_STEPS - 1 do stepChord() end
Input:keyreleased("tab")
check(not stepChord(), "lifting SELECT one step short cancels")
Input:keypressed("tab")
for i = 1, HOLD_STEPS - 1 do
check(not stepChord(),
"re-pressing it restarts the count from 16, not from 1 (step " .. i .. ")")
end
check(stepChord(), "and a full fresh hold resets")
-- same re-arm for the direction case: a thumb brushing the d-pad mid-hold
holdKeys("z", "x", "escape", "tab")
for _ = 1, HOLD_STEPS - 1 do stepChord() end
Input:keypressed("up")
check(not stepChord(), "a direction one step short cancels")
Input:keyreleased("up")
for _ = 1, HOLD_STEPS - 1 do
check(not stepChord(), "and the count starts over")
end
check(stepChord(), "reaching 16 again from the restart")
-- ---- Game:step routes it above the state stack --------------------------
-- Doubles for the two services Game:step touches on the reset path. The
-- real stack is not needed: what is being pinned is that the combo is read
-- before stack:update, so it fires from a battle, a menu or a cutscene and
-- not just from the overworld (#563).
local function fakeGame()
local game = { input = Input, save = {}, returned = 0 }
game.stack = {
updates = 0,
update = function(self) self.updates = self.updates + 1 end,
}
function game:returnToTitle() self.returned = self.returned + 1 end
return game
end
local game = fakeGame()
holdKeys("z", "x", "escape", "tab")
for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end
eq(game.returned, 1, "Game:step falls back to the title on the 16th step")
eq(game.stack.updates, HOLD_STEPS - 1,
"and the state on top never gets that step, whatever it was")
check(not Input:isDown("a"),
"the still-physically-held A is cleared, so the title screen does not "
.. "read it as a menu choice on its first frame")
-- A tool mod may hand Game a stand-in input; Game guards on the method
-- existing rather than assuming it, so those simply never soft reset.
local bare = fakeGame()
bare.input = { step = function() end, isDown = function() return false end }
for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end
eq(bare.returned, 0, "an input with no chord bookkeeping never resets")
-- ---- the on-screen overlay ---------------------------------------------
-- Force the overlay live the way a phone would have it. img is only ever
-- tested for truthiness on the input path (drawing is what reads it), so a
-- placeholder keeps this suite off love.graphics.newImage.
Input:init()
TouchControls:init()
TouchControls.active, TouchControls.enabled = true, true
TouchControls.img = { stub = true }
local L = TouchControls:layout()
-- One finger, one control: touchpressed returns on its first hit, so no
-- single touch can ever arm more than a quarter of the chord however the
-- layout editor has moved the controls around.
for _, btn in ipairs({ "a", "b", "start", "select" }) do
Input:init()
TouchControls:reset()
TouchControls:touchpressed(1, L[btn].cx, L[btn].cy)
local held = 0
for _ in pairs(TouchControls.held) do held = held + 1 end
eq(held, 1, "a finger on " .. btn:upper() .. " presses that button alone")
check(not Input:softResetHeld(), "...which is not the combo")
end
-- Four fingers on four controls is the only way there, and it still has to
-- survive the same 16 steps -- better than a quarter second of everything
-- staying put.
Input:init()
TouchControls:reset()
local ids = { a = 1, b = 2, start = 3, select = 4 }
for btn, id in pairs(ids) do
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
end
check(Input:softResetHeld(), "four fingers on A, B, START and SELECT arm it")
for i = 1, HOLD_STEPS - 1 do
check(not stepChord(), "the overlay holds the same countdown (step " .. i .. ")")
end
check(stepChord(), "and resets on the 16th")
-- the accidental version: one finger slips off part way through
Input:init()
TouchControls:reset()
for btn, id in pairs(ids) do
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
end
for _ = 1, HOLD_STEPS - 1 do stepChord() end
TouchControls:touchreleased(ids.select, L.select.cx, L.select.cy)
check(not stepChord(), "a finger leaving SELECT one step short cancels")
check(not Input:softResetHeld(), "and the chord is no longer armed")
-- the fifth finger a real two-handed grip has on the d-pad
Input:init()
TouchControls:reset()
for btn, id in pairs(ids) do
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
end
TouchControls:touchpressed(5, L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4)
check(not Input:softResetHeld(),
"a thumb on the d-pad cancels it the same way a keyboard direction does")
Input:init()
TouchControls:reset()
T.finish("soft_reset_bug563")
+107
View File
@@ -0,0 +1,107 @@
-- A beaten trainer's own line is spoken, so it carries their name tag.
--
-- TrainerEndBattleText (home/trainers.asm:381-386) prints _TrainerNameText
-- -- wNameBuffer then ": " (data/text/text_1.asm:16-19) -- and only then
-- the saved EndBattleText pointer, so the loss line opens
-- "BUG CATCHER: ...". The port printed the pointer alone, leaving an
-- unattributed sentence on the battle screen (#566). SaveTrainerName reads
-- TrainerNamePointers, whose RIVAL1/2/3 entries aim at wTrainerName, so the
-- rival tags with his name and never with the class id.
--
-- The tag belongs to the box, not to every page: a `para` inside the loss
-- text is a second page and prints untagged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local function freshGame()
local save = SaveData.newGame()
save.player.name = "RED"
save.player.rival = "GARY"
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
return { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
end
-- KO the whole enemy party and take the win branch of enemyMonFainted
local function beat(game, oppClass, endText)
local battle = BattleState.newTrainer(game, oppClass, 1)
battle.participants = {}
battle.playVictoryMusic = function() end
battle.endBattleText = endText
for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end
battle.enemyIndex = #battle.enemyParty
battle.enemy.mon = battle.enemyParty[battle.enemyIndex]
battle:enemyMonFainted()
local texts = {}
for _, row in ipairs(battle.queue) do
if row.text then texts[#texts + 1] = row.text end
end
return battle, texts
end
-- index of the first queued message containing `needle`
local function findText(texts, needle)
for i, text in ipairs(texts) do
if text:find(needle, 1, true) then return i end
end
end
do
local game = freshGame()
local battle, texts = beat(game, "OPP_FIX_YOUNGSTER",
"Well fixed!\fBut I will win next time.")
T.eq(battle.result, "win", "the last enemy mon fainting ends the battle")
local loss = findText(texts, "Well fixed!")
T.check(loss ~= nil, "the trainer's loss line is queued")
T.eq(texts[loss], battle.trainer.name .. ": Well fixed!",
"the loss line opens with the trainer class and a colon")
T.eq(texts[loss + 1], "But I will win next time.",
"the `para` page is a page of the same box, so it is not tagged again")
-- TrainerBattleVictory (core.asm:915-949) order: TrainerDefeatedText,
-- the pic scroll, PrintEndBattleText, then MoneyForWinningText
local defeated = findText(texts, "defeated")
local money = findText(texts, "for winning")
T.check(defeated and defeated < loss, "the tag follows 'RED defeated ...'")
T.check(money and money > loss + 1, "the prize line still comes last")
end
-- RIVAL1/2/3 tag with the rival's name, not the class id: newTrainer
-- overlays wRivalName the way GetTrainerName_ does.
do
local game = freshGame()
Data.trainers.OPP_RIVAL1 = {
id = "OPP_RIVAL1", index = 2, name = "RIVAL1", baseMoney = 15,
parties = { { { level = 5, species = "FIXMON_C" } } },
}
local battle, texts = beat(game, "OPP_RIVAL1", "You're still learning!")
T.eq(battle.trainer.name, "GARY", "the rival battles under his own name")
local loss = findText(texts, "still learning")
T.check(loss ~= nil, "the rival's loss line is queued")
T.eq(texts[loss], "GARY: You're still learning!",
"the rival tag is his name, never the RIVAL1 class id")
end
-- A scripted battle that prints its own follow-up leaves endBattleText nil;
-- nothing may invent a tagged empty box for it.
do
local game = freshGame()
local _, texts = beat(game, "OPP_FIX_YOUNGSTER", nil)
for _, text in ipairs(texts) do
T.check(not text:match("^FIX YOUNGSTER: %s*$"),
"no empty tagged box when the trainer has no loss line")
end
end
T.finish("trainer loss line bug566")
@@ -0,0 +1,80 @@
-- SHIFT's "about to use" offer is one box with a CONT, not two boxes.
--
-- _TrainerAboutToUseText (data/text/text_2.asm:911-921) is
-- "<wTrainerName> is" / line "about to use" / cont "<wEnemyMonNick>!": the
-- cont scrolls "X is" off the top so the nick arrives UNDER the words that
-- explain it. Splitting that into two say() rows put the nick alone on a
-- fresh page, which reads as a bare Pokemon name with no sentence around
-- it (#565).
--
-- The rendered half is checked through BattleState:startMessage, the same
-- parser the battle box types from, so this fails if \v ever stops meaning
-- ContText there.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
-- SHIFT only offers the switch when the player has more than one party
-- slot and the active mon is alive (core.asm:1366-1443)
local save = SaveData.newGame()
save.player.name = "RED"
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
save.options = save.options or {}
save.options.battleStyle = "shift"
local game = { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
T.check(#battle.enemyParty >= 2, "the fixture trainer has a reserve to send out")
battle.participants = {}
-- KO the lead so enemyMonFainted queues the send-out for slot 2
battle.enemyParty[1].hp = 0
battle.enemy.mon = battle.enemyParty[1]
battle:enemyMonFainted()
local texts = {}
for _, row in ipairs(battle.queue) do
if row.text then texts[#texts + 1] = row.text end
end
local offer
for _, text in ipairs(texts) do
if text:find("about to use", 1, true) then offer = text end
end
T.check(offer ~= nil, "SHIFT queues the about-to-use offer")
local nick = Data.pokemon[battle.enemyParty[2].species].name
T.eq(offer, battle.trainer.name .. " is\nabout to use\v" .. nick .. "!",
"one box: name, the line break, then a CONT to the nick")
-- the regression itself: the nick must never be a message of its own
for _, text in ipairs(texts) do
T.neq(text, nick .. "!", "no bare-nickname box follows the offer")
end
-- and what the box actually renders: three lines, the third scrolled in,
-- so the two visible rows when the nick lands are "about to use" / nick
battle:startMessage({ text = offer })
T.eq(#battle.lines, 3, "the offer types as three lines")
T.check(not battle.lines[2].cont, "line 2 follows a plain line break")
-- guarded so a regression reports the missing line instead of dying on it
if battle.lines[3] then
T.check(battle.lines[3].cont, "line 3 is a ContText scroll, not a new page")
T.same(battle.lines[2].codes, Font.encode("about to use"),
"the line above the nick still says 'about to use'")
T.same(battle.lines[3].codes, Font.encode(nick .. "!"),
"the scrolled-in line is the nick")
end
T.finish("trainer shift prompt bug565")
+156
View File
@@ -0,0 +1,156 @@
-- A tap on the d-pad turns in place; only a held direction steps (#415).
-- home/overworld.asm .handleDirectionButtonPress reaches the turn only while
-- wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed -- the poll
-- that finds nothing held -- is the one place that sets it. So the turn
-- delay is spent once per press, not at every facing change: a direction
-- swapped mid-walk keeps stepping. Player.turnArmed is that flag and
-- Player:turnWindow is how long the fresh turn holds the step off.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Collision = require("src.world.Collision")
local FieldDefaults = require("src.world.FieldDefaults")
local Input = require("src.core.Input")
local Player = require("src.world.Player")
Collision.load(Data)
-- the fixture dataset ships one player sheet under its own id; Player.new
-- builds a SpriteRenderer off whatever field.playerSprites names
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
-- Collision.canMove only ever asks a map these four things, so open floor
-- is cheaper to state directly than to stand a real MapLoader up (that
-- pulls the tileset atlas in, which is the asset pipeline, not this).
local map = {
def = { tileset = "FIX_OUT" },
inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end,
isWalkableCell = function() return true end,
isWaterCell = function() return false end,
cellTile = function() return 0 end,
}
local function newPlayer(facing)
local p = Player.new(Data, 5, 5, facing or "down")
Input:init()
local Game = require("src.core.Game")
Game.input = Input
return p
end
-- One fixed step of the overworld's input half: OverworldState:handleInput
-- polls the held direction and re-arms turnArmed when it finds none, then
-- Player:update ticks turnTimer down. `dir` nil is a released d-pad.
local function step(p, dir)
local result
if dir then
result = p:tryMove(dir, map, {})
else
p.turnArmed = true
end
p:update()
return result
end
-- Hold `dir` from a standstill until the step it starts lands, and stop on
-- the landing poll: one more held poll would chain straight into the next
-- tile, which is the walk this suite is not measuring.
local function walkOneTile(p, dir)
local fromX, fromY = p.cellX, p.cellY
for _ = 1, 120 do
step(p, dir)
if not p.moving and (p.cellX ~= fromX or p.cellY ~= fromY) then
return true
end
end
return false
end
-- ---- the tap: one poll with the direction down, then nothing -------------
do
local p = newPlayer("down")
T.eq(step(p, "up"), "turned", "the first poll of a fresh press turns")
T.eq(p.facing, "up", "and the facing lands on the tapped direction")
for _ = 1, 20 do step(p, nil) end
T.eq(p.cellY, 5, "a released d-pad never commits the step")
T.eq(p.cellX, 5, "and never drifts sideways either")
T.eq(p.facing, "up", "the turn survives the release")
end
-- ---- the hold: the same press kept down steps once the window closes -----
do
local window = FieldDefaults.world(Data, "turnFrames")
T.eq(window, 4, "the dataset's turn window is 4 fixed steps")
-- one short of the window: still turning, still on the tile
local short = newPlayer("down")
T.eq(step(short, "up"), "turned", "hold: the first poll turns")
for _ = 1, window - 1 do
T.eq(step(short, "up"), nil, "a hold inside the window takes no step")
end
T.eq(short.cellY, 5, "so the tile is unchanged after " .. window .. " polls")
-- the poll after the window closes is the one that moves
T.eq(step(short, "up"), "moved", "the poll past the window commits the step")
T.check(short.moving, "and the step animation is running")
end
-- ---- the corner: a direction swapped without ever letting go -------------
--
-- This is what turnArmed buys. Walking a corner never lifts the d-pad, so
-- .noDirectionButtonsPressed never runs and the original charges no turn
-- delay for the new facing; gating on the timer alone stalled every corner.
do
local p = newPlayer("down")
T.check(walkOneTile(p, "up"), "the held direction walks a whole tile")
T.eq(p.cellY, 4, "one tile north")
local turned = step(p, "right") -- swap without a release in between
T.eq(p.facing, "right", "the swap changes the facing")
T.eq(turned, "moved", "and steps straight away: no second turn delay")
T.eq(p.turnTimer, 0, "the corner charges no turn window")
end
-- ---- re-arming: the flag only comes back from a standstill ---------------
do
local p = newPlayer("down")
T.eq(p.turnArmed, true, "a fresh player starts armed")
step(p, "left")
T.eq(p.turnArmed, false, "the turn spends the arm")
step(p, "left")
T.eq(p.turnArmed, false, "a still-held direction does not re-arm it")
step(p, nil)
T.eq(p.turnArmed, true, "the poll that finds nothing held re-arms it")
end
-- ---- the on-screen d-pad gets the longer window -------------------------
--
-- A finger cannot produce a 4-frame press, so Player:turnWindow widens it
-- for the overlay only. Keyed on the live source (Input:isTouchDown), not
-- on whether the overlay is visible, so a phone with a pad plugged in keeps
-- the physical window.
do
local p = newPlayer("down")
p.facing = "up"
T.eq(p:turnWindow(), 4, "keyboard and pad presses get the short window")
Input:overlayPressed("up")
Input:step()
T.check(Input:isTouchDown("up"), "the overlay registers as a touch source")
T.eq(p:turnWindow(), 8, "an overlay press gets the long window")
Input:keypressed("up")
Input:overlayReleased("up")
Input:step()
T.check(Input:isDown("up"), "the keyboard still holds the direction")
T.check(not Input:isTouchDown("up"), "but the touch source is gone")
T.eq(p:turnWindow(), 4, "so the window drops back to the short one")
end
T.finish("turn in place")
+180
View File
@@ -0,0 +1,180 @@
-- BATTLE LAYOUT = WIDE, screen shakes (#562).
--
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window
-- PredefShakeScreenVertically / PredefShakeScreenHorizontally /
-- AnimationShakeScreenHorizontallySlow displace IS the whole screen: pics,
-- both HUDs and the message window move together, which is what
-- BattleState:drawClassic does by offsetting its whole BG canvas. The wide
-- composition used to apply fx.shakeX/shakeY to the two pic regions only, so
-- an applying-attack shake slid the monsters across a nailed-down HUD and
-- text box -- TAIL WHIP's slow 3px creep (wAnimationType 6) being the case
-- the report named.
--
-- Headless: WideBattle.draw is played into a recording love.graphics and the
-- Font / HudTiles entry points, so what is asserted is where each piece of
-- the composition landed, not pixels.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local WideBattle = require("src.battle.WideBattle")
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
-- ---------------------------------------------------------------- recorder
-- The stub's push/pop keep no transform, so the translate stack is tracked
-- here: `tx, ty` is the offset in force when a draw call is issued. Font and
-- HudTiles are patched in place (WideBattle captured these very tables at
-- require time, so swapping package.loaded would not reach it).
local g = love.graphics
local realPush, realPop = g.push, g.pop
local tx, ty, stack = 0, 0, {}
local scissors, marks
g.push = function(...) stack[#stack + 1] = { tx, ty }; realPush(...) end
g.pop = function()
local s = table.remove(stack)
if s then tx, ty = s[1], s[2] end
realPop()
end
g.translate = function(dx, dy) tx, ty = tx + (dx or 0), ty + (dy or 0) end
-- restoreScissor calls setScissor() with no arguments to clear; only a real
-- rect is a region being opened
g.setScissor = function(x, y, w, h)
if x then scissors[#scissors + 1] = { x = x, y = y, w = w, h = h } end
end
local function mark(name) marks[#marks + 1] = { name = name, tx = tx, ty = ty } end
Font.drawBox = function() mark("box") end
Font.draw = function() mark("text") end
Font.drawCode = function() mark("code") end
Font.split = function() return {} end
Font.spansFitting = function() return math.huge end
HudTiles.tile = function() mark("tile") end
HudTiles.drawHPBar = function() mark("hpbar") end
-- A battle stripped to what the composition reads. drawPicsLayer and
-- drawAnimLayer only record the offset they were handed, which is the whole
-- question: are the two pic regions and everything else moving as one?
local function battleWith(fx, sprites)
return {
data = {},
frame = 0,
phase = "messages",
current = {},
shown = {},
fx = fx,
enemy = { name = "GENGAR", shownHP = 60,
mon = { level = 25, stats = { hp = 60 } } },
player = { name = "NIDORINO", shownHP = 55,
mon = { level = 24, stats = { hp = 55 } } },
animPlaying = sprites ~= nil,
animPlayer = sprites and { stepIndex = 1, steps = { { sprites = sprites } } },
drawPicsLayer = function() mark("pics") end,
drawAnimLayer = function() mark("anim") end,
growInScale = function() return nil end,
drawBallRow = function() end,
statusLabel = function() return "" end,
}
end
local function draw(fx, sprites)
tx, ty, stack = 0, 0, {}
scissors, marks = {}, {}
WideBattle.draw(battleWith(fx, sprites))
return scissors, marks
end
local function marksNamed(list, name)
local out = {}
for _, m in ipairs(list) do if m.name == name then out[#out + 1] = m end end
return out
end
-- Every piece of the composition drawn in wide-surface coordinates sits at
-- (dx, dy). The two pic passes and the anim layer carry their own anchor on
-- top of the shake and are checked against it by hand.
local SELF_ANCHORED = { pics = true, anim = true }
local function everythingAt(list, dx, dy, label)
local seen = 0
for _, m in ipairs(list) do
if not SELF_ANCHORED[m.name] then
seen = seen + 1
if m.tx ~= dx or m.ty ~= dy then
T.check(false, ("%s: %s drew at (%d, %d), wanted (%d, %d)")
:format(label, m.name, m.tx, m.ty, dx, dy))
return
end
end
end
T.check(seen >= 6,
label .. ": the HUDs, the status bars and the message box all drew")
T.check(true, ("%s: all %d pieces moved to (%d, %d) together")
:format(label, seen, dx, dy))
end
-- ------------------------------------------------------------ still screen
-- The baseline the shaken frames are measured against: the player's 160x144
-- region is inset 32px from the top of the wider field, the enemy's starts at
-- x=160, and both draw their classic-space pics at the wide anchors.
local s, m = draw(nil)
T.eq(s[1].x, 0, "unshaken, the player region clips from the left edge")
T.eq(s[1].y, 32, "unshaken, the player region starts below the foe's HUD")
T.eq(s[2].x, 160, "unshaken, the enemy region clips from the halfway mark")
T.eq(s[2].y, 0, "unshaken, the enemy region starts at the top")
local pics = marksNamed(m, "pics")
T.eq(#pics, 2, "one pics pass per side")
T.eq(pics[1].tx, 20, "unshaken, the player pic sits on the player anchor")
T.eq(pics[1].ty, 8, "unshaken, the player pic sits on the player baseline")
T.eq(pics[2].tx, 136, "unshaken, the enemy pic sits on the enemy anchor")
everythingAt(m, 0, 0, "no shake")
-- -------------------------------------------------- horizontal shake (#562)
-- TAIL WHIP is wAnimationType 6, AnimationShakeScreenHorizontallySlow b=3:
-- the screen creeps out to 3px and back, twice, in silence. Before the fix
-- the two pic regions took the 3px and drawHUDs / drawTextArea did not, so
-- the foe glided sideways over a stationary HUD and message box.
s, m = draw({ shakeX = 3, shakeY = 0 })
T.eq(s[1].x, 3, "a horizontal shake carries the player region's clip with it")
T.eq(s[2].x, 163, "...and the enemy region's")
pics = marksNamed(m, "pics")
T.eq(pics[1].tx, 23, "the player pic rides the shake")
T.eq(pics[2].tx, 139, "the enemy pic rides the shake")
everythingAt(m, 3, 0, "3px horizontal shake")
-- ---------------------------------------------------- vertical shake (#562)
-- wAnimationType 1 (PredefShakeScreenVertically b=8) drops the whole window.
-- The region rects move with their contents, so a pic pushed toward
-- FIELD_BOTTOM is not sheared off its own window edge on the way down.
s, m = draw({ shakeX = 0, shakeY = 8 })
T.eq(s[1].y, 40, "a vertical shake carries the player region's clip down")
T.eq(s[1].h, WideBattle.FIELD_BOTTOM - 32,
"the player region keeps its height, so the pic is not cropped short")
T.eq(s[2].y, 8, "the enemy region's clip drops the same 8px")
pics = marksNamed(m, "pics")
T.eq(pics[1].ty, 16, "the player pic drops with it")
everythingAt(m, 0, 8, "8px vertical shake")
-- --------------------------------------------------- the animations-off path
-- With ANIMATION = OFF an extracted anim that carries a shake flag falls back
-- to fx.shake, a +-2 alternation read off battle.frame; it moves the screen
-- through the same path.
s, m = draw({ shake = 24 })
T.eq(s[1].x, 2, "the animations-off fallback shakes the regions too")
everythingAt(m, 2, 0, "animations-off fallback")
-- ------------------------------------------------------- the OAM anim layer
-- Battle animation sprites are OAM, not BG, so the window shake never moves
-- them -- drawClassic draws its anim layer outside the offset for the same
-- reason. Their own offset is WideBattle.animationOffset, and that stays put
-- while the screen under them shakes.
local sprites = { { x = 104 }, { x = 144 } }
local ax, ay = WideBattle.animationOffset(sprites)
s, m = draw({ shakeX = 3, shakeY = 0 }, sprites)
local anim = marksNamed(m, "anim")
T.eq(#anim, 1, "the anim layer drew")
T.eq(anim[1].tx, ax, "the anim layer keeps its own anchor, not the shake")
T.eq(anim[1].ty, ay, "...on both axes")
T.eq(s[#s].x, 0, "the anim region spans the field from the left edge")
T.eq(s[#s].w, WideBattle.WIDTH, "...the full wide surface")
T.finish("wide battle shake")
+143
View File
@@ -0,0 +1,143 @@
-- Yellow's two extra pic rips and the plumbing that reaches for them.
--
-- #557: LoadPlayerBackPic (pokeyellow engine/battle/core.asm:6384-6391)
-- loads OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene, and DisplayBattleMenu
-- splits the displayed thrower name on the same wBattleType. The port
-- carries that split as makeOldManDemo's name argument.
--
-- #561: TalkToPikachu's framed portrait draws the chosen PikaPicAnimScript's
-- own base frame (data/pikachu/pikachu_pic_animation.asm), not the battle
-- front pic that stood in for all of them.
--
-- Both rips are gated on manifest symbols, so tools/rom_manifest_yellow.json
-- is as much a part of these fixes as the Lua is: without the symbol the
-- extractor writes nothing, the runtime existence check falls back, and the
-- screen looks exactly like the bug report.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local FieldDefaults = require("src.world.FieldDefaults")
local Sprites = require("src.pokemon.Sprites")
local PROF_BACK = "assets/generated/battle/profoakb.png"
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
local RED_BACK = "assets/generated/battle/redb.png"
local function readFile(path)
local handle = io.open(path, "r")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
return text
end
-- ---------------------------------------------------------------------
-- #557: the back pic keys and the wBattleType split
-- ---------------------------------------------------------------------
T.eq(FieldDefaults.fieldValue(nil, "playerPics", "oakBack"), PROF_BACK,
"playerPics carries a third back pic for the PROF.OAK demo")
T.eq(FieldDefaults.fieldValue(nil, "playerPics", "demoBack"), OLDMAN_BACK,
"the old man's back pic is untouched")
-- The love stub's filesystem is a table of written files, so writing the
-- path IS "this cache was imported after the rip landed".
love.filesystem.write(PROF_BACK, "png")
T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }),
PROF_BACK, "the Pallet catch demo fights behind Oak's own back pic")
T.eq(Sprites.playerPath(nil, "back", { demo = true }), OLDMAN_BACK,
"the Route 5 catch tutorial still gets the old man")
T.eq(Sprites.playerPath(nil, "back", {}), RED_BACK,
"an ordinary battle still gets the player")
love.filesystem.remove(PROF_BACK)
T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }),
OLDMAN_BACK,
"a cache built before the rip falls back to the old man, not a missing file")
-- makeOldManDemo only fills fields when a player battler already exists, so
-- a stub with one stays clear of Pokemon.new and the fixture dataset.
local oak = { player = true }
BattleState.makeOldManDemo(oak, "PROF.OAK")
T.eq(oak.demoName, "PROF.OAK", "the Yellow demo names PROF.OAK as the thrower")
T.eq(oak.oakDemo, true, "and asks for his back pic")
local oldMan = { player = true }
BattleState.makeOldManDemo(oldMan)
T.eq(oldMan.demoName, "OLD MAN", "the unnamed demo is still the old man")
T.eq(oldMan.oakDemo, false, "and does not reach for Oak's pic")
-- ---------------------------------------------------------------------
-- the symbol tables the two rips are gated on
-- ---------------------------------------------------------------------
local extractor = readFile("src/import/RomExtractor.lua")
T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable")
extractor = extractor or ""
local yellowManifest = readFile("tools/rom_manifest_yellow.json")
T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable")
yellowManifest = yellowManifest or ""
local generator = readFile("tools/make_yellow_manifest.py")
T.check(generator ~= nil, "tools/make_yellow_manifest.py is readable")
generator = generator or ""
-- Every symbol below is real in pokeyellow.sym; what decides whether the
-- extractor can see it is whether the generator injects it. Red never
-- referenced any of them, so make_yellow_manifest's Red-name remap cannot
-- pick them up on its own -- they have to be listed in YELLOW_EXTRA_SYMBOLS.
-- One line per group rather than per label: a missing symbol list reads as
-- one fault to fix, not fifty-odd.
local function missingFrom(text, labels)
local missing = {}
for _, label in ipairs(labels) do
if not text:find('"' .. label .. '"', 1, true) then
missing[#missing + 1] = label
end
end
return missing
end
local function requireSymbols(labels, why)
local gone = missingFrom(yellowManifest, labels)
T.check(#gone == 0, why .. ": rom_manifest_yellow.json is missing "
.. (#gone == 0 and "nothing" or table.concat(gone, " ")))
gone = missingFrom(generator, labels)
T.check(#gone == 0, why .. ": make_yellow_manifest.py never asks for "
.. (#gone == 0 and "nothing" or table.concat(gone, " "))
.. " (add them to YELLOW_EXTRA_SYMBOLS)")
end
T.check(extractor:find('self.symbols["ProfOakPicBack"]', 1, true) ~= nil,
"the extractor gates Oak's back pic on the ProfOakPicBack symbol")
requireSymbols({ "ProfOakPicBack" }, "#557")
-- Read the label list out of the extractor itself rather than repeating it:
-- the contract under test is that the manifest answers whatever
-- extractField asks for, so a later edit to PIKAPIC_BASE stays covered.
local pikapicBlock = extractor:match("local PIKAPIC_BASE = {(.-)\n }")
T.check(pikapicBlock ~= nil, "RomExtractor's PIKAPIC_BASE table is readable")
local pikapic, seen = {}, {}
for label in (pikapicBlock or ""):gmatch('"([%w_]+)"') do
if not seen[label] then
seen[label] = true
pikapic[#pikapic + 1] = label
end
end
-- 28 PikaPicAnimScripts, script 26 sharing script 11's base pic
T.eq(#pikapic, 27, "PIKAPIC_BASE names 27 distinct base pics")
requireSymbols(pikapic, "#561")
-- Red and Blue have neither pic, so neither manifest may grow one: a stray
-- entry there would rip Yellow art out of the wrong ROM.
for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do
local text = readFile(path)
T.check(text ~= nil, path .. " is readable")
T.check(text == nil or text:find("ProfOakPicBack", 1, true) == nil,
path .. " has no ProfOakPicBack, matching pokered")
end
T.finish("yellow oak back pic and pikapic bases")