mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
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:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user