Spider Car Unleashed

CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
This commit is contained in:
bryanthaboi
2026-08-24 07:52:05 -04:00
parent f895293217
commit 1905261c5b
180 changed files with 19567 additions and 1556 deletions
@@ -0,0 +1,174 @@
-- Eye check on the CANCEL row at the foot of the item list (#1685).
-- PrintListMenuEntries prints ListMenuCancelText on the $ff terminator and
-- returns there (pokered home/list_menu.asm:371-372, 523-528), so the '▼'
-- at :518-522 never shares a page with CANCEL; DisplayListMenuIDLoop treats
-- that row as ExitListMenu (:105-110). No POKEPORT_SPEED: the arrow and
-- the cursor step are being judged frame by frame.
-- POKEPORT_DRIVER=tests/drivers/bag_cancel_row_bug1685_test.lua POKEPORT_IDENTITY=bug1685 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
-- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5)
-- and (13,5) and the lab's at (12,11), so (10,8) is open ground.
local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8
-- six items: page one fills all four printed rows (so the '▼' is there to
-- lose), and CANCEL lands on the page after it
local STOCK = { "POTION", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL",
"AWAKENING", "PARLYZ_HEAL" }
for _, id in ipairs(STOCK) do
check(id .. " exists as an item", game.data.items[id] ~= nil)
end
check("CANCEL has a string", Strings("CANCEL") ~= nil and Strings("CANCEL") ~= "")
check("renderer is up", game.renderer ~= nil)
game.save.player.name = "SEBAS"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.inventory = {}
game.save.bagOrder = nil
local Bag = require("src.inventory.Bag")
for i, id in ipairs(STOCK) do Bag.add(game.save, id, i) end
local function stationary()
for _ = 1, 60 do
if not (game.overworld and game.overworld.player.moving) then break end
coroutine.yield()
end
end
local function openBag(where)
stationary()
U.tap(game, "start")
U.wait(20)
local menu = game.stack:top()
if getmetatable(menu) == Menu then
local ITEM = Strings("ITEM")
for _ = 1, 20 do
if menu.items[menu.index].label == ITEM then break end
U.tap(game, "down")
U.wait(6)
end
U.tap(game, "a")
U.wait(20)
end
local bag = game.stack:top()
if getmetatable(bag) ~= ListMenu then
U.log("START -> ITEM did not reach the bag " .. where
.. ", pushing BagMenu directly")
bag = Screens.push(game, "BagMenu")
U.wait(20)
end
return getmetatable(bag) == ListMenu and bag or nil
end
local function toTop(bag)
for _ = 1, #bag.items + 2 do
if bag.index == 1 then break end
U.tap(game, "up")
U.wait(6)
end
end
U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down")
U.wait(20)
local bag = openBag("in Pallet Town")
if check("the bag opened", bag ~= nil) then
check("six items are in it", #bag.items == #STOCK + 1)
local last = bag.items[#bag.items]
check("the row after the last item is the terminator's CANCEL",
last ~= nil and last.cancel == true)
check("and it carries no item id", last ~= nil and last.value == nil)
check("labelled CANCEL", last ~= nil and last.label == Strings("CANCEL"))
toTop(bag)
check("page one shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1685_page_one.png"))
-- walk down until the cursor stops moving: it must stop ON the CANCEL
-- row, not one row short of it
local before
for _ = 1, #bag.items + 4 do
before = bag.index
U.tap(game, "down")
U.wait(6)
if bag.index == before then break end
end
check("the cursor walked all the way onto the CANCEL row",
bag.items[bag.index] ~= nil and bag.items[bag.index].cancel == true)
check("and stops there", bag.index == #bag.items)
check("with the list scrolled to its last page",
bag.scroll == #bag.items - (bag.cursorRows or bag.rows))
check("cancel row shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1685_cancel_row.png"))
-- A on CANCEL is ExitListMenu: the same exit B takes
U.tap(game, "a")
U.wait(25)
local stillUp = false
for _, s in ipairs(game.stack.states) do
if s == bag then stillUp = true end
end
check("A on CANCEL closed the item list", not stillUp)
check("and left no box or submenu over it",
getmetatable(game.stack:top()) ~= ListMenu)
check("after-cancel shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1685_after_cancel.png"))
end
for _ = 1, 10 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(12)
end
-- an empty bag is a box with CANCEL alone, which is what the cart shows;
-- the port used to print an invented "Nothing here." instead
game.save.inventory = {}
game.save.bagOrder = nil
local empty = openBag("with an empty bag")
if check("the empty bag still opens a box", empty ~= nil) then
check("holding exactly one row", #empty.items == 1)
check("and that row is CANCEL",
empty.items[1] ~= nil and empty.items[1].cancel == true)
check("empty bag shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1685_empty_bag.png"))
end
U.log("")
if ok then
U.log("the bag has been opened, walked to the bottom and cancelled for you.")
U.log("in bug1685_page_one.png the four item names fill the box and the")
U.log("down arrow sits in the lower right corner. in bug1685_cancel_row.png")
U.log("CANCEL is one row below the last item, at the same left edge as the")
U.log("names, with the cursor on it -- and that corner arrow must be GONE.")
U.log("an arrow still painted next to CANCEL is the near miss here: it is")
U.log("easy to miss in a still and it is the exact thing the terminator's")
U.log("tail jump settles.")
U.log("bug1685_empty_bag.png is an empty bag: a box with CANCEL and nothing")
U.log("else. that replaces the port's old \"Nothing here.\" line, which the")
U.log("cart never printed, so it is a deliberate change, not a regression.")
U.log("the empty bag is on screen now; A or B should both close it.")
U.log("yellow shares this list code: rerun with POKEPORT_VERSION=yellow and")
U.log("a yellow identity and the three shots should look the same.")
else
U.log("a check above failed, so nothing on screen is worth reading yet.")
end
while true do
coroutine.yield()
end
end
+2 -3
View File
@@ -160,9 +160,8 @@ return function(game)
if check("the bag opened", isBag) then
if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end,
"BICYCLE") then
U.tap(game, "a") -- USE / TOSS submenu
U.wait(20)
U.tap(game, "a") -- USE is the first row
-- the BICYCLE skips USE/TOSS (start_sub_menus.asm:340-342)
U.tap(game, "a")
U.wait(30)
end
end
@@ -0,0 +1,342 @@
-- Eye check that the BICYCLE never shows the USE/TOSS box (#1705).
-- StartMenu_Item .choseItem does `cp BICYCLE / jp z, .useOrTossItem` before
-- it loads USE_TOSS_MENU_TEMPLATE (engine/menus/start_sub_menus.asm:340-342),
-- so mount, dismount and the Cycling Road refusal all land on one A press.
-- No POKEPORT_SPEED: it scales only the logic clock, and the frame the box
-- would flash on is the whole point of this run.
-- POKEPORT_DRIVER=tests/drivers/bike_option_box_bug1705_test.lua POKEPORT_IDENTITY=bug1705 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
-- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5)
-- and (13,5) and the lab's at (12,11), so (10,8) is open ground. OVERWORLD
-- tileset, so IsBikeRidingAllowed says yes here (home/overworld.asm).
local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8
-- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,11) is a landing cell
-- out of the Route 16 gate's south door and arms BIT_ALWAYS_ON_BIKE.
local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11
-- pokered data/maps/objects/Route16Gate1F.asm: warps on x 0 and 7, the
-- guard on (4,5), so (3,6) is plain floor -- and the gate script is what
-- clears the flag again (scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`)
local GATE_MAP, GATE_X, GATE_Y = "ROUTE_16_GATE_1F", 3, 6
-- a missing item def, an unextracted refusal line and a bag that never
-- opens all look like the bug from the couch
check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil)
local cannot = game.data.text._CannotGetOffHereText
check("_CannotGetOffHereText was extracted",
type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil)
check("POTION exists as the control item", game.data.items.POTION ~= nil)
check("renderer is up", game.renderer ~= nil)
game.save.player.name = "SEBAS"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.inventory = game.save.inventory or {}
game.save.inventory.BICYCLE = 1
game.save.inventory.POTION = 3
game.save.onBike = false
game.save.forcedBike = nil
local function stationary()
for _ = 1, 90 do
game.input.state.b = true
if not (game.overworld and game.overworld.player.moving) then break end
coroutine.yield()
end
game.input.state.b = false
end
-- START -> ITEM -> the bag, by real presses. Route 16/17 roll the player
-- south whenever nothing is held, so brake into a standstill first or
-- handleInput eats the START.
local function openBag(where)
stationary()
U.tap(game, "start")
U.wait(20)
local menu = game.stack:top()
if getmetatable(menu) == Menu then
local ITEM = Strings("ITEM")
for _ = 1, 20 do
if menu.items[menu.index].label == ITEM then break end
U.tap(game, "down")
U.wait(6)
end
U.tap(game, "a")
U.wait(20)
end
local bag = game.stack:top()
if getmetatable(bag) ~= ListMenu then
-- a start menu that reordered itself would otherwise strand the run;
-- say so, then get to the moment anyway
U.log("START -> ITEM did not reach the bag on " .. where
.. ", pushing BagMenu directly")
bag = Screens.push(game, "BagMenu")
U.wait(20)
end
return getmetatable(bag) == ListMenu and bag or nil
end
-- the cursor comes back on the last-used row (#1732), so walk to the top
-- before walking down or a row above the saved one is unreachable
local function cursorTo(bag, id)
for _ = 1, #bag.items do
if bag.index == 1 then break end
U.tap(game, "up")
U.wait(5)
end
for _ = 1, #bag.items do
local row = bag.items[bag.index]
if row and row.value == id then return true end
U.tap(game, "down")
U.wait(5)
end
return bag.items[bag.index] and bag.items[bag.index].value == id
end
-- StartMenu_Item keeps the START menu up behind the bag, and that is a
-- Menu too, so only a Menu that was NOT already there counts as the
-- option box
local function menuSnapshot()
local seen = {}
for _, s in ipairs(game.stack.states) do seen[s] = true end
return seen
end
local function newMenu(before)
for _, s in ipairs(game.stack.states) do
if getmetatable(s) == Menu and not before[s] then return true end
end
return false
end
-- press A and watch every frame after it: a USE/TOSS box that appears and
-- is replaced two frames later is invisible in a still, and is the near
-- miss this whole run exists to catch
local function chooseAndWatch(frames)
local before = menuSnapshot()
U.tap(game, "a")
local flashed = newMenu(before)
for _ = 1, frames or 30 do
if newMenu(before) then flashed = true end
coroutine.yield()
end
return flashed, before
end
local function boxSays()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return table.concat(lines, " / ")
end
local function dismissBox()
for _ = 1, 8 do
if getmetatable(game.stack:top()) ~= TextBox then break end
U.tap(game, "b")
U.wait(12)
end
end
local function backToOverworld()
for _ = 1, 10 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(12)
end
end
-- the control: an ordinary item DOES open the box, so a run where nothing
-- ever appears cannot be mistaken for the fix working
U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down")
U.wait(20)
local bag = openBag("Pallet Town")
if check("the bag opened in " .. TOWN, bag ~= nil) then
if check("found the POTION row", cursorTo(bag, "POTION")) then
U.tap(game, "a")
U.wait(20)
check("a POTION still opens USE/TOSS", getmetatable(game.stack:top()) == Menu)
check("control shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1705_potion_usetoss.png"))
U.tap(game, "b")
U.wait(15)
end
end
-- mount: one press, straight to the text
if bag and check("found the BICYCLE row", cursorTo(bag, "BICYCLE")) then
local flashed, before = chooseAndWatch(4)
check("no option box in the first frames after A (mount)", not flashed)
check("early shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1705_mount_first_frames.png"))
U.wait(60)
check("still no option box while the text types", not newMenu(before))
local said = boxSays()
U.log("the box reads:", tostring(said))
check("the single press mounted the bike", game.save.onBike == true)
check("and the line is the mount text",
said ~= nil and said:find("got on", 1, true) ~= nil)
check("mount shot reached disk", U.shot(game, SHOT_DIR .. "/bug1705_mount.png"))
end
dismissBox()
backToOverworld()
-- dismount: the same one press
bag = openBag("Pallet Town (riding)")
if bag and check("found the BICYCLE row again", cursorTo(bag, "BICYCLE")) then
local flashed = chooseAndWatch(4)
check("no option box in the first frames after A (dismount)", not flashed)
U.wait(60)
local said = boxSays()
U.log("the box reads:", tostring(said))
check("the single press dismounted", game.save.onBike == false)
check("and the line is the dismount text",
said ~= nil and said:find("got off", 1, true) ~= nil)
check("dismount shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1705_dismount.png"))
end
dismissBox()
backToOverworld()
-- the Cycling Road refusal, armed the way the game arms it: by arriving on
-- the forced-bike cell (setMap runs checkForcedMovement on entry)
game.save.onBike = false
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
U.wait(25)
check("stepping onto the Route 16 cell mounts the bike", game.save.onBike == true)
check("and arms the forced-bike flag", game.save.forcedBike == true)
bag = openBag("Route 16")
if bag and check("found the BICYCLE row on the road", cursorTo(bag, "BICYCLE")) then
local flashed = chooseAndWatch(4)
check("no option box in the first frames after A (refusal)", not flashed)
U.wait(60)
local said = boxSays()
U.log("the box reads:", tostring(said))
check("the refusal printed, not the dismount",
said ~= nil and said:find("get off", 1, true) ~= nil)
check("the player is still riding", game.save.onBike == true)
local stillUp = false
for _, s in ipairs(game.stack.states) do
if s == bag then stillUp = true end
end
check("and the bag list is still on the stack under the box (#513)", stillUp)
check("refusal shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1705_forced_refusal.png"))
end
dismissBox()
backToOverworld()
-- walking into the gate releases the flag; the gate's tileset is not one of
-- IsBikeRidingAllowed's (home/overworld.asm), so the map change dismounts on
-- the way in and the release has to be shown back out on the road
U.teleport(game, GATE_MAP, GATE_X, GATE_Y, "up")
U.wait(25)
check("the gate clears the forced-bike flag",
game.save.forcedBike == nil or game.save.forcedBike == false)
-- a plain Route 16 cell: walkable, nobody on it, and NOT one of the two
-- forced-bike tiles, so nothing re-arms the flag when we land
local function plainRoadCell()
local ow = game.overworld
local fm = game.data.field.forcedMovement
local forced = {}
for _, t in ipairs((fm and fm.tiles and fm.tiles[FORCE_MAP]) or {}) do
forced[t.x .. "," .. t.y] = true
end
if not ow then return nil end
for y = 0, 35 do
for x = 0, 19 do
if not forced[x .. "," .. y] and ow.map:inBounds(x, y)
and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) then
return x, y
end
end
end
return nil
end
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y - 3, "up")
U.wait(20)
local px, py = plainRoadCell()
if px then
U.teleport(game, FORCE_MAP, px, py, "up")
U.wait(20)
end
check("back on Route 16, off the forced tiles, flag still clear",
game.save.forcedBike == nil or game.save.forcedBike == false)
-- with the flag released the bike answers again: mount, then get off, one
-- press each, and the get-off is the very thing the road refused
if not game.save.onBike then
bag = openBag("Route 16 (released)")
if bag and check("found the BICYCLE row to remount", cursorTo(bag, "BICYCLE")) then
local flashed = chooseAndWatch(4)
check("no option box on the remount", not flashed)
U.wait(60)
U.log("the box reads:", tostring(boxSays()))
check("the remount worked", game.save.onBike == true)
end
dismissBox()
backToOverworld()
end
bag = openBag("Route 16 (getting off)")
if bag and check("found the BICYCLE row again on the road",
cursorTo(bag, "BICYCLE")) then
local flashed = chooseAndWatch(4)
check("no option box on the released dismount", not flashed)
U.wait(60)
local said = boxSays()
U.log("the box reads:", tostring(said))
check("the flag really is released: this one gets off",
game.save.onBike == false)
check("and the line is the dismount text, not the refusal",
said ~= nil and said:find("got off", 1, true) ~= nil)
check("released dismount shot reached disk",
U.shot(game, SHOT_DIR .. "/bug1705_released_dismount.png"))
end
check("the BICYCLE was never consumed", (game.save.inventory.BICYCLE or 0) == 1)
U.log("")
if ok then
U.log("the bike has been used five times for you: mounted and dismounted in")
U.log("PALLET TOWN, refused on the Route 16 forced stretch, then mounted and")
U.log("taken off again after the gate released the flag. every one of those")
U.log("was a single A press on the BICYCLE row, with no USE/TOSS box.")
U.log("shots are in " .. SHOT_DIR .. ": bug1705_potion_usetoss.png is the")
U.log("control, the box a POTION still gets. bug1705_mount_first_frames.png")
U.log("is two frames after the A press on the bike -- that one must show")
U.log("the mount text or a bare map, never a small box in the lower right.")
U.log("the near miss is a box that appears and is replaced before the text")
U.log("types, which reads as a pass in every later shot.")
U.log("you are on Route 16 with the bag closed; open it and try the bike as")
U.log("often as you like. it also has no TOSS any more, since the box that")
U.log("carried it is the one that no longer opens.")
U.log("yellow shares this code path: rerun with POKEPORT_VERSION=yellow")
U.log("and a yellow identity, and every beat should read the same.")
else
U.log("a check above failed, so nothing on screen is worth reading yet.")
end
while true do
coroutine.yield()
end
end
+94
View File
@@ -0,0 +1,94 @@
-- SHADER FX preset examples on Crystal, including a stacked pair.
--
-- POKEPORT_IDENTITY=crystal-dev POKEPORT_GAME=crystal POKEPORT_TOUCH=0 \
-- POKEPORT_SHOT_DIR=/tmp/shaderfx \
-- POKEPORT_DRIVER=tests/drivers/crystal_shaderfx_shots.lua love .
--
-- Never run this under POKEPORT_SPEED: the shots are of a live frame.
local U = require("tests.drivers.util")
local ShaderFX = require("src.render.ShaderFX")
local WANT = {
{ file = "gameboy-color-dot-matrix.slangp", tag = "gbc-dot-matrix" },
{ file = "sameboy-lcd.slangp", tag = "sameboy-lcd" },
{ file = "crt-caligari.slangp", tag = "crt-caligari" },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shaderfx"
U.wait(30)
U.log(("bridge canConvert=%s"):format(tostring(ShaderFX.canConvert())))
if not ShaderFX.canConvert() then
U.log("FAIL no librashader bridge, nothing can be converted")
return
end
local list = ShaderFX.list()
U.log(("found %d presets"):format(#list))
if #list == 0 then U.log("FAIL no presets on disk") return end
local byName = {}
for _, e in ipairs(list) do byName[e.name] = e end
local ready = {}
for _, want in ipairs(WANT) do
local entry = byName[want.file]
if not entry then
U.log(("SKIP %s not in the preset set"):format(want.file))
else
local ok, err = true, nil
if not entry.converted then ok, err = ShaderFX.convert(entry) end
if ok then
ready[#ready + 1] = { entry = entry, tag = want.tag }
U.log(("PASS converted %s"):format(want.file))
else
U.log(("FAIL convert %s: %s"):format(want.file, tostring(err)))
end
end
end
if #ready == 0 then U.log("FAIL nothing converted") return end
U.wait(60)
local world = game.world
if world and world.setMap then
world:setMap("CHERRYGROVE_CITY", 21, 6, "down")
U.wait(20)
else
U.log("note: no gen2 world yet, shooting whatever is on screen")
end
ShaderFX.deactivate("main")
ShaderFX.deactivate("secondary")
U.wait(20)
U.shot(game, out .. "/00-none.png")
U.log("shot: no shader")
for i, r in ipairs(ready) do
ShaderFX.deactivate("secondary")
local ok, err = ShaderFX.activate("main", r.entry)
U.wait(30)
if ok then
U.shot(game, ("%s/%02d-%s.png"):format(out, i, r.tag))
U.log(("shot: %s"):format(r.tag))
else
U.log(("FAIL activate %s: %s"):format(r.tag, tostring(err)))
end
end
if #ready >= 2 then
local a, b = ready[1], ready[2]
local okA = ShaderFX.activate("main", a.entry)
local okB = ShaderFX.activate("secondary", b.entry)
U.wait(30)
if okA and okB then
U.shot(game, ("%s/%02d-stacked-%s-over-%s.png"):format(out, #ready + 1, a.tag, b.tag))
U.log(("shot: stacked %s + %s"):format(a.tag, b.tag))
else
U.log("FAIL could not stack two slots")
end
end
U.log("done. the pad is yours; SHADER FX rows are in OPTIONS.")
while true do coroutine.yield() end
end
+86
View File
@@ -0,0 +1,86 @@
-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm), shot
-- finely enough to catch both Ditto bounces, plus both IntroSequence exits
-- (pokecrystal engine/menus/intro_menu.asm:964-967): watched -> CrystalIntro,
-- skipped -> the title.
local U = require("tests.drivers.util")
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
local CrystalSplash = require("src.ui.gen2.CrystalSplash")
local TitleState = require("src.ui.gen2.TitleState")
local LIMIT = 900
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-splash"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or 2
U.wait(10)
local finished, skipped
local splash = CrystalSplash.new(game, {
oakSpeech = game.oakSpeechData or {},
onDone = function(s)
finished, skipped = true, s
end,
})
game.stack:clear()
game.stack:push(splash)
local st = splash.anims.structs[1]
assert(st and st.index ~= 0, "no Ditto sprite anim struct was spawned")
local minY, sawMorph, scene = 256, false, -1
while not finished and splash.frames < LIMIT do
U.wait(interval)
local offset = st.yOffset
if offset >= 128 then offset = offset - 256 end
if st.jt == 1 and offset < minY then minY = offset end
if st.jt >= 3 then sawMorph = true end
if splash.scene ~= scene then
scene = splash.scene
U.log(("scene %d at frame %d (jt=%d var1=%d)")
:format(scene, splash.frames, st.jt, st.var1))
end
if not finished then
U.shot(game, ("%s/splash-%04d-scene%d.png")
:format(out, splash.frames, splash.scene))
end
end
assert(finished, "the splash never finished (frame "
.. splash.frames .. ", scene " .. splash.scene .. ")")
assert(not skipped, "an unskipped splash reported skipped")
assert(minY <= -90, "Ditto never reached the top of its 96px bounce (min "
.. minY .. ")")
assert(sawMorph, "the transform scene never ran")
U.log(("splash done at frame %d, min bounce offset %d")
:format(splash.frames, minY))
-- Watched through: Game2 routes to the intro movie.
game:showGameFreak()
U.wait(5)
assert(getmetatable(game.stack:top()) == CrystalSplash,
"showGameFreak did not open the Crystal splash")
for _ = 1, LIMIT do
if getmetatable(game.stack:top()) == CrystalIntro then break end
U.wait(5)
end
assert(getmetatable(game.stack:top()) == CrystalIntro,
"a watched splash did not hand off to CrystalIntro (top "
.. tostring(game.stack:top()) .. ")")
U.log("watched splash handed off to the intro movie")
-- Skipped: straight to the title, no intro movie.
game:showGameFreak()
U.wait(40)
U.shot(game, out .. "/skip-before.png")
U.tap(game, "b")
for _ = 1, 60 do
if getmetatable(game.stack:top()) == TitleState then break end
assert(getmetatable(game.stack:top()) ~= CrystalIntro,
"a skipped splash still played the intro movie")
U.wait(1)
end
assert(getmetatable(game.stack:top()) == TitleState,
"a skipped splash did not land on the title (top "
.. tostring(game.stack:top()) .. ")")
U.wait(30)
U.shot(game, out .. "/skip-title.png")
U.log("PASS crystal splash shots in " .. out)
end
@@ -0,0 +1,278 @@
-- #1709: BATTLE BG on Gold, the WHITE / BLACK void around the battle screen,
-- and whether it survives a menu opened over the battle. The cart has no such
-- setting; maps/Route29.asm:432 is only where this parks the player.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_battle_bg_bug1709_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-battle-bg love .
--
-- No POKEPORT_SPEED: the shots land a fixed number of frames after a
-- transition, and a logic clock running ahead of the render moves them.
local U = require("tests.drivers.util")
local Chrome = require("src.ui.gen2.Chrome")
local Mon = require("src.battle.gen2.Mon")
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local Permissions = require("src.world.gen2.Permissions")
local Save = require("src.core.gen2.Save")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-bg"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[battlebg] ok " .. label)
else
failures = failures + 1
print("[battlebg] FAIL " .. label .. " " .. tostring(detail))
end
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function shot(path)
if not U.shot(game, path) then failures = failures + 1 end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- ---- what the eye cannot check -----------------------------------------
-- Each of these fails the same way the bug did: the void just stays white.
ok("battleBg has a default in the Gold options table",
Save.DEFAULT_OPTIONS.battleBg == "white", Save.DEFAULT_OPTIONS.battleBg)
local bgRow
for i, row in ipairs(OptionsMenu.ROWS) do
if row.label == "BATTLE BG" then bgRow = i end
end
ok("OPTION carries a BATTLE BG row", bgRow ~= nil, bgRow)
if bgRow then
ok("and it is the last row before CANCEL",
OptionsMenu.ROWS[bgRow + 1] and OptionsMenu.ROWS[bgRow + 1].cancel == true,
bgRow)
end
local battleModule = require("src.ui.gen2.BattleState")
ok("the Gold battle screen answers bgMode",
type(battleModule.bgMode) == "function", type(battleModule.bgMode))
ok("and Game2 owns the repaint, not the battle screen",
type(game.paintBattleSurround) == "function",
type(game.paintBattleSurround))
-- At the letterbox size there is no void, so nothing below would show.
if love.window and love.window.setMode then
love.window.setMode(1280, 840, { resizable = true })
U.wait(6)
end
local winW, winH = love.graphics.getDimensions()
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
ok(("the window leaves a void to look at (%dx%d, panel at %d,%d x%d)")
:format(winW, winH, ox, oy, scale), ox > 8 and oy > 8, ox .. "," .. oy)
local player = Mon.new(game.data, "CYNDAQUIL", 12)
local wild = Mon.new(game.data, "PIDGEY", 4)
ok("CYNDAQUIL builds from the extracted tables",
player ~= nil and #player.moves > 0, player and #player.moves)
ok("and so does the wild PIDGEY", wild ~= nil and #wild.moves > 0,
wild and #wild.moves)
game.save.party = { player }
game.save.inventory = { POKE_BALL = 5, POTION = 3 }
-- maps/Route29.asm:432, the teacher's WALK_LEFT_RIGHT lane, so (15,11) and
-- its neighbours are floor; a map edit that moves it falls back below.
assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed")
U.wait(8)
if not Permissions.isWalkable(world:playerCollision()) then
for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 2, 0 }, { -2, 0 } }) do
if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down")
and Permissions.isWalkable(world:playerCollision()) then
break
end
end
U.wait(8)
end
ok("the player is standing on floor, not in a wall",
Permissions.isWalkable(world:playerCollision()),
tostring(world:playerCollision()))
print(failures == 0
and "[battlebg] preflight PASS -- the shots below are worth looking at"
or ("[battlebg] preflight FAIL (%d) -- fix these before judging a pixel")
:format(failures))
-- ---- the run -----------------------------------------------------------
-- Start from WHITE whatever the gold-dev identity was left on, so the first
-- half of the run is the same every time.
game.options.battleBg = "white"
shot(out .. "/00-route29.png")
-- OPTION, walked to off the START menu the way a player gets there.
tap("start")
local startMenu = game.stack:top()
ok("START opened the menu", startMenu ~= nil and startMenu.items ~= nil,
startMenu)
if startMenu and startMenu.list then
for _ = 1, #startMenu.items do
local item = startMenu.items[startMenu.list.index]
if item and item.value == "option" then break end
tap("down", 3)
end
local landed = startMenu.items[startMenu.list.index]
ok("the cursor found OPTION", landed and landed.value == "option",
landed and landed.value)
end
tap("a", 10)
local options = game.stack:top()
ok("the OPTION screen is up", options ~= nil and options.rows ~= nil, options)
if options then
-- UP from the first row wraps onto CANCEL, so BATTLE BG is two presses
-- away however many rows the build has.
for _ = 1, #options.rows do
if options:row() and options:row().key == "battleBg" then break end
tap("up", 3)
end
local row = options:row()
ok("the cursor is on BATTLE BG", row and row.key == "battleBg",
row and row.label)
U.log("01: the OPTION screen, cursor on BATTLE BG. it should read WHITE,")
U.log("sitting under MAX FPS and above CANCEL.")
shot(out .. "/01-option-white.png")
tap("right", 6)
ok("right stored black", game.options.battleBg == "black",
game.options.battleBg)
U.log("02: same row after one press right. it should read BLACK, and no")
U.log("other row on screen should have changed.")
shot(out .. "/02-option-black.png")
end
-- Gold plays with an empty stack: the world is not a state, so "back on the
-- map" is nothing on top rather than the overworld being on top.
tap("b", 10)
for _ = 1, 20 do
if game.stack:top() == nil then break end
tap("b", 4)
end
ok("the menus closed", game.stack:top() == nil, game.stack:top())
U.log("03: Route 29 with BATTLE BG on BLACK. the overworld is not a battle,")
U.log("so there must be NO black bars here -- the surround around the map is")
U.log("whatever VOID FILL draws, exactly as it was before.")
shot(out .. "/03-route29-black-set.png")
-- ---- the battle --------------------------------------------------------
assert(world:startBattle({ wild = wild }), "startBattle failed")
local battle
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
ok("the battle screen came up after the transition", battle ~= nil, battle)
if not battle then
print(("[battlebg] FAIL no battle to shoot (%d)"):format(failures))
while true do coroutine.yield() end
end
for _ = 1, 150 do
if battle.phase == "menu" then break end
tap("a", 2)
end
ok("the battle reached the FIGHT menu", battle.phase == "menu", battle.phase)
ok("and it reports the black surround",
battle:bgMode() == "black", battle:bgMode())
U.wait(10)
U.log("04: the battle on BLACK. the void on all four sides of the GB screen")
U.log("is solid black; the battle's own field, HUD and message box stay")
U.log("white. black creeping over the panel edge -- a dark frame eating a")
U.log("row of the HUD or the box border -- is the band maths being wrong.")
shot(out .. "/04-battle-black.png")
-- The 2x2 is FIGHT, <PK><MN> / PACK, RUN. Clamp onto FIGHT first and step
-- from there: one cell off is RUN, which ends the battle instead of a menu.
local function pointAt(index)
tap("left", 3)
tap("up", 3)
if index == 2 or index == 4 then tap("right", 3) end
if index == 3 or index == 4 then tap("down", 3) end
return battle.menuIndex == index
end
ok("the cursor is on PKMN", pointAt(2), battle.menuIndex)
tap("a", 12)
ok("the party list opened over the battle", battle.phase == "submenu",
battle.phase)
U.log("05: the party list over the same battle. this is the one that used to")
U.log("go wrong: the surround must still be black. white returning the")
U.log("instant the list goes up means the repaint is on the wrong layer.")
shot(out .. "/05-party-over-black.png")
tap("b", 12)
ok("the cursor is on PACK", pointAt(3), battle.menuIndex)
tap("a", 14)
U.log("06: the PACK over the battle, same rule -- still black behind it.")
shot(out .. "/06-pack-over-black.png")
tap("b", 12)
for _ = 1, 20 do
if battle.phase == "menu" then break end
tap("b", 4)
end
-- SCREEN POS moves the panel off centre, which is the other way the bands
-- can stop lining up with where the panel actually landed.
local positions = {
{ 7, "upper", "the panel sits high, so the black above it is thin and",
"the band below is deep. both stop dead at the panel edge." },
{ 8, "top", "the panel is flush with the top, so there is no black",
"above it at all. a band up there means the lift was ignored." },
}
for _, pos in ipairs(positions) do
game.options.screenPos = pos[2]
game:applyOptions()
U.wait(8)
local px, py = Chrome.fitOrigin(love.graphics.getDimensions())
U.log(("0%d: SCREEN POS %s, panel at %d,%d."):format(
pos[1], pos[2], px, py))
U.log(pos[3])
U.log(pos[4])
shot(out .. ("/0%d-screenpos-%s.png"):format(pos[1], pos[2]))
end
game.options.screenPos = "center"
game:applyOptions()
U.wait(8)
-- The no-regression half: WHITE has to look exactly like it always did.
game.options.battleBg = "white"
U.wait(6)
ok("and back to white", battle:bgMode() == "white", battle:bgMode())
U.log("09: the same battle flipped back to WHITE. paper white all the way to")
U.log("the window edge, no seam where the panel ends -- compare it with 04.")
shot(out .. "/09-battle-white.png")
ok("back on PKMN for the white pair", pointAt(2), battle.menuIndex)
tap("a", 12)
U.log("10: the party list over the white battle, for the same comparison.")
shot(out .. "/10-party-over-white.png")
tap("b", 12)
game.options.battleBg = "black"
U.wait(6)
print(failures == 0 and "[battlebg] PASS gold_battle_bg_bug1709"
or ("[battlebg] FAIL gold_battle_bg_bug1709 (%d)"):format(failures))
U.log("the battle is still up on BLACK and the controls are yours. leaving")
U.log("the OPTION screen wrote BLACK into the gold-dev options.lua, so the")
U.log("next boot starts there; set it back from OPTION if you want white.")
while true do coroutine.yield() end
end
@@ -0,0 +1,274 @@
-- #1718 / #1749: START while the Cycling Road rolls the bike downhill, and SURF
-- refused there. Assertion driver -- every line it prints is PASS or FAIL.
-- Covers the halves tests/gen2_cycling_road_test.lua cannot: Game2's joypad
-- latch, which needs a real Input, and the A press at the water.
-- ../pokegold/maps/Route17.asm:11-16, engine/overworld/events.asm:193-231.
-- Never add POKEPORT_SPEED: which fixed step a press lands on IS the subject.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_cycling_road_bug1718_1749_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-cycling-road \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Mon = require("src.battle.gen2.Mon")
local Permissions = require("src.world.gen2.Permissions")
local Screens = require("src.ui.Screens")
-- The lane: x=9 is road, x=10 the water beside it, and Route 17's four bikers
-- stand at (4,17) (16,32) (3,53) (6,80) (../pokegold/maps/Route17.asm:147-150).
local ROAD = { map = "ROUTE_17", x = 9, y = 36 }
-- The control, a route with no ALWAYS_ON_BIKE on it: the shore south of the
-- Union Cave mouth (../pokegold/maps/Route32.asm:861 is the nearest object).
local SEA = { map = "ROUTE_32", x = 11, y = 41 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-cycling-road"
local fails = 0
local function report(ok, line)
if not ok then fails = fails + 1 end
U.log((ok and "PASS " or "FAIL ") .. line)
return ok
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL gold world did not boot; nothing below ran")
while true do coroutine.yield() end
end
local save = game.save
-- --------------------------------------------------------------- helpers
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
-- One edge then a sustained hold: re-queueing every frame would hand the
-- gate a fresh edge on the landing tick and prove nothing.
local function holdUntilMenu(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
local opened = false
for _ = 1, frames do
coroutine.yield()
if game.stack:top() then opened = true break end
end
game.input.state[button] = false
U.wait(2)
return opened
end
local function tapAndRelease(button)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
coroutine.yield()
game.input.state[button] = false
end
local function closeMenus()
for _ = 1, 40 do
if not game.stack:top() then return true end
tap("b", 4)
end
return game.stack:top() == nil
end
local function dismiss()
for _ = 1, 80 do
if not (world.textbox or world.choicebox or world:busy()) then return true end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
return not (world.textbox or world.choicebox)
end
-- Put the roll back at the top of the lane, standing, so every trial starts
-- from the same step phase and none of them drifts into a biker.
local function park(x, y, facing)
local p = world.player
p.moving = false
p.progress = 0
p.targetX, p.targetY = nil, nil
p.turnTimer = 0
p.cellX, p.cellY = x, y
p.px, p.py = x * 16, y * 16
p.facing = facing or "down"
world.stepFinished = false
world.heldDir = nil
end
-- ---- preflight: everything here fails the same silent way the bugs do ----
local okScreen = pcall(Screens.get, game, "Gen2StartMenu")
report(okScreen, "the Gen2StartMenu screen id resolves")
local badges = save.player.badges or {}
save.player.badges = badges
badges[FieldMoves.BADGE.SURF] = true
report(FieldMoves.hasBadge(save, FieldMoves.BADGE.SURF), "the FOGBADGE is on")
local swimmer = Mon.new(game.data, "LAPRAS", 30, { moves = { { id = "SURF" } } })
report(swimmer ~= nil, "the cache builds a LAPRAS")
save.party = { swimmer }
report(FieldMoves.partyMoveUser(save.party, "SURF") ~= nil,
"and it knows SURF, so CheckPartyMove has a mon to find")
report(world:setMap(ROAD.map, ROAD.x, ROAD.y, "down") ~= false,
"ROUTE_17 loads")
U.wait(10)
report(world.map.id == ROAD.map, "and the world is on it")
report(world:alwaysOnBike() == true,
"MAPCALLBACK_NEWMAP set ALWAYS_ON_BIKE")
report(world:downhill() == true, "and DOWNHILL")
report(FieldMoves.isBiking(world.playerState),
"CheckUpdatePlayerSprite put the player on the bike")
for cy = ROAD.y, ROAD.y + 12 do
local road = Permissions.isLand(world.map:cellCollision(ROAD.x, cy))
local sea = Permissions.isWater(world.map:cellCollision(ROAD.x + 1, cy))
if not (road and sea) then
report(false, ("ROUTE_17 (%d,%d) is no longer road-beside-water")
:format(ROAD.x, cy))
break
end
end
report(true, "the lane is road all the way down with water on its right")
local clear = true
for _, npc in ipairs(world.npcs or {}) do
if npc.cellX == ROAD.x and npc.cellY >= ROAD.y - 2
and npc.cellY <= ROAD.y + 14 then
clear = false
end
end
report(clear, "and no biker is parked in it")
if fails > 0 then
U.log("preflight failed -- the trials below would be measuring the setup,")
U.log("not the fix. fix the lines above first.")
while true do coroutine.yield() end
end
-- ---- #1718: the nine offsets in an 8-frame STEP_BIKE cell ---------------
-- Offset 0 is the one tick the gate opens; 1..8 only the latch can carry.
local opened = 0
for offset = 0, 8 do
park(ROAD.x, ROAD.y, "down")
U.wait(offset)
local moving = world.player.moving and "mid-step" or "standing"
local ok = holdUntilMenu("start", 24)
if ok then opened = opened + 1 end
report(ok, ("START at offset %d (%s) opened the menu"):format(offset, moving))
if offset == 4 then U.shot(game, out .. "/01-start-mid-roll.png") end
closeMenus()
end
report(opened == 9, ("all nine offsets opened the menu (%d/9)"):format(opened))
-- A press RELEASED inside the step is one the frozen mirror never sees again.
park(ROAD.x, ROAD.y, "down")
U.wait(3)
report(world.player.moving == true, "three ticks in, the roll is mid-step")
tapAndRelease("start")
U.wait(24)
report(game.stack:top() == nil,
"a START tapped and released inside the step opens nothing")
closeMenus()
-- And the dropped press must not poison the latch for the next one.
park(ROAD.x, ROAD.y, "down")
U.wait(3)
report(holdUntilMenu("start", 24),
"the next held START still opens the menu")
closeMenus()
-- ---- #1749: SURF refused on the road ------------------------------------
park(ROAD.x, ROAD.y, "down")
-- Held RIGHT into the water the step is refused every tick, so the player
-- stands facing the sea instead of rolling and an A press can land at all.
game.input.pressQueue[#game.input.pressQueue + 1] = "right"
game.input.state.right = true
U.wait(20)
report(world.player.facing == "right", "facing the water off the Cycling Road")
report(world.player.moving == false, "and held there by the water it bumps")
local ctx = world:fieldContext()
report(Permissions.isWater(ctx.facingColl), "the faced cell really is water")
report(ctx.alwaysOnBike == true,
"and the field-move context carries ALWAYS_ON_BIKE (#1749)")
U.shot(game, out .. "/02-road-facing-water.png")
-- TrySurfOW's arm is `.quit`: xor a, no script, no text
-- (engine/events/overworld.asm:513-515). Pressed through the real Input.
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(2)
game.input.state.a = false
U.wait(12)
game.input.state.right = false
report(world.textbox == nil, "A at the water put up no text box")
report(world.queuedFieldMove == nil, "and queued no field move")
report(FieldMoves.isBiking(world.playerState), "the player is still on the bike")
-- SurfFunction.TrySurf's arm is .FailSurf, which TALKS
-- (engine/events/overworld.asm:350-352, :387-390). The world has to be idle
-- first or useFieldMove answers CANT_USE_HERE off its own busy gate, which
-- is a refusal for the wrong reason and would read as a pass.
dismiss()
report(not world:busy(), "the world is idle before the PACK's SURF is asked")
local menu = world:useFieldMove("SURF", swimmer)
report(menu and menu.ok == false, "the PACK's SURF refuses on the road")
report(menu and menu.text == FieldMoves.TEXT.CANT_SURF,
"with CantSurfText, not the badge line and not silence")
U.wait(6)
report(world.textbox ~= nil, "and the refusal is on screen")
U.wait(70) -- let the line finish typing so the shot is readable
U.shot(game, out .. "/03-road-cant-surf.png")
dismiss()
-- ---- the control: plain water, ALWAYS_ON_BIKE clear ---------------------
report(world:setMap(SEA.map, SEA.x, SEA.y, "right") ~= false, "ROUTE_32 loads")
U.wait(10)
world:applyPlayerState(FieldMoves.PLAYER_NORMAL)
report(world:alwaysOnBike() == false, "ROUTE_32 has no ALWAYS_ON_BIKE")
local sea = world:fieldContext()
report(sea.alwaysOnBike == false, "so the context reports it clear")
report(Permissions.isWater(sea.facingColl), "and the player faces its water")
local allowed = world:useFieldMove("SURF", swimmer)
report(allowed and allowed.ok == true, "the PACK's SURF still works there")
world.queuedFieldMove = nil
tap("a", 10)
report(world.textbox ~= nil or world.choicebox ~= nil,
"and A at the water still offers to SURF")
U.wait(70)
U.shot(game, out .. "/04-route32-offer.png")
dismiss()
if fails == 0 then
U.log("all trials passed. shots are in " .. out)
else
U.log(fails .. " trial(s) failed -- see the FAIL lines above")
end
U.log("what a wrong fix looks like: START opening the menu on roughly one")
U.log("press in eight is the World half applied without the Game2 latch, and")
U.log("a menu from the tapped-and-released press is the latch re-pressing a")
U.log("button the player already let go of.")
world:setMap(ROAD.map, ROAD.x, ROAD.y, "down")
U.wait(10)
U.log("parked back on the Cycling Road; the controls are yours from here")
while true do
coroutine.yield()
end
end
+21 -10
View File
@@ -118,7 +118,10 @@ return function(game)
for _, row in ipairs(FieldMoves.FLYPOINTS) do
save.engineFlags[row.flag] = true
end
ok("openFlyMap opens a screen", world:openFlyMap() == true)
-- The mon that used the move: FlyFunction_InitGFX and TownMapMon both draw
-- wCurPartyMon's icon (engine/events/field_moves.asm:390).
local flyMon = (save.party and save.party[1]) or { species = "PIDGEY" }
ok("openFlyMap opens a screen", world:openFlyMap(flyMon) == true)
U.wait(4)
local picker = game.stack:top()
ok("and it is the town-map picker, not a yes/no box",
@@ -130,18 +133,26 @@ return function(game)
U.wait(4)
U.shot(game, out .. "/06-flymap-moved.png")
-- A takes the destination: the fade out lifts the player off the map first.
-- A takes the destination: FlyFromAnim hovers over the tile for 64 frames,
-- climbs off the top of the screen, and FlyToAnim drops back in after the warp.
U.tap(game, "a")
local lifted = 0
for frame = 1, 40 do
U.wait(4)
ok("FLY starts the take-off animation", world.flyAnim ~= nil)
U.shot(game, out .. "/07-fly-takeoff.png")
local risen, landing = 0, false
for _ = 1, 300 do
U.wait(1)
lifted = math.min(lifted, (world.player and world.player.spriteYOffset) or 0)
if frame == 4 then U.shot(game, out .. "/07-fly-takeoff.png") end
local fa = world.flyAnim
if fa then
risen = math.min(risen, fa.y or 0)
if fa.phase == "to" then landing = true end
elseif landing then
break
end
end
ok("FLY lifts the player under the fade", lifted < 0, lifted)
U.wait(30)
ok("and lands them back on the tile",
(world.player.spriteYOffset or 0) == 0)
ok("the mon climbs off the map", risen < 0, risen)
ok("and FlyToAnim brings it back down", landing)
ok("with the player standing again", world.flyAnim == nil)
U.shot(game, out .. "/08-fly-landed.png")
-- --------------------------------------------------- tilt and the void
@@ -0,0 +1,369 @@
-- #1708: Gold's fishing pose and rod. The cast is reached the way a player
-- reaches it -- walk to the shore, START, PACK, KEY ITEMS, OLD ROD, USE -- once
-- per facing, and the pose row and rod tile are sampled out of the real draw.
-- FacingFishDown/Up/Left/Right ../pokegold/data/sprites/facings.asm:122-152;
-- LoadFishingGFX ../pokegold/engine/events/fishing_gfx.asm:1-24.
-- No POKEPORT_SPEED: it scales the logic clock only, so the sampled frames and
-- the rendered ones drift apart and the rod is read off the wrong frame.
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_fishing_rod_bug1708_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-fishing \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local U = require("tests.drivers.util")
local Assets = require("src.render.Assets")
local Map = require("src.world.gen2.Map")
local PackMenu = require("src.ui.gen2.PackMenu")
local Permissions = require("src.world.gen2.Permissions")
local SpriteRenderer = require("src.render.SpriteRenderer")
local StartMenu = require("src.ui.gen2.StartMenu")
local ROD_ITEM = "OLD_ROD"
local FACINGS = { "down", "up", "left", "right" }
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- The loose rod OAM, `db y, x, attr, tile`, off the sprite's top-left
-- (data/sprites/facings.asm:122-152). Read here a second time so a typo in the
-- engine's copy of the table is a mismatch rather than a shared mistake.
local ROD_OAM = {
down = { dx = 0, dy = 16, tile = 0xfc, flip = false },
up = { dx = 0, dy = -8, tile = 0xfc, flip = false },
left = { dx = -8, dy = 5, tile = 0xfd, flip = true },
right = { dx = 16, dy = 5, tile = 0xfd, flip = false },
}
-- LoadFishingGFX's three destinations, vTiles $02 / $06 / $0a, which are the
-- bottom tile rows of the down / up / left standing frames; right is left
-- x-flipped (fishing_gfx.asm:13-18, facings.asm:143-152).
local POSE_ROW = { down = 0, up = 1, left = 2, right = 2 }
local POSE_FLIP = { down = false, up = false, left = false, right = true }
-- Four rows of two tiles: the three pose rows then $fc/$fd.
local SHEET_W, SHEET_H, ROD_ROW_Y = 16, 32, 24
-- Ponds with a shore on all four sides. Route 28's has no object events at
-- all (../pokegold/maps/Route28.asm:29), so nothing walks into a shot and no
-- trainer's line of sight crosses one; the other two are the fallbacks if its
-- blocks are ever edited (../pokegold/maps/Route28.blk).
local MAPS = { "ROUTE_28", "VIRIDIAN_CITY", "ROUTE_43" }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-fishing"
local failures = 0
local function check(label, condition, detail)
if condition then
U.log("PASS", label)
else
failures = failures + 1
U.log("FAIL", label, detail ~= nil and tostring(detail) or "")
end
return condition and true or false
end
local function tap(btn, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = btn
game.input.state[btn] = true
U.wait(2)
game.input.state[btn] = false
U.wait(frames or 6)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save = game.save
-- ---- the halves of the drawing, before anything is on screen -------------
-- A cache with no sheet, a sheet of the wrong size and a draw branch that
-- never fires all look identical on screen: a plain standing player.
local sheet = world.fishingSheet
if not check("the cache carries LoadFishingGFX's sheet (world.fishingSheet)",
type(sheet) == "string", sheet) then
U.log("no emotes/fishing.png in this cache, so nothing below can draw.")
U.log("re-import the cart first:")
U.log(" POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_IMPORT_ONLY=1 \\")
U.log(" POKEPORT_IMPORT_ROM=/path/to/gold.gbc love .")
while true do coroutine.yield() end
end
local okImg, image = pcall(Assets.image, sheet)
check("and it loads: " .. tostring(sheet), okImg and image ~= nil)
if okImg and image then
local w, h = image:getDimensions()
check(("it is %dx%d, the four rows the quads are written against")
:format(SHEET_W, SHEET_H),
w == SHEET_W and h == SHEET_H, ("%dx%d"):format(w, h))
if h ~= SHEET_H then
U.log("a taller sheet moves the rod row, and both draws then sample")
U.log("the wrong pixels -- Gen 1 shipped exactly that as #321.")
end
end
local Player = require("src.world.gen2.Player")
check("Player:drawFishing exists (the branch that reads player.fishing)",
type(Player.drawFishing) == "function")
check("SpriteRenderer:drawTile exists (it bakes the OBJ palette; a raw "
.. "love.graphics.draw would leave the rod in DMG greys)",
type(SpriteRenderer.drawTile) == "function")
local rodDef = game.data and game.data.items and game.data.items[ROD_ITEM]
check("OLD ROD is a real item in this cache", rodDef ~= nil)
check("and it lives in the KEY ITEMS pocket",
rodDef ~= nil and rodDef.pocket == "KEY_ITEM",
rodDef and rodDef.pocket)
-- ---- pick the pond ------------------------------------------------------
-- Shores are re-derived from the generated blocks every run, so a re-extract
-- that shifts the water degrades to another cell instead of parking the
-- player at a wall.
local function shores(mapId)
local def = world.maps and world.maps[mapId]
local tileset = def and world.tilesets and world.tilesets[def.tileset]
if not (def and tileset) then return nil end
local probe = Map.new(def, tileset)
local taken = {}
for _, obj in ipairs(def.objects or {}) do
taken[obj.x .. "," .. obj.y] = true
end
local function land(cx, cy)
if not probe:inBounds(cx, cy) then return false end
if taken[cx .. "," .. cy] then return false end
local coll = probe:cellCollision(cx, cy)
return Permissions.isWalkable(coll) and not Permissions.isWater(coll)
end
local found = {}
for _, facing in ipairs(FACINGS) do
local d = DELTA[facing]
for cy = 0, probe.heightCells - 1 do
for cx = 0, probe.widthCells - 1 do
if not found[facing] and land(cx, cy)
and probe:inBounds(cx + d[1], cy + d[2])
and Permissions.isWater(probe:cellCollision(cx + d[1], cy + d[2]))
and land(cx - d[1], cy - d[2]) then
found[facing] = { x = cx, y = cy }
end
end
end
if not found[facing] then return nil end
end
return found
end
local MAP, SHORE
for _, id in ipairs(MAPS) do
SHORE = shores(id)
if SHORE then MAP = id break end
end
if not check("a pond with all four shores and a step of room behind each",
MAP ~= nil, table.concat(MAPS, ", ")) then
while true do coroutine.yield() end
end
U.log("fishing on " .. MAP .. ", from " ..
("down (%d,%d), up (%d,%d), left (%d,%d), right (%d,%d)"):format(
SHORE.down.x, SHORE.down.y, SHORE.up.x, SHORE.up.y,
SHORE.left.x, SHORE.left.y, SHORE.right.x, SHORE.right.y))
-- ---- the bag, and a pond that never bites -------------------------------
-- FISHGROUP_NONE is .FishNoFish: the cast, the pose and the rod are the same
-- as a bite's, and every run ends on "Not even a nibble!" back on the map
-- rather than in a battle nobody asked for. Put back before the hand-off.
save.inventory = { [ROD_ITEM] = 1 }
save.bagOrder = { ROD_ITEM }
local mapDef = world.maps[MAP]
local realGroup = mapDef.fishGroup
mapDef.fishGroup = "FISHGROUP_NONE"
-- ---- what the sprite actually drew --------------------------------------
local watch = nil
local realDrawTile = SpriteRenderer.drawTile
SpriteRenderer.drawTile = function(self, path, x, y, flip, quad)
if watch and path == sheet then
local qx, qy, qw, qh = 0, 0, 0, 0
if quad and quad.getViewport then qx, qy, qw, qh = quad:getViewport() end
watch[#watch + 1] = { x = x, y = y, flip = flip and true or false,
qx = qx, qy = qy, qw = qw, qh = qh }
end
return realDrawTile(self, path, x, y, flip, quad)
end
local function lastOfSize(calls, w, h)
local hit
for _, c in ipairs(calls) do
if c.qw == w and c.qh == h then hit = c end
end
return hit
end
-- ---- walk to a shore ----------------------------------------------------
local function goToShore(facing)
local cell, d = SHORE[facing], DELTA[facing]
world:setMap(MAP, cell.x - d[1], cell.y - d[2], facing)
U.wait(24)
U.hold(game, facing, 26)
U.wait(10)
local p = world.player
if p.cellX ~= cell.x or p.cellY ~= cell.y then
world:setMap(MAP, cell.x, cell.y, facing)
U.wait(24)
p = world.player
end
local fx, fy = p.cellX + d[1], p.cellY + d[2]
check(("%s: standing at (%d,%d) with water in front")
:format(facing, p.cellX, p.cellY),
p.facing == facing and world.map:inBounds(fx, fy)
and Permissions.isWater(world.map:cellCollision(fx, fy)),
("facing %s"):format(tostring(p.facing)))
end
-- ---- START, PACK, KEY ITEMS, OLD ROD, USE --------------------------------
local function castRod(facing)
tap("start")
local menu = game.stack:top()
if not check(("%s: START opened the menu"):format(facing),
getmetatable(menu) == StartMenu) then
return false
end
for _ = 1, #menu.items do
local row = menu.list and menu.list:current()
if row and row.value == "pack" then break end
tap("down", 4)
end
tap("a", 10)
local pack = game.stack:top()
if not check(("%s: and the PACK is open"):format(facing),
getmetatable(pack) == PackMenu) then
return false
end
for _ = 1, 4 do
if pack:pocket().id == "KEY_ITEM" then break end
tap("right", 6)
end
check(("%s: on the KEY ITEMS pocket"):format(facing),
pack:pocket().id == "KEY_ITEM", pack:pocket().id)
for _ = 1, 20 do
local row = pack.rows[pack.index]
if row and row.id == ROD_ITEM then break end
tap("down", 4)
end
local row = pack.rows[pack.index]
if not check(("%s: cursor on the OLD ROD"):format(facing),
row ~= nil and row.id == ROD_ITEM, row and row.id) then
return false
end
tap("a", 8)
local sub = pack.submenu
check(("%s: the item submenu opened on USE"):format(facing),
sub ~= nil and sub.rows[sub.index] == "use",
sub and sub.rows[sub.index])
tap("a", 10)
return true
end
-- ---- one facing, end to end ---------------------------------------------
local function castAndSample(facing, index)
goToShore(facing)
if not castRod(facing) then return end
for _ = 1, 60 do
if world.fishing then break end
U.wait(1)
end
check(("%s: the rod is out (world.fishing)"):format(facing),
world.fishing ~= nil)
check(("%s: the pose is up (player.fishing)"):format(facing),
world.player.fishing == true)
check(("%s: and it drew from the fishing sheet"):format(facing),
world.player.fishSheet == sheet, world.player.fishSheet)
check(("%s: the PACK closed, so the cast is on screen"):format(facing),
getmetatable(game.stack:top()) ~= PackMenu)
watch = {}
U.wait(6)
local calls = watch
watch = nil
local pose = lastOfSize(calls, 16, 8)
local rod = lastOfSize(calls, 8, 8)
check(("%s: a 16x8 pose row was drawn"):format(facing), pose ~= nil)
check(("%s: and the 8x8 rod tile beside it"):format(facing), rod ~= nil)
if pose and rod then
check(("%s: the pose row is sheet row %d")
:format(facing, POSE_ROW[facing]),
pose.qx == 0 and pose.qy == POSE_ROW[facing] * 8,
("%d,%d"):format(pose.qx, pose.qy))
check(("%s: the pose is%s x-flipped")
:format(facing, POSE_FLIP[facing] and "" or " not"),
pose.flip == POSE_FLIP[facing])
local oam = ROD_OAM[facing]
check(("%s: the rod tile is $%02x on the sheet's rod row")
:format(facing, oam.tile),
rod.qy == ROD_ROW_Y and rod.qx == (oam.tile - 0xfc) * 8,
("%d,%d"):format(rod.qx, rod.qy))
check(("%s: the rod tile is%s x-flipped")
:format(facing, oam.flip and "" or " not"),
rod.flip == oam.flip)
-- The pose row sits at the sprite's bottom tile row, so the OAM offsets
-- are read back relative to it.
local drop = math.max(0, (world.player.sprite.frameHeight or 16) - 8)
check(("%s: the rod sits at OAM offset %d,%d from the sprite")
:format(facing, oam.dx, oam.dy),
rod.x - pose.x == oam.dx and rod.y - pose.y == oam.dy - drop,
("%d,%d"):format(rod.x - pose.x, rod.y - pose.y + drop))
end
U.shot(game, ("%s/%02d-fishing-%s.png"):format(out, index, facing))
-- PutTheRodAway: the pose comes down with the verdict box, not later.
local down = nil
for frame = 1, 300 do
if world.textbox then tap("a", 2) else U.wait(1) end
if not down and world.player.fishing == nil then down = frame end
if down and not world.textbox then break end
end
check(("%s: the pose came down with the box"):format(facing), down ~= nil)
watch = {}
U.wait(8)
local after = #watch
watch = nil
check(("%s: and nothing draws the rod afterwards"):format(facing),
after == 0, after)
end
for i, facing in ipairs(FACINGS) do
castAndSample(facing, i)
end
SpriteRenderer.drawTile = realDrawTile
mapDef.fishGroup = realGroup
-- ---- hand off on a live cast --------------------------------------------
goToShore("right")
castRod("right")
U.wait(20)
U.log(("%d shot(s) in %s, one per facing (#1708)."):format(#FACINGS, out))
U.log("in each one the player's lower half is the two-handed fishing pose,")
U.log("not the standing legs, and one thin dark rod line runs from the hands")
U.log("into the water: straight down below the feet facing down, straight up")
U.log("above the head facing up, and a short stroke starting a tile clear of")
U.log("the sprite and sloping away from it facing left or right.")
U.log("the near miss to look for is the pose swapping in with no rod, or the")
U.log("rod as a garbage block, which is the sheet and the quads disagreeing;")
U.log("the other is a rod in flat DMG greys while the player is in colour.")
U.log("the pond bit on nothing for the four shots; the group is back now, so")
U.log("this last cast can bite. the controls are yours.")
if failures > 0 then
U.log(("%d check(s) failed above -- read those before looking at the shots")
:format(failures))
end
while true do
coroutine.yield()
end
end
@@ -0,0 +1,305 @@
-- #1711: FLY is an animation, not a warp. .FlyScript hides the map's objects
-- and runs FlyFromAnim before WarpToSpawnPoint and FlyToAnim after the
-- newloadmap (../pokegold/engine/events/overworld.asm:595-609, and the two
-- animations at engine/events/field_moves.asm:300 and :334). Flown the way a
-- player flies: START > POKeMON > FLY out of New Bark Town.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_fly_anim_bug1711_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-fly \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Do not add POKEPORT_SPEED. It scales the logic clock only, and the beat
-- being judged here is SFX_FLY against the wingbeat, which is audio against
-- logic -- fast-forward pulls those two apart.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Mon = require("src.battle.gen2.Mon")
local FLY_SPECIES = "PIDGEOTTO"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-fly"
local fails = 0
local function ok(cond, msg)
if cond then
print("[fly] ok " .. msg)
else
fails = fails + 1
print("[fly] FAIL " .. msg)
end
return cond
end
local function tap(btn) U.tap(game, btn) U.wait(3) end
local function top() return game.stack:top() end
U.wait(45)
local world = game.world
local save, data = game.save, game.data
if not ok(world and world.map, "gold booted into the overworld") then
error("gold fly: no world")
end
local opts = save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("SFX volume is 0, so this run is silent whether the fix works or")
U.log("not. turn it up in OPTIONS and run again before judging the sound.")
end
ok(sfxVol > 0, ("SFX volume is %d"):format(sfxVol))
-- The wingbeat itself: no Sfx_Fly in the cache and the whole flight is
-- silent, which sounds exactly like the bug.
local audio = data and data.audio
local flySfx = world:sfxIdNamed("Sfx_Fly", 0x18)
local flySfxName = audio and audio.sfxOrder and audio.sfxOrder[flySfx + 1]
ok(flySfxName == "Sfx_Fly" and audio.sfx and audio.sfx[flySfxName] ~= nil,
("the cache has Sfx_Fly at id %d"):format(flySfx))
-- FlyFunction .TryFly is CheckBadge STORMBADGE then CheckOutdoorMap
-- (../pokegold/engine/events/overworld.asm:544-551).
save.player = save.player or {}
if type(save.player.badges) ~= "table" then save.player.badges = {} end
save.player.badges.STORM = true
save.engineFlags = save.engineFlags or {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
save.engineFlags[row.flag] = true
end
-- Mon.learnMove refuses a fifth move (src/battle/gen2/Mon.lua:650), and a
-- level-24 PIDGEOTTO already knows four.
local flyer = Mon.new(data, FLY_SPECIES, 24)
table.remove(flyer.moves, 1)
Mon.learnMove(flyer, "FLY", data)
save.party = { flyer }
local knowsFly = false
for _, entry in ipairs(flyer.moves or {}) do
if entry.id == "FLY" then knowsFly = true end
end
ok(knowsFly, FLY_SPECIES .. " knows FLY")
-- FlyFunction_InitGFX's GetSpeciesIcon: no icon row for the species and
-- World:startFlyAnim refuses, which is the old instant warp back again.
local icons = data and data.gen2Icons
local iconId = icons and icons.species and icons.species[FLY_SPECIES]
local entry = iconId and icons.icons and icons.icons[iconId]
ok(entry and entry.image ~= nil,
("%s flies on %s (%s)"):format(FLY_SPECIES, tostring(iconId),
tostring(entry and entry.image)))
-- New Bark Town: a TOWN, so CheckOutdoorMap passes, and it keeps two objects
-- in view for HideSprites to take away -- the teacher at (6,8) and the
-- fisher at (12,9), ../pokecrystal/maps/NewBarkTown.asm:301-304. Scene 1 is
-- SCENE_NEWBARKTOWN_NOOP, so the teacher's coord event stays out of the way.
world.mapScenes = world.mapScenes or {}
world.mapScenes.NEW_BARK_TOWN = 1
world:setMap("NEW_BARK_TOWN", 9, 8, "down")
U.wait(15)
local p = world.player
local function free(cx, cy)
return world.map:isWalkable(cx, cy) and not world:npcAt(cx, cy)
end
local DIRS = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 },
right = { 1, 0 } }
if not free(p.cellX, p.cellY) then
for _, d in pairs(DIRS) do
if free(9 + d[1], 8 + d[2]) then
world:setMap("NEW_BARK_TOWN", 9 + d[1], 8 + d[2], "down")
U.wait(10)
break
end
end
end
ok(world.map.id == "NEW_BARK_TOWN" and free(p.cellX, p.cellY),
("standing free on New Bark Town (%d,%d)"):format(p.cellX, p.cellY))
ok((world.map.def and world.map.def.environment) == "TOWN",
"which is a TOWN, so FLY is allowed here")
ok(#world.npcs > 0,
("with %d object(s) on the map to hide"):format(#world.npcs))
-- One real step through the pad, on a free neighbour.
local fromX, fromY = p.cellX, p.cellY
for name, d in pairs(DIRS) do
if free(fromX + d[1], fromY + d[2]) then
U.hold(game, name, 24)
break
end
end
U.wait(8)
ok(p.cellX ~= fromX or p.cellY ~= fromY,
("the pad walked the player from (%d,%d) to (%d,%d)")
:format(fromX, fromY, p.cellX, p.cellY))
local takeoffX, takeoffY = p.cellX, p.cellY
-- Count the wingbeats, and only the ones the animation itself asks for.
local sfxFrom, sfxTo = 0, 0
local realPlaySfx = world.playSfx
world.playSfx = function(self, id)
local fa = self.flyAnim
if fa and id == flySfx then
if fa.phase == "to" then sfxTo = sfxTo + 1 else sfxFrom = sfxFrom + 1 end
end
return realPlaySfx(self, id)
end
-- START > POKeMON. CheckMenuOW only runs while nothing else owns the frame
-- (../pokegold/engine/overworld/events.asm), so wait the map's own settling
-- out rather than pressing into it.
for _ = 1, 240 do
if not world:busy() then break end
U.wait(2)
end
local menu
for _ = 1, 10 do
tap("start")
menu = top()
if menu and menu.screenId == "Gen2StartMenu" then break end
U.wait(6)
end
if not ok(menu and menu.screenId == "Gen2StartMenu",
"START opened the menu (top is "
.. tostring(menu and (menu.screenId or "?")) .. ")") then
error("gold fly: no start menu")
end
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
ok(menu.list:current().value == "pokemon", "the cursor found POKeMON")
tap("a")
local party = top()
if not ok(party and party.screenId == "Gen2PartyMenu",
"POKeMON opened the party list") then
error("gold fly: no party list")
end
tap("a")
local sub = party.submenu
if not ok(sub ~= nil, "and A opened the action submenu") then
error("gold fly: no submenu")
end
local flyRow
for index, item in ipairs(sub.items) do
if item.id == "FLY" then flyRow = index end
end
if not ok(flyRow ~= nil, "which lists FLY") then
error("gold fly: the submenu has no FLY row")
end
for _ = 1, #sub.items do
if sub.index == flyRow then break end
tap("down")
end
tap("a")
U.wait(10)
local picker = top()
if not ok(picker and picker.screenId == "Gen2Pokegear" and picker.fly,
"FLY opened the destination map") then
error("gold fly: no picker")
end
-- _FlyMap's .HandleDPad walks the visited rows; up is the next one along,
-- so one press leaves New Bark for the town after it.
tap("up")
U.wait(6)
local destination = picker:flyRow()
-- Landmark names carry TownMap_ConvertLineBreakCharacters' own <LF>, which
-- would break a log line in half.
ok(destination ~= nil, ("picked %s (%s)"):format(
(tostring(destination and destination.name):gsub("%s+", " ")),
tostring(destination and destination.spawn)))
-- A commits, and .FlyScript starts on this map, not the far one.
U.tap(game, "a")
U.wait(3)
if not ok(world.flyAnim ~= nil and world.flyAnim.phase == "from",
"A started FlyFromAnim over the map being left") then
error("gold fly: the flight collapsed into a warp")
end
ok(world:busy(), "and the world is frozen under it, as the callasm is")
local hoverFrames, minY, maxSwing = 0, 0, 0
local toAmpFirst, toAmpLast, sawTo = nil, nil, false
local shots = { hover = false, climb = false, drop = false }
for _ = 1, 900 do
local fa = world.flyAnim
if fa and fa.phase == "from" then
if (fa.hover or 0) > 0 then hoverFrames = hoverFrames + 1 end
minY = math.min(minY, fa.y or 0)
maxSwing = math.max(maxSwing, math.abs(fa.xoff or 0))
if not shots.hover and hoverFrames >= 20 then
shots.hover = true
U.shot(game, out .. "/01-fly-hover.png")
elseif not shots.climb and (fa.y or 0) <= -40 then
shots.climb = true
U.shot(game, out .. "/02-fly-climb.png")
end
elseif fa then
sawTo = true
toAmpFirst = toAmpFirst or fa.amp
toAmpLast = fa.amp
-- Mid-drop, not the first frame of it: the swing opens at 11 * 8 px, so
-- the icon starts the descent most of a screen off to one side.
if not shots.drop and (fa.y or 0) >= -44 and (fa.y or 0) <= -16 then
shots.drop = true
U.shot(game, out .. "/03-fly-drop.png")
end
elseif sawTo then
break
end
U.wait(1)
end
-- FlyFromAnim: 0x40 frames on the tile, then 2px a frame off the top with
-- Sprites_Cosine widening the swing (engine/events/field_moves.asm:300-333).
ok(hoverFrames >= 48,
("the icon held the take-off tile for %d frames"):format(hoverFrames))
ok(minY <= -84, ("and climbed %d px off it"):format(-minY))
ok(maxSwing >= 32, ("swinging up to %d px wide"):format(maxSwing))
-- Nine beats going up (left 128 down to 64, every eighth), one coming down.
ok(sfxFrom == 9, ("SFX_FLY flapped %d times on the way up"):format(sfxFrom))
ok(sfxTo == 1, ("and %d time on the way down"):format(sfxTo))
ok(sawTo, "FlyToAnim ran on the far side of the warp")
ok(toAmpFirst and toAmpLast and toAmpLast < toAmpFirst,
("with its swing damping from %s to %s"):format(tostring(toAmpFirst),
tostring(toAmpLast)))
ok(shots.hover and shots.climb and shots.drop,
"hover, climb and drop are all shot")
U.wait(30)
ok(world.flyAnim == nil, "the animation is over")
ok(world.map.id ~= "NEW_BARK_TOWN",
("and the player landed on %s at (%d,%d)"):format(world.map.id,
p.cellX, p.cellY))
ok(not world:busy(), "with the controls handed back")
U.shot(game, out .. "/04-fly-landed.png")
ok(world.player.spriteYOffset == 0 or world.player.spriteYOffset == nil,
"standing flat on the tile, not still lifted")
U.log(("flew %s out of New Bark Town from (%d,%d) for you (#1711). the"):
format(FLY_SPECIES, takeoffX, takeoffY))
U.log("player's own sprite and every other object on the map should vanish")
U.log("the moment A is pressed, replaced by the mon's 16x16 party icon on")
U.log("that same tile, wings beating four times a second under a chirp of")
U.log("SFX_FLY, for about a second. only then does it climb off the top of")
U.log("the screen in a widening swing, and only then does the screen fade.")
U.log("on the far side the icon falls back in from above, its swing damping")
U.log("out, one more chirp, and the player pops in standing.")
U.log("shots are in " .. out .. ".")
U.log("the player still visible on the tile while the icon flies is the near")
U.log("miss for the hide; an icon sitting dead still for two seconds and")
U.log("then jumping is the hover without the climb.")
if fails > 0 then
U.log(("%d assertion(s) failed above -- read those before the pictures.")
:format(fails))
end
U.log("the party still holds a flyer and the badge, so FLY again from the")
U.log("menu whenever you want another look. the controls are yours.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,234 @@
-- #1712: the FLY map's cursor is the flying mon's party icon, not the MAP
-- card's arrow. FlyMap's .MapHud calls TownMapMon, which reads wCurPartyMon
-- and spawns SPRITE_ANIM_OBJ_PARTY_MON on that species' icon
-- (../pokecrystal/engine/pokegear/pokegear.asm:2326, :2708-2721). Reached the
-- way a player reaches it: START > POKeMON > FLY, standing in New Bark Town.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_flymap_cursor_bug1712_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-flymap \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Do not add POKEPORT_SPEED: the icon's two-frame beat is half of what is
-- being judged here, and the logic clock is the only thing speed scales.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Mon = require("src.battle.gen2.Mon")
local FLY_SPECIES = "PIDGEOTTO"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-flymap"
local fails = 0
local function ok(cond, msg)
if cond then
print("[flymap] ok " .. msg)
else
fails = fails + 1
print("[flymap] FAIL " .. msg)
end
return cond
end
local function tap(btn) U.tap(game, btn) U.wait(3) end
local function top() return game.stack:top() end
U.wait(45)
local world = game.world
local save, data = game.save, game.data
if not ok(world and world.map, "gold booted into the overworld") then
error("gold flymap: no world")
end
-- FlyFunction .TryFly is CheckBadge STORMBADGE then CheckOutdoorMap
-- (../pokegold/engine/events/overworld.asm:544-551).
save.player = save.player or {}
if type(save.player.badges) ~= "table" then save.player.badges = {} end
save.player.badges.STORM = true
save.engineFlags = save.engineFlags or {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
save.engineFlags[row.flag] = true
end
-- Mon.learnMove refuses a fifth move (src/battle/gen2/Mon.lua:650), and a
-- level-24 PIDGEOTTO already knows four.
local flyer = Mon.new(data, FLY_SPECIES, 24)
table.remove(flyer.moves, 1)
Mon.learnMove(flyer, "FLY", data)
save.party = { flyer }
local knowsFly = false
for _, entry in ipairs(flyer.moves or {}) do
if entry.id == "FLY" then knowsFly = true end
end
ok(knowsFly, FLY_SPECIES .. " knows FLY")
-- The asset side, which fails exactly as silently as the bug: no icon row
-- for the species and drawFlyMonCursor gives the arrow back.
local icons = data and data.gen2Icons
local iconId = icons and icons.species and icons.species[FLY_SPECIES]
local entry = iconId and icons.icons and icons.icons[iconId]
ok(iconId ~= nil, "the cache maps " .. FLY_SPECIES .. " to an icon ("
.. tostring(iconId) .. ")")
ok(entry and entry.image ~= nil, "and that icon has a sheet ("
.. tostring(entry and entry.image) .. ")")
if entry and entry.image and love.filesystem.getInfo then
ok(love.filesystem.getInfo(entry.image) ~= nil,
"and the sheet is on disk")
end
-- New Bark Town is a TOWN, so TryFly's CheckOutdoorMap passes, and it has
-- two wandering objects to watch -- the teacher at (6,8) and the fisher at
-- (12,9), ../pokecrystal/maps/NewBarkTown.asm:301-304. Scene 1 is
-- SCENE_NEWBARKTOWN_NOOP, so the teacher's coord event stays out of the way.
world.mapScenes = world.mapScenes or {}
world.mapScenes.NEW_BARK_TOWN = 1
world:setMap("NEW_BARK_TOWN", 9, 8, "down")
U.wait(15)
local p = world.player
local function free(cx, cy)
return world.map:isWalkable(cx, cy) and not world:npcAt(cx, cy)
end
local DIRS = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 },
right = { 1, 0 } }
if not free(p.cellX, p.cellY) then
for _, d in pairs(DIRS) do
if free(9 + d[1], 8 + d[2]) then
world:setMap("NEW_BARK_TOWN", 9 + d[1], 8 + d[2], "down")
U.wait(10)
break
end
end
end
ok(world.map.id == "NEW_BARK_TOWN" and free(p.cellX, p.cellY),
("standing free on New Bark Town (%d,%d)"):format(p.cellX, p.cellY))
ok((world.map.def and world.map.def.environment) == "TOWN",
"which is a TOWN, so FLY is allowed here")
-- One real step through the pad, on a free neighbour.
local fromX, fromY = p.cellX, p.cellY
for name, d in pairs(DIRS) do
if free(fromX + d[1], fromY + d[2]) then
U.hold(game, name, 24)
break
end
end
U.wait(8)
ok(p.cellX ~= fromX or p.cellY ~= fromY,
("the pad walked the player from (%d,%d) to (%d,%d)")
:format(fromX, fromY, p.cellX, p.cellY))
-- START > POKeMON. CheckMenuOW only runs while nothing else owns the frame
-- (../pokegold/engine/overworld/events.asm), so wait the map's own settling
-- out rather than pressing into it.
for _ = 1, 240 do
if not world:busy() then break end
U.wait(2)
end
local menu
for _ = 1, 10 do
tap("start")
menu = top()
if menu and menu.screenId == "Gen2StartMenu" then break end
U.wait(6)
end
if not ok(menu and menu.screenId == "Gen2StartMenu",
"START opened the menu (top is "
.. tostring(menu and (menu.screenId or "?")) .. ")") then
error("gold flymap: no start menu")
end
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
ok(menu.list:current().value == "pokemon", "the cursor found POKeMON")
tap("a")
local party = top()
if not ok(party and party.screenId == "Gen2PartyMenu",
"POKeMON opened the party list") then
error("gold flymap: no party list")
end
-- A on the flyer, then down the submenu to its FLY row.
tap("a")
local sub = party.submenu
if not ok(sub ~= nil, "and A opened the action submenu") then
error("gold flymap: no submenu")
end
local flyRow
for index, item in ipairs(sub.items) do
if item.id == "FLY" then flyRow = index end
end
if not ok(flyRow ~= nil, "which lists FLY") then
error("gold flymap: the submenu has no FLY row")
end
for _ = 1, #sub.items do
if sub.index == flyRow then break end
tap("down")
end
ok(sub.index == flyRow, "the cursor found FLY")
tap("a")
U.wait(10)
local picker = top()
if not ok(picker and picker.screenId == "Gen2Pokegear",
"FLY opened the town map") then
error("gold flymap: FLY did not open the picker")
end
ok(picker.fly ~= nil and #picker.fly > 0,
"in _FlyMap dress, with " .. tostring(picker.fly and #picker.fly)
.. " flypoints to walk")
-- wCurPartyMon, as TownMapMon reads it (World:openFlyMap's flyMon).
ok(picker.flyMon == flyer, "and it was handed the mon that used the move")
ok(picker.icons ~= nil, "and it can see the icon table")
U.wait(12)
ok(picker.flyMonIcon ~= nil and picker.flyMonIcon ~= false,
"the mon icon loaded for the cursor")
-- No objColors, no OBP bake, and the bake is what keys colour 0 transparent
-- (src/render/SpriteRenderer.lua:243): the icon would sit in a white box.
ok(picker.flyMonIcon and picker.flyMonIcon.objColors ~= nil,
"with an OBJ palette baked onto it")
-- Landmark names carry TownMap_ConvertLineBreakCharacters' own <LF>, which
-- would break a log line in half.
local function flat(entry)
return (tostring(entry and entry.name):gsub("%s+", " "))
end
local before = picker:mapLandmark()
local player = picker:playerLandmark()
ok(before and before.x and before.y, "the cursor is on " .. flat(before))
U.shot(game, out .. "/01-flymap-newbark.png")
tap("up")
U.wait(8)
local after = picker:mapLandmark()
ok(after and (after.x ~= before.x or after.y ~= before.y),
("up walked the cursor to %s at (%s,%s)"):format(flat(after),
tostring(after and after.x), tostring(after and after.y)))
ok(picker:playerLandmark() == player,
"and the player's own icon stayed where it was")
U.shot(game, out .. "/02-flymap-cherrygrove.png")
U.log("flew the party list into the FLY map for you (#1712). the cursor over")
U.log("the highlighted town should be " .. FLY_SPECIES .. "'s 16x16 party")
U.log("icon, red like the little Chris icon parked on New Bark, swapping")
U.log("between its two frames about twice a second, and it moves with")
U.log("up/down while Chris stays put. shots are in " .. out .. ".")
U.log("the arrow cursor instead of a mon is the bug. a mon in a solid white")
U.log("box is the near miss -- the icon is right, the palette bake is not.")
if fails > 0 then
U.log(("%d assertion(s) failed above; the picture below is not worth")
:format(fails))
U.log("reading until those are green.")
end
U.log("the picker is open and the controls are yours.")
while true do
coroutine.yield()
end
end
+17 -19
View File
@@ -8,22 +8,27 @@
-- Two halves, and the second is the one that matters. A hook that fires is
-- easy; a hook that fires and MOVES A PIXEL is worse than no hook at all,
-- because it silently changes what every existing Gold screenshot means. So
-- this shoots the overworld, a menu over the overworld, CLASSIC, and CLASSIC +
-- GBC FX with nothing subscribed, wraps all six hooks with pass-throughs that
-- draw nothing, shoots the same four frames again, and compares the PNG bytes.
-- this shoots the overworld, CLASSIC, and a menu over the overworld with
-- nothing subscribed, wraps all six hooks with pass-throughs that draw
-- nothing, shoots the same three frames again, and compares the PNG bytes.
-- Identical files are the claim; the shots are left on disk either way so a
-- human can look at the picture the port is actually producing.
--
-- The four frames are chosen to cover every branch of Game2:draw: no canvas at
-- all, the zone pass alone, the zone pass into a texture GBC FX then reads, and
-- (once the wraps are on) the render.compose path that forces a canvas even
-- when no display mode wanted one.
-- The three frames are chosen to cover every branch of Game2:draw: no canvas
-- at all, the zone pass alone, and (once the wraps are on) the render.compose
-- path that forces a canvas even when no display mode wanted one.
--
-- The zone-pass-into-a-texture-then-reread branch (`reread` in
-- Game2:drawViewportFrame, formerly exercised by CLASSIC + GBC FX before
-- GBCFX.lua's removal) is not covered here any more:
-- ShaderFX is the only thing left that triggers it, and activating it needs
-- a converted on-disk preset this headless driver does not set up. Flagged,
-- not silently dropped.
local U = require("tests.drivers.util")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local GbcPalette = require("src.render.GbcPalette")
local GBCFX = require("src.render.GBCFX")
local OWNER = "driver_frame_seams"
@@ -50,23 +55,17 @@ return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- The four frames, each named for the display state it exercises. `fx` is
-- requested rather than asserted: GBCFX.isSupported refuses on mobile GPUs
-- and a headless checkout may have no shader at all, in which case that
-- frame simply repeats the CLASSIC one and the comparison still means
-- something.
-- The three frames, each named for the display state it exercises.
local frames = {
{ name = "world", color = "gbc", fx = 0 },
{ name = "classic", color = "classic", fx = 0 },
{ name = "classicfx", color = "classic", fx = 2 },
{ name = "menu", color = "gbc", fx = 0, menu = true },
{ name = "world", color = "gbc" },
{ name = "classic", color = "classic" },
{ name = "menu", color = "gbc", menu = true },
}
local function shoot(tag)
local menuOpen = false
for _, frame in ipairs(frames) do
GbcPalette.setMode(frame.color)
GBCFX.setLevel(frame.fx)
if frame.menu and not menuOpen then
game:openStartMenu()
menuOpen = true
@@ -82,7 +81,6 @@ return function(game)
U.wait(8)
end
GbcPalette.setMode("gbc")
GBCFX.setLevel(0)
end
-- POKEPORT_SEAM_BASELINE=<tag> shoots the four frames under that tag and
@@ -0,0 +1,272 @@
-- #1713: a ledge hop advanced only every other logic frame, so the whole map
-- scrolled at 30Hz in 2px lurches and the sprite bobbed against its own arc.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_ledge_hop_bug1713_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-ledge \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- jump_step is STEP_WALK (pokegold/engine/overworld/movement.asm:595-597), so
-- StepFunction_PlayerJump runs the `db 0, 2, 8, 2` StepVectors row as two
-- 8-frame beats (engine/overworld/map_objects.asm:365-381, :1163-1200) and
-- UpdateJumpPosition walks its 16-entry arc one entry a frame (:1796-1817).
-- We render at twice that resolution, so the hop owes 1px and half an arc
-- entry on every one of its 32 frames.
--
-- No POKEPORT_SPEED here on purpose: the whole subject is per-frame motion,
-- and fast-forward scales only the logic clock.
local U = require("tests.drivers.util")
local Player = require("src.world.gen2.Player")
local Permissions = require("src.world.gen2.Permissions")
-- ROUTE_30's ledge run at y=10 (data/generated/maps.lua, TILESET_JOHTO
-- collision $a3 COLL_HOP_DOWN): open ground two cells above it, a wall in
-- between, floor on the landing. Re-derived by scanning if a re-import moves.
local MAP = "ROUTE_30"
local LEDGE = { x = 4, y = 10 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-ledge"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function stop()
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to look at")
while true do coroutine.yield() end
end
claim(Player.STEP_FRAMES == 16, "a walk is 16 logic frames per cell")
claim(type(world.tryLedgeJump) == "function",
"World:tryLedgeJump is wired for the refused step to fall into")
-- ---- find a ledge with room to run up to it ------------------------------
world:setMap(MAP, LEDGE.x, LEDGE.y - 2, "down")
U.wait(10)
world.noWildEncounters = true
local map = world.map
local function hopsDown(x, y)
if not map:inBounds(x, y) then return false end
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
return facings ~= nil and facings.down == true
end
local function walkable(x, y)
return map:inBounds(x, y) and map:isWalkable(x, y)
end
-- A usable ledge: two free cells to walk down through, a refused cell in the
-- middle (that refusal is what becomes the jump) and a free landing.
local function usable(x, y)
return hopsDown(x, y) and not walkable(x, y + 1) and walkable(x, y + 2)
and walkable(x, y - 1) and walkable(x, y - 2)
end
local target
if usable(LEDGE.x, LEDGE.y) then target = { x = LEDGE.x, y = LEDGE.y } end
if not target then
local def = world.maps[MAP]
for y = 0, (def.height or 0) * 2 - 1 do
for x = 0, (def.width or 0) * 2 - 1 do
if not target and usable(x, y) then target = { x = x, y = y } end
end
end
end
claim(target ~= nil,
("%s still has a hop-down ledge with a run-up to it"):format(MAP))
if not target then
U.log("nothing to hop off; stopping rather than parking you at a wall")
stop()
end
if target.x ~= LEDGE.x or target.y ~= LEDGE.y then
U.log(("note: using the ledge at (%d,%d), not the (%d,%d) in the header")
:format(target.x, target.y, LEDGE.x, LEDGE.y))
end
local START = { x = target.x, y = target.y - 2 }
local function press(dir)
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
-- Back to the top of the run-up. The settle first is load bearing: a
-- setMap on top of a hop that is still in the air carries the leftover jump
-- into the new position and the next run measures nonsense.
local function reset()
game.input.state.down = false
for _ = 1, 120 do
if not (world.player and world.player.moving) then break end
coroutine.yield()
end
world:setMap(MAP, START.x, START.y, "down")
world.noWildEncounters = true
U.wait(8)
end
-- ---- the run-up and the hop, measured ------------------------------------
--
-- Nobody can count pixels per frame by eye, and this is the whole bug: the
-- per-frame travel of the camera-following position, plus the arc riding on
-- top of it. Every sample is "pixels travelled == frames elapsed", never a
-- difference between two driver frames -- the fixed-step loop can run two
-- logic steps for one yield, and a delta would read that as a lurch.
reset()
local p = world.player
local hopRows, takeoff = {}, 0
local walkSeen, walkWrong = 0, 0
for _ = 1, 240 do
press("down")
if p.jumping then
if #hopRows == 0 then takeoff = p.cellY * 16 end
hopRows[#hopRows + 1] = { n = p.progress, py = p.py,
off = p.spriteYOffset or 0 }
elseif #hopRows > 0 then
break
elseif p.moving and (p.progress or 0) > 0 then
walkSeen = walkSeen + 1
if p.py - p.cellY * 16 ~= p.progress then walkWrong = walkWrong + 1 end
end
end
game.input.state.down = false
local wrong, lurch, last = 0, 0, 0
for _, r in ipairs(hopRows) do
if r.py - takeoff ~= r.n then wrong = wrong + 1 end
if r.n > last then last = r.n end
end
-- The old quantum, named directly: an even number of pixels on an odd frame.
for _, r in ipairs(hopRows) do
if (r.py - takeoff) % 2 == 0 and r.n % 2 == 1 then lurch = lurch + 1 end
end
claim(#hopRows > 0, "walking down into the ledge started a hop")
claim(last >= 30,
("the hop ran its full 32 frames (the last one sampled was %d)")
:format(last))
claim(p.cellY == target.y + 2,
("it landed two cells down at y=%d (the player is at y=%d)")
:format(target.y + 2, p.cellY))
claim(p.py - takeoff == 32,
("and 32 pixels down, which is two cells (it moved %d)")
:format(p.py - takeoff))
claim(wrong == 0,
("on every one of the %d frames sampled the player had moved a pixel per"
.. " frame elapsed (%d had not)"):format(#hopRows, wrong))
claim(lurch == 0,
("so no frame of it stood still waiting to lurch two (%d did)")
:format(lurch))
claim(walkSeen > 0 and walkWrong == 0,
("the two ordinary steps before it move a pixel a frame too, on all %d"
.. " frames sampled (%d wrong)"):format(walkSeen, walkWrong))
-- The composed screen position: the camera-following py plus the sprite
-- offset. The armed frame is skipped because the first frame after it is
-- the take-off, and rising there is the jump starting.
local air, back = {}, 0
for _, r in ipairs(hopRows) do
if r.n >= 1 then air[#air + 1] = r.py + r.off end
end
for i = 2, #air do
if air[i] < air[i - 1] then back = back + 1 end
end
claim(back == 0,
("after the take-off the sprite never reversed against its own arc"
.. " (%d frames did)"):format(back))
-- ---- one frame per run, so the strip really is consecutive ---------------
--
-- A capture costs the driver an unknown number of frames, so shooting four
-- frames in one hop would skip some. Four identical run-ups, each shot on a
-- different frame, gives a strip that can be flipped through.
-- One run-up, ending either on the frame asked for or on the landing. The
-- fixed-step loop can occasionally run two logic steps for one driver yield,
-- so the shot is taken at the first frame AT OR PAST the one asked for, and
-- the frame it actually landed on is reported rather than assumed.
local function hopRun(frame, path)
reset()
local airborne = false
for _ = 1, 240 do
press("down")
local pl = world.player
if pl.jumping then
airborne = true
if frame and pl.progress >= frame then
local at, py = pl.progress, pl.py
game.input.state.down = false
U.shot(game, path)
return at, py
end
elseif airborne then
break
end
end
game.input.state.down = false
end
reset()
U.shot(game, out .. "/01-standing.png")
local strip = {}
for i, frame in ipairs({ 12, 13, 14, 15 }) do
local at, py = hopRun(frame, ("%s/02-strip-%d.png"):format(out, i))
strip[#strip + 1] = { at = at or -1, py = py or -1 }
end
hopRun(4, out .. "/03-rising.png")
hopRun(16, out .. "/04-peak.png")
hopRun(28, out .. "/05-falling.png")
hopRun()
U.wait(20)
U.shot(game, out .. "/06-landed.png")
local consecutive = true
local shown = {}
for i, s in ipairs(strip) do
shown[i] = ("%d"):format(s.at)
if i > 1 and (s.at - strip[i - 1].at ~= 1
or s.py - strip[i - 1].py ~= 1) then
consecutive = false
end
end
claim(consecutive,
("the strip really is four consecutive frames, %s -- if this is FAIL the"
.. " images are not comparable"):format(table.concat(shown, ", ")))
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("something above is FAIL, so do not spend time watching the replay")
end
-- ---- the replay, live ----------------------------------------------------
U.log("shots are in " .. out .. "; 02-strip-* are consecutive frames of the")
U.log("same hop, so the ground should shift one pixel between each, and")
U.log("03 / 04 / 05 are the rise, the top of the arc and the drop.")
U.log("the replay walks down into the ledge three times, then hands over.")
U.wait(120)
for _ = 1, 3 do
hopRun()
U.wait(60)
end
U.log("two ordinary steps down, then the hop: the map should scroll at the")
U.log("same speed through all three, and the sprite should rise, hang and")
U.log("fall once. a stutter, a 2px lurch, or the sprite bobbing back up")
U.log("during the rise is the old behaviour.")
U.log("the near miss to watch for: a smooth hop that clears only one cell,")
U.log("or one that takes twice as long as it should -- that is the two-cell")
U.log("span applied without the doubled duration.")
U.log("gen 1 is the reference; a Red ledge hop already looked like this.")
while true do coroutine.yield() end
end
@@ -0,0 +1,328 @@
-- #1748: overworld Pokemon objects (SPRITEMOVEDATA_POKEMON, $16) never bounce.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_ow_bounce_bug1748_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-bounce \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- The $16 row is SPRITEMOVEFN_BOUNCE + OBJECT_ACTION_BOUNCE
-- (data/sprites/map_objects.asm:181-187). SetFacingBounce steps
-- OBJECT_STEP_FRAME once a frame and reads bit 3, swapping FACING_STEP_DOWN_0
-- for FACING_STEP_UP_0 -- eight frames on each (map_object_action.asm:184-202).
-- Its frozen column, SetFacingFreezeBounce, pins FACING_STEP_DOWN_0.
--
-- No POKEPORT_SPEED here on purpose: an eight-frame dwell is the whole thing
-- being watched, and fast-forward scales only the logic clock.
local U = require("tests.drivers.util")
local NPC = require("src.world.gen2.Npc")
-- ../pokegold/maps/PokemonFanClub.asm:311-316 -- the bouncing SPRITE_ODDISH at
-- (7,3) shares the room with a SPRITE_FAIRY doll at (2,4) on
-- SPRITEMOVEDATA_STANDING_DOWN, which is the negative control standing next to
-- it. ../pokegold/maps/PewterPokecenter1F.asm is the reporter's own room.
local ROOM = { map = "POKEMON_FAN_CLUB", x = 7, y = 3 }
local PEWTER = { map = "PEWTER_POKECENTER_1F", x = 1, y = 3 }
local SPRITEMOVEDATA_POKEMON = 0x16
local DELTA = {
up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 },
}
local FACE_FROM = { up = "down", down = "up", left = "right", right = "left" }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bounce"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function stop(why)
for _, line in ipairs(lines) do U.log(line) end
U.log(why)
while true do coroutine.yield() end
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to look at")
while true do coroutine.yield() end
end
-- ---- the things a human's eyes cannot check ------------------------------
--
-- Every one of these fails as silence: a missing movement byte, a sheet with
-- no second frame and a doll that quietly took the animation all look exactly
-- like "the object sits there", which is also what the bug looked like.
claim(NPC.MOVE.POKEMON == SPRITEMOVEDATA_POKEMON,
"SPRITEMOVEDATA_POKEMON is modelled as $16")
claim(type(NPC.bounceFrame) == "function",
"NPC:bounceFrame exists for the draw call to spend")
local function findObject(mapId, x, y)
local def = world.maps[mapId]
if not def then return nil end
local exact, any
for _, obj in ipairs(def.objects or {}) do
if obj.movement == SPRITEMOVEDATA_POKEMON then
any = any or obj
if obj.x == x and obj.y == y then exact = obj end
end
end
return exact or any
end
local monDef = findObject(ROOM.map, ROOM.x, ROOM.y)
claim(monDef ~= nil,
("%s still carries a $16 object"):format(ROOM.map))
if not monDef then
stop("no bouncing object on the map; stopping rather than faking the moment")
end
if monDef.x ~= ROOM.x or monDef.y ~= ROOM.y then
U.log(("note: using the mon at (%d,%d), not the (%d,%d) in the header")
:format(monDef.x, monDef.y, ROOM.x, ROOM.y))
end
local sheet = type(monDef.sprite) == "string"
and world.sprites and world.sprites[monDef.sprite]
claim(sheet ~= nil,
("its sprite %s resolves to a sheet"):format(tostring(monDef.sprite)))
claim(sheet and (sheet.frames or 1) >= 2,
("and that sheet is two frames deep (frames = %s) -- one frame is the half"
.. " of #1748 a re-import fixes"):format(sheet and tostring(sheet.frames)))
claim(world.scripts and world.scripts[monDef.scriptKey] ~= nil,
("its script %s is in the cache, so the A press below opens a box")
:format(tostring(monDef.scriptKey)))
-- ---- walk in and stand in front of it ------------------------------------
world:setMap(ROOM.map, monDef.x, monDef.y + 1, "up")
U.wait(20)
local map = world.map
-- Stand on the mon's neighbour and face back at it; any free neighbour will
-- do, so a re-import that shuffles the room degrades instead of parking the
-- player at a wall.
local spot
for _, dir in ipairs({ "down", "left", "right", "up" }) do
local d = DELTA[dir]
local x, y = monDef.x + d[1], monDef.y + d[2]
if not spot and map:isWalkable(x, y) then
spot = { x = x, y = y, facing = FACE_FROM[dir] }
end
end
claim(spot ~= nil, "there is a free cell beside it to stand on")
if not spot then
stop("nowhere to stand; stopping rather than parking the player at a wall")
end
world:setMap(ROOM.map, spot.x, spot.y, spot.facing)
U.wait(20)
local mon, dolls = nil, {}
for _, npc in ipairs(world.npcs or {}) do
if npc.def == monDef then mon = npc
elseif npc.def and npc.def.movement ~= SPRITEMOVEDATA_POKEMON then
dolls[#dolls + 1] = npc
end
end
claim(mon ~= nil, "the object spawned as an NPC")
if not mon then stop("the mon never spawned; nothing to watch") end
claim(mon.bouncing == true, "and it spawned bouncing")
local stillBouncers = 0
for _, npc in ipairs(dolls) do
if npc.bouncing then stillBouncers = stillBouncers + 1 end
end
claim(stillBouncers == 0,
("none of the %d other objects in the room bounce -- the Clefairy doll"
.. " beside it must not move"):format(#dolls))
-- ---- the cycle, measured on the logic clock ------------------------------
-- One sample per frame, and the run lengths are what SetFacingBounce's bit 3
-- decides. Measured rather than eyeballed because four flips a second is
-- exactly the rate a human reads as "about right" when it is wrong.
local samples = {}
for i = 1, 96 do
samples[i] = mon:bounceFrame()
coroutine.yield()
end
local runs, values = {}, {}
for i = 1, #samples do
if i > 1 and samples[i] == samples[i - 1] then
runs[#runs] = runs[#runs] + 1
else
runs[#runs + 1] = 1
values[#values + 1] = samples[i]
end
end
claim(#runs >= 5, ("the pose changed %d times in 96 frames"):format(#runs - 1))
local alternates, dwell, uneven = true, nil, nil
for i = 2, #runs - 1 do
dwell = dwell or runs[i]
if runs[i] ~= dwell then uneven = uneven or runs[i] end
if values[i] == values[i - 1] then alternates = false end
end
claim(alternates and values[1] ~= nil,
"it alternates between two poses and only two")
claim(uneven == nil, ("every whole dwell is the same length"
.. (uneven and (" -- saw %d and %d"):format(dwell or 0, uneven) or "")))
claim(dwell == 8, ("and it is the eight frames bit 3 buys (measured %s)")
:format(tostring(dwell)))
-- ---- what the renderer was actually handed -------------------------------
-- A bounce the draw call drops looks exactly like no bounce, and a doll that
-- picked the override up is the other way to pass this by accident.
local seen, watched = {}, { { key = "mon", npc = mon } }
for i, npc in ipairs(dolls) do
watched[#watched + 1] = { key = "doll" .. i, npc = npc }
end
for _, row in ipairs(watched) do
local sprite = row.npc.sprite
row.sprite, row.real = sprite, sprite.draw
seen[row.key] = {}
sprite.draw = function(s, ...)
local args = { ... }
local log = seen[row.key]
-- "none" rather than a hole, so a sprite that was never drawn at all is
-- not mistaken for one that was drawn with no override.
log[#log + 1] = args[10] == nil and "none" or args[10]
return row.real(s, ...)
end
end
U.wait(40)
for _, row in ipairs(watched) do row.sprite.draw = row.real end
local monPoses = {}
for _, v in ipairs(seen.mon) do monPoses[v] = true end
claim(#seen.mon > 0, "the mon was drawn")
claim(monPoses[0] and monPoses[1],
"and both icon frames reached SpriteRenderer over 40 frames")
local dollDraws, dollOverrides = 0, 0
for i = 1, #dolls do
for _, v in ipairs(seen["doll" .. i] or {}) do
dollDraws = dollDraws + 1
if v ~= "none" then dollOverrides = dollOverrides + 1 end
end
end
claim(dollDraws > 0, ("the other %d objects were drawn too"):format(#dolls))
claim(dollOverrides == 0, "and not one of them was handed a frame override")
-- ---- the pair of shots ---------------------------------------------------
-- The counter is held while each capture lands so the file is definitely one
-- pose rather than whichever the spin drifted onto; the cycle above is what
-- proves it runs on its own.
local function holdShot(path, step)
os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null')
game.capturePath = path
for _ = 1, 120 do
mon.bounceStep = step
if not game.capturePath then break end
coroutine.yield()
end
mon.bounceStep = step
coroutine.yield()
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
return bytes
end
local down = holdShot(out .. "/01-pose-down.png", 0)
local up = holdShot(out .. "/02-pose-up.png", 8)
claim(down ~= nil and up ~= nil, "both pose shots reached disk")
claim(down and up and down ~= up,
"and the two files differ, so the screen really changes between poses")
-- ---- talking to it holds the first pose ----------------------------------
-- OBJECT_ACTION_BOUNCE's frozen column is SetFacingFreezeBounce, which writes
-- FACING_STEP_DOWN_0 and never touches the counter.
mon.bounceStep = 12
local phaseBefore = mon.bounceStep
U.tap(game, "a")
U.wait(30)
claim(world.talkNpc == mon or mon.frozen,
"the A press reached the mon and froze it")
local heldWrong = nil
for _ = 1, 60 do
if mon:bounceFrame() ~= 0 then heldWrong = mon:bounceFrame() end
coroutine.yield()
end
claim(heldWrong == nil,
"and it holds its first pose for the whole conversation")
claim(mon.bounceStep == phaseBefore,
("with the step counter left at %d, so the bounce resumes on the phase it"
.. " froze at (it is %s)"):format(phaseBefore, tostring(mon.bounceStep)))
U.shot(game, out .. "/03-frozen-talking.png")
U.tap(game, "a")
U.wait(40)
U.tap(game, "a")
U.wait(40)
-- ---- the reporter's own room ---------------------------------------------
local pewterDef = findObject(PEWTER.map, PEWTER.x, PEWTER.y)
if not pewterDef then
U.log("note: no $16 object on " .. PEWTER.map .. ", skipping its shots")
else
world:setMap(PEWTER.map, pewterDef.x, pewterDef.y + 2, "up")
U.wait(30)
local jiggly
for _, npc in ipairs(world.npcs or {}) do
if npc.def == pewterDef then jiggly = npc end
end
claim(jiggly ~= nil and jiggly.bouncing == true,
("the %s at (%d,%d) bounces too"):format(PEWTER.map,
pewterDef.x, pewterDef.y))
if jiggly then
local a, b
local function pin(path, step)
os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null')
game.capturePath = path
for _ = 1, 120 do
jiggly.bounceStep = step
if not game.capturePath then break end
coroutine.yield()
end
coroutine.yield()
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
return bytes
end
a = pin(out .. "/04-pewter-down.png", 0)
b = pin(out .. "/05-pewter-up.png", 8)
claim(a ~= nil and b ~= nil and a ~= b,
"and its two pose shots differ as well")
end
end
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("something above says FAIL, so do not spend time watching the room.")
end
-- ---- what to look at -----------------------------------------------------
U.log("shots are in " .. out .. ". 01 and 02 are the two poses of the same")
U.log("mon; they should look like the two halves of its party menu icon,")
U.log("the second one sitting a pixel or two differently. 03 is the same mon")
U.log("mid-conversation, which must match 01.")
U.log("")
U.log("the run ends back in the Fan Club with the Oddish on screen at (7,3).")
U.log("it should visibly flip between its two icon poses about four times a")
U.log("second, without moving off its tile or turning to face anything.")
U.log("the Clefairy doll at (2,4) and the people in the room must stay dead")
U.log("still: a doll that bobs means the bounce got keyed off the sprite")
U.log("instead of the movement byte. a mon that keeps bobbing while its text")
U.log("box is open is the other near miss -- it must hold one pose there and")
U.log("pick the cycle back up where it left off when the box closes.")
world:setMap(ROOM.map, spot.x, spot.y, spot.facing)
while true do coroutine.yield() end
end
@@ -0,0 +1,243 @@
-- The PACK's TM/HM rows (#1695), the list cursor's colour (#1694) and the rows
-- a message prints on in the description box (#1725).
-- engine/items/tmhm.asm:355-403 and engine/gfx/cgb_layouts.asm:723-726 (pokegold).
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_pack_rows_bug1695_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-pack-rows love .
--
-- No POKEPORT_SPEED: it scales the logic clock only, so every shot here would
-- race the keypress that redrew the list.
local U = require("tests.drivers.util")
local Bag = require("src.inventory.Bag")
local GbcPalette = require("src.render.GbcPalette")
local PackMenu = require("src.ui.gen2.PackMenu")
-- ../pokecrystal/maps/NewBarkTown.asm:287 -- the tile in front of the player's
-- own front door. An outdoor map is what makes the ESCAPE ROPE below print
-- OakThisIsntTheTimeText instead of warping the PACK shut.
local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 }
-- One-digit and two-digit counts side by side, both HM ends of the pocket, and
-- the two key items whose submenus reach a SEL and a USE.
local SEED = {
{ "POTION", 5 },
{ "SUPER_POTION", 50 },
{ "ESCAPE_ROPE", 3 },
{ "POKE_BALL", 7 },
{ "GREAT_BALL", 12 },
{ "ITEMFINDER", 1 },
{ "BICYCLE", 1 },
{ "TM_DYNAMICPUNCH", 1 },
{ "TM_HEADBUTT", 3 },
{ "TM_THUNDER", 24 },
{ "HM_CUT", 1 },
{ "HM_SURF", 1 },
{ "HM_WATERFALL", 1 },
}
-- TM/HM pocket order is wTMsHMs order (engine/items/tmhm.asm:341), and each row
-- carries the number the cart prints at column 5 and the move name at column 8.
local TMHM = {
{ "TM_DYNAMICPUNCH", "01", "DYNAMICPUNCH" },
{ "TM_HEADBUTT", "02", "HEADBUTT" },
{ "TM_THUNDER", "25", "THUNDER" },
{ "HM_CUT", "H1", "CUT" },
{ "HM_SURF", "H3", "SURF" },
{ "HM_WATERFALL", "H7", "WATERFALL" },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pack-rows"
local failed = 0
local function pass(ok, line)
if not ok then failed = failed + 1 end
U.log((ok and "PASS " or "FAIL ") .. line)
end
local function shot(name)
U.wait(4)
U.shot(game, ("%s/%s.png"):format(out, name))
end
local function tap(button, frames)
U.tap(game, button)
U.wait(frames or 5)
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local world, save = game.world, game.save
-- A later map edit must not park the player against a wall: try the door
-- tile, then any free neighbour of it.
local placed = world:setMap(HOME.map, HOME.x, HOME.y, "down")
if not placed then
for _, step in ipairs({ { 0, -1 }, { 1, 0 }, { -1, 0 }, { 0, 1 } }) do
placed = world:setMap(HOME.map, HOME.x + step[1], HOME.y + step[2], "down")
if placed then break end
end
end
U.wait(12)
local mapId = world.map and world.map.id
local env = world.map and world.map.def and world.map.def.environment
pass(placed and env ~= "CAVE" and env ~= "DUNGEON",
("standing in %s (environment %s), where an ESCAPE ROPE has nowhere to " ..
"pay out to"):format(tostring(mapId), tostring(env)))
save.inventory = {}
save.bagOrder = {}
for _, entry in ipairs(SEED) do
local id, count = entry[1], entry[2]
if game.data.items and game.data.items[id] then
save.inventory[id] = count
table.insert(save.bagOrder, id)
else
pass(false, id .. " is not in this cache, so its row will be missing")
end
end
Bag.order(save, { items = game.data.items })
-- A probe pack for the checks, so the one on screen opens on a clean cursor.
local probe = PackMenu.new(game, { save = save, world = world,
pocket = "TM_HM" })
for i, want in ipairs(TMHM) do
local row = probe.rows[i]
pass(row and row.id == want[1] and row.tmhmLabel == want[2],
("row %d is %s and its number reads %q (got %s / %s)"):format(
i, want[1], want[2], tostring(row and row.id),
tostring(row and row.tmhmLabel)))
pass(row and row.teaches == want[3],
("row %d prints the move name %q rather than the item name %s"):format(
i, want[3], tostring(row and row.name)))
end
pass(probe.gfx and probe.gfx:available(),
"this cache has the PACK's own tiles, so the screen is the cart's chrome " ..
"and not the fallback boxes")
pass(GbcPalette.available(),
"the palette shader is loaded, so a coloured cursor is drawable at all")
pass(GbcPalette.mode == "gbc",
("colour mode is %q; anything else draws the cursor in DMG greys and " ..
"looks exactly like the bug"):format(tostring(GbcPalette.mode)))
local cursorPal = probe.gfx and probe.gfx:available()
and probe.gfx:colorsAt(7, 2)
local ink = cursorPal and cursorPal[4]
pass(ink and ink[1] == 255 and ink[2] == 0 and ink[3] == 0,
("the cursor column (7,2) resolves to a palette whose colour 3 is " ..
"255,0,0 (got %s)"):format(ink and table.concat(ink, ",") or "nil"))
local lastPal = probe.gfx and probe.gfx:available()
and probe.gfx:colorsAt(7, 10)
pass(lastPal == cursorPal,
"and the fifth list row's cursor cell (7,10) is in the same 1x9 zone")
local finder = game.data.items and game.data.items.ITEMFINDER
pass(finder and finder.canSelect == true and world.registerItem ~= nil,
"the ITEMFINDER is registerable, so SEL prints \"Registered the\" and " ..
"not the refusal")
local rope = game.data.items and game.data.items.ESCAPE_ROPE
pass(rope and rope.fieldMenu ~= "ITEMMENU_NOUSE",
"the ESCAPE ROPE's submenu carries a USE row to reach the three-line " ..
"OAK message with")
if failed > 0 then
U.log(("%d check(s) failed above, so the shots below cannot be read as " ..
"a pass."):format(failed))
end
game.packCursor = nil
local pack = PackMenu.new(game, { save = save, world = world,
onClose = function() game.stack:pop() end })
game.stack:push(pack)
U.log("00-items: the arrow beside POTION should be the same red as the")
U.log("pocket plaque on the left, not black. black in every pocket means")
U.log("the palette never reached it (#1694).")
shot("00-items")
tap("select")
tap("down")
U.log("01-items-select: row 1 now carries the hollow arrow and row 2 the")
U.log("solid one, both red. \"Where should this\" / \"be moved to?\" sit on")
U.log("rows 14 and 16 with a blank row between them (#1725).")
U.log("switching row is " .. tostring(pack.switching) ..
", cursor on " .. tostring(pack.index))
shot("01-items-select")
tap("b")
tap("up")
tap("right")
shot("02-balls")
tap("right")
shot("03-key-items")
U.log("02 and 03: same red arrow in POKe BALLS and KEY ITEMS.")
tap("right")
U.log("04-tmhm-top: the rows read \"01 DYNAMICPUNCH\", \"02 HEADBUTT\",")
U.log("\"25 THUNDER\", \"H1 CUT\", \"H3 SURF\". the number is hard against the")
U.log("left edge of the item area where the blue pattern column ends, the")
U.log("arrow one tile later, the move name from column 8. the word TM or HM")
U.log("printed anywhere, or a right-aligned number, is the bug (#1695).")
U.log("pocket is " .. tostring(pack:pocket().id) ..
" with " .. tostring(#pack.rows) .. " rows")
shot("04-tmhm-top")
for _ = 1, 5 do tap("down") end
U.log("05-tmhm-hms: scrolled to the last HM. \"H7 WATERFALL\" reads with a")
U.log("single digit and no count; \"H07\" would mean the TM branch took it.")
U.log("the three TM rows above it still carry their xNN on the line below.")
shot("05-tmhm-hms")
tap("left")
tap("left")
tap("left")
tap("a")
tap("down")
tap("down")
tap("a")
U.log("06-toss-how-many: \"Throw away how\" on row 14 and \"many?\" on row 16,")
U.log("with a full blank row between them and the first line clear of the")
U.log("box's top border. adjacent lines mean the message path is still")
U.log("single-spaced (#1725).")
U.log("message is " ..
(pack.message and table.concat(pack.message, " / ") or "nil"))
shot("06-toss-how-many")
tap("b")
tap("down")
tap("down")
tap("a")
tap("a")
U.log("07-oak-three-lines: a message too tall for two rows still packs one")
U.log("row apart, so \"OAK: GOLD!\" / \"This isn't the\" / \"time to use that!\"")
U.log("fill rows 13, 14 and 15. a gap here would clip the third line.")
U.log("message is " ..
(pack.message and table.concat(pack.message, " / ") or "nil"))
shot("07-oak-three-lines")
tap("b")
tap("right")
tap("right")
tap("a")
tap("down")
tap("a")
U.log("08-registered: the case the report filed. \"Registered the\" on row 14")
U.log("and \"ITEMFINDER.\" on row 16, lining up with the item description")
U.log("that shares this box. row 13 plus row 14 is the old behaviour; row 15")
U.log("plus a clipped row 17 means the offset went the other way.")
U.log("message is " ..
(pack.message and table.concat(pack.message, " / ") or "nil"))
shot("08-registered")
U.log("shots in " .. out .. "; the PACK is open on KEY ITEMS, controls yours")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,90 @@
-- Regression test: with SHADER FX active on Gen 2 (Gold), opening the
-- START menu used to render as a flat black fill
-- instead of menu content. Root cause: ShaderFX.lua's cropToGbSource()
-- samples the scene canvas via love.graphics.draw(), which multiplies by
-- the current draw color -- the menu's own drawing (black text/border)
-- left that color at (0,0,0,1) and never reset it, so cropToGbSource's own
-- draw silently multiplied its whole output to black. push("all") at the
-- top of cropToGbSource saves/restores state for its caller but does not
-- reset color, so the function must set its own white explicitly rather
-- than trust whatever the caller left active. Fixed by an explicit
-- love.graphics.setColor(1, 1, 1, 1) immediately before that draw call.
--
-- Samples ShaderFX._lastCrop directly rather than a screenshot of the
-- final window: ShaderFX.render() always draws the correct, unmodified
-- `canvas` as a base layer before compositing the (possibly-broken) shader
-- chain output on top, so a whole-window screenshot stays mostly readable
-- even when the crop itself is fully black -- it just loses the shader
-- effect under a faint dark overlay. Only the crop canvas itself catches
-- the bug precisely.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shaderfx_menu_black_crop_test.lua \
-- lovec.exe .
--
-- Skips itself (does not fail) if no ShaderFX preset is installed in the
-- save dir's shaders/ folder (see ShaderFX.presetDir()) -- this test needs
-- a real preset to exercise cropToGbSource at all.
local U = require("tests.drivers.util")
local StartMenu = require("src.ui.gen2.StartMenu")
-- Same 4x4-grid luminance-range technique this bug was originally
-- characterized with.
local function luminanceRange(canvas)
local id = canvas:newImageData()
local w, h = id:getDimensions()
local minL, maxL = 1, 0
for gy = 0, 3 do
for gx = 0, 3 do
local x = math.min(w - 1, math.floor((gx + 0.5) * w / 4))
local y = math.min(h - 1, math.floor((gy + 0.5) * h / 4))
local r, g, b = id:getPixel(x, y)
local l = 0.299 * r + 0.587 * g + 0.114 * b
if l < minL then minL = l end
if l > maxL then maxL = l end
end
end
return maxL - minL
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local ShaderFX = require("src.render.ShaderFX")
local entry = ShaderFX.findEntry("gameboy-color-dot-matrix.slangp")
if not entry then
print("[driver] SKIP no ShaderFX preset installed -- cannot exercise cropToGbSource")
love.event.quit(0)
return
end
if not entry.converted then
local ok, err = ShaderFX.convert(entry)
assert(ok, "ShaderFX.convert failed: " .. tostring(err))
end
game.options = game.options or {}
game.options.shaderfx = entry.name
game:applyOptions()
assert(ShaderFX.active("main"), "ShaderFX main slot did not activate")
-- Baseline: no menu on the stack, crop should already have real content.
U.wait(10)
local baseline = luminanceRange(ShaderFX._lastCrop)
assert(baseline > 0.05,
("sanity check failed: baseline crop (no menu) is already near-flat " ..
"(variance %.3f) -- something else is broken, not this bug"):format(baseline))
-- The actual repro: push the START menu, one more frame renders through
-- it, then sample the same crop canvas again.
game.stack:push(StartMenu.new(game, { save = game.save }))
U.wait(3)
local withMenu = luminanceRange(ShaderFX._lastCrop)
game.stack:pop()
assert(withMenu > 0.05,
("crop is near-flat with the START menu open (variance %.3f, baseline " ..
"was %.3f) -- the ShaderFX Gen2 blank-menu bug is back"):format(withMenu, baseline))
print(("[driver] PASS gold shaderfx menu-blank regression, baseline=%.3f withMenu=%.3f")
:format(baseline, withMenu))
love.event.quit(0)
end
@@ -0,0 +1,73 @@
-- Regression test: on Gen 2, SHADER FX only ever shaded the small centred
-- "faithful ratio" 160x144 box, never the
-- real on-screen footprint of the live overworld -- unlike Gen 1, whose
-- Renderer.lua grows ShaderFX's rect to the real world canvas/zoom footprint
-- whenever the live overworld is what's on screen (src/render/Renderer.lua,
-- the `self.worldActive` branch around line 977). Gen2's drawViewportFrame
-- instead always built ShaderFX's rect from Chrome.fitScale's fixed
-- faithful-ratio box (src/core/Game2.lua), so on any window that is not
-- exactly 4:3 -- every real phone, and any resized desktop window -- the
-- live world already draws edge to edge past that box (Chrome.lua's own
-- comment: "The live overworld IS the background here -- it draws edge to
-- edge... with no surround to paint first"), and SHADER FX only ever shaded
-- the small box in the middle, leaving the rest of the visible map unshaded.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shaderfx_zoom_sizing_test.lua \
-- lovec.exe .
--
-- Skips itself (does not fail) if no ShaderFX preset is installed in the
-- save dir's shaders/ folder (see ShaderFX.presetDir()).
local U = require("tests.drivers.util")
local GameViewport = require("src.render.GameViewport")
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local ShaderFX = require("src.render.ShaderFX")
local entry = ShaderFX.findEntry("gameboy-color-dot-matrix.slangp")
if not entry then
print("[driver] SKIP no ShaderFX preset installed -- cannot exercise ShaderFX.render")
love.event.quit(0)
return
end
if not entry.converted then
local ok, err = ShaderFX.convert(entry)
assert(ok, "ShaderFX.convert failed: " .. tostring(err))
end
game.options = game.options or {}
game.options.shaderfx = entry.name
game:applyOptions()
assert(ShaderFX.active("main"), "ShaderFX main slot did not activate")
-- conf.lua's bare-desktop default (1024x768) is exactly 4:3, which
-- coincidentally hides this bug -- resize to a 16:9 window, the shape of
-- every real phone/handheld this project actually ships on, where the
-- live world draws well past the small faithful-ratio box.
love.window.setMode(1280, 720, { resizable = true })
U.wait(5)
local w, h = GameViewport.dimensions()
local pw, ph = GameViewport.pixelDimensions()
assert(game.frameWorldActive, "expected the live overworld to be on screen")
local rect = ShaderFX._lastRect
assert(rect, "ShaderFX._lastRect was never set -- ShaderFX.render did not run")
print(("[driver] window %dx%d (%dx%d px), faithful box would be %dx%d, " ..
"ShaderFX rect is %.1fx%.1f"):format(
w, h, pw, ph,
160 * math.floor(math.min(w / 160, h / 144)),
144 * math.floor(math.min(w / 160, h / 144)),
rect.w, rect.h))
-- Real assertion: with the live overworld on screen, ShaderFX's rect
-- should track the world's real edge-to-edge footprint (~ the full
-- window), not the small integer-multiple 4:3 box centered inside it.
assert(rect.w >= pw - 2 and rect.h >= ph - 2,
("SHADER FX's rect only covers %.1fx%.1f of a %dx%d px window -- still " ..
"the fixed faithful-ratio box, not the live world's real on-screen " ..
"footprint"):format(rect.w, rect.h, pw, ph))
print("[driver] PASS gold shaderfx zoom-sizing regression")
love.event.quit(0)
end
+42
View File
@@ -0,0 +1,42 @@
-- A button during the GS splash skips the intro movie
-- (pokegold engine/menus/intro_menu.asm:848-851 IntroSequence).
local U = require("tests.drivers.util")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
local TitleState = require("src.ui.gen2.TitleState")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-splash"
U.wait(10)
game:showGameFreak()
U.wait(5)
assert(getmetatable(game.stack:top()) == GameFreakPresents,
"showGameFreak did not open the GS splash (top "
.. tostring(game.stack:top()) .. ")")
U.wait(40)
U.shot(game, out .. "/splash.png")
U.tap(game, "b")
for _ = 1, 60 do
if getmetatable(game.stack:top()) == TitleState then break end
assert(getmetatable(game.stack:top()) ~= GoldSilverIntro,
"a skipped splash still played the intro movie")
U.wait(1)
end
assert(getmetatable(game.stack:top()) == TitleState,
"a skipped splash did not land on the title (top "
.. tostring(game.stack:top()) .. ")")
U.wait(30)
U.shot(game, out .. "/title.png")
-- Watched through, the splash still hands off to the movie.
game:showGameFreak()
for _ = 1, 900 do
if getmetatable(game.stack:top()) == GoldSilverIntro then break end
U.wait(5)
end
assert(getmetatable(game.stack:top()) == GoldSilverIntro,
"a watched splash did not hand off to the intro movie (top "
.. tostring(game.stack:top()) .. ")")
U.log("PASS gold splash skip in " .. out)
end
@@ -0,0 +1,279 @@
-- #1693: the mon SUMMARY's lower half never took the selected page's colour.
-- StatsScreen_LoadGFX .LoadPals farcalls LoadStatsScreenPals with the page
-- index, and that writes one gfx/stats/stats.pal colour over colour 0 of BG
-- palette 0 AND BG palette 2 (engine/gfx/color.asm:373-393); WipeAttrmap
-- leaves rows 8-17 on palette 0 with the exp strip at (10,16) on palette 2
-- (engine/gfx/cgb_layouts.asm:219-229), so both writes land below the rule.
-- Never add POKEPORT_SPEED here: fast-forward scales the logic clock only, so
-- the cry and the page redraw stop landing in the order a reader is judging.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_stats_page_tint_bug1693_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1693 \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local U = require("tests.drivers.util")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local Mon = require("src.battle.gen2.Mon")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
-- gfx/stats/stats.pal, 5-bit the way the ROM keeps it: pink, green, blue
local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } }
local PAGE_NAMES = { "pink", "green", "blue" }
local function up(v) return math.floor(v * 255 / 31 + 0.5) end
local function pct(v) return math.floor(v + 0.5) end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1693"
local fails = 0
local function ok(cond, msg)
if cond then U.log("PASS", msg) else fails = fails + 1 U.log("FAIL", msg) end
return cond and true or false
end
local function tap(btn) U.tap(game, btn) U.wait(4) end
local function top() return game.stack:top() end
local function shot(name)
return ok(U.shot(game, ("%s/%s.png"):format(out, name)),
name .. " reached disk")
end
U.wait(45)
if not ok(game.world and game.world.map ~= nil,
"gold booted into the world") then
error("gold stats tint: no world, nothing to open a summary over")
end
-- ---- the party the pink page needs ---------------------------------------
-- Mon.new is the only Gen 2 party builder; a mon out of Gen 1's Pokemon.new
-- comes back with no moves and no growth rate, so both bars would be empty.
local save = game.save
save.player.name = "GOLD"
save.player.id = 12345
save.party = {
Mon.new(game.data, "CYNDAQUIL", 22),
Mon.new(game.data, "TOTODILE", 18),
}
local lead = save.party[1]
lead.item = "BERRY"
lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45))
local def = game.data.pokemon[lead.species]
local rates = game.data.pokemon.growthRates
local rate = rates and def and rates[def.growthRate]
if rate then
local base = Mon.experienceForLevel(rate, lead.level)
local next_ = Mon.experienceForLevel(rate, lead.level + 1)
lead.experience = base + math.floor((next_ - base) * 0.55)
end
ok(game.world.giveEgg and game.world:giveEgg(175, 5) and save.party[3]
and save.party[3].isEgg == true, "slot 3 holds an egg")
-- ---- everything the eye cannot check -------------------------------------
for page = 1, 3 do
local tint = SummaryMenu.PAGE_TINTS and SummaryMenu.PAGE_TINTS[page]
ok(tint ~= nil and tint[1] == up(ROM_TINTS[page][1])
and tint[2] == up(ROM_TINTS[page][2])
and tint[3] == up(ROM_TINTS[page][3]),
("the %s tint is stats.pal's own colour"):format(PAGE_NAMES[page]))
local probe = setmetatable({ page = page }, { __index = SummaryMenu })
local lower = probe.lowerColors and probe:lowerColors()
ok(lower ~= nil and lower[1] == tint and lower[4][1] == 0,
("the %s lower palette is that tint over black ink"):format(
PAGE_NAMES[page]))
end
ok(GbcPalette.available(), "the GBC shade-remap shader compiled")
-- COLOR is the port's own display row and DMG flattens every palette in the
-- game, so a run started on it looks exactly like the bug.
game.options = game.options or {}
local startedOn = game.options.color or "gbc"
local function setColor(mode)
game.options.color = mode
if game.save then game.save.options = game.options end
GbcPalette.applyOptions(game.options)
return GbcPalette.mode
end
if startedOn ~= "gbc" then
U.log("COLOR was on " .. GbcPalette.modeLabel(startedOn) .. ", which would")
U.log("grey every shot below for the wrong reason. forcing it to GBC.")
end
ok(setColor("gbc") == "gbc", "COLOR is GBC for the run")
-- ---- the live screen, counted rather than admired ------------------------
-- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes
-- a GB pixel a pixel and the tint countable.
local function frameOf(screen)
local G = love.graphics
local canvas = G.newCanvas(160, 144)
G.setCanvas(canvas)
G.clear(0, 0, 0, 1)
G.setColor(1, 1, 1, 1)
screen:draw()
G.setCanvas()
G.setColor(1, 1, 1, 1)
return canvas:newImageData()
end
local function near(a, b) return math.abs(a - b) <= 2 end
-- Counted against stats.pal itself, not the port's table, so a run that has
-- no PAGE_TINTS cannot agree with itself.
local function tintFor(page)
local rom = ROM_TINTS[page] or ROM_TINTS[1]
return { up(rom[1]), up(rom[2]), up(rom[3]) }
end
local function scan(img, want, x0, y0, x1, y1)
local hit, white, total = 0, 0, 0
for y = y0, y1 do
for x = x0, x1 do
local r, g, b = img:getPixel(x, y)
r, g, b = r * 255, g * 255, b * 255
total = total + 1
if near(r, want[1]) and near(g, want[2]) and near(b, want[3]) then
hit = hit + 1
end
if near(r, 255) and near(g, 255) and near(b, 255) then
white = white + 1
end
end
end
return hit / total * 100, white / total * 100
end
-- StatsScreen_LoadGFX .ClearBox: hlcoord 0, 8 / lb bc, 10, 20
local LOWER = { 0, 64, 159, 143 }
local function report(label, hit, white)
U.log((" %-24s %3d%% tinted %3d%% white"):format(label, pct(hit),
pct(white)))
end
local function zone(img, tint, label, rect, wantTint)
local hit, white = scan(img, tint, rect[1], rect[2], rect[3], rect[4])
report(label, hit, white)
ok(hit >= wantTint, label .. " carries the page tint")
ok(white <= 2, label .. " has no white left in it")
end
local function inspect(screen, name)
local img = frameOf(screen)
local tint = tintFor(screen.page)
zone(img, tint, "rows 8-17", LOWER, 40)
if screen.page == SummaryMenu.PINK_PAGE then
-- DrawPlayerHP: "HP:" at (0,9), six bar cells from (2,9)
zone(img, tint, "the HP bar's cells", { 16, 72, 63, 79 }, 8)
-- LoadPinkPage's divider column, and FillInExpBar at (11,16)
zone(img, tint, "the divider, column 9", { 72, 64, 79, 143 }, 20)
zone(img, tint, "the exp bar", { 88, 128, 151, 135 }, 8)
elseif screen.page == SummaryMenu.BLUE_PAGE then
zone(img, tint, "the divider, column 10", { 80, 64, 87, 143 }, 20)
end
-- The upper half is on the mon palette, which the cart never tints.
local _, white = scan(img, tint, 0, 0, 159, 55)
ok(white >= 20, "the upper half stayed white")
shot(name)
end
-- ---- walk in through the pad ---------------------------------------------
tap("start")
local menu = top()
if not ok(menu and menu.screenId == "Gen2StartMenu",
"START opened the menu") then
error("gold stats tint: no start menu, cannot reach the summary")
end
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
ok(menu.list:current().value == "pokemon", "the cursor found the party row")
tap("a")
local party = top()
if not ok(party and party.screenId == "Gen2PartyMenu",
"that opened the party list") then
error("gold stats tint: no party list, cannot reach the summary")
end
local function openStats()
tap("a")
ok(party.submenu ~= nil and party.submenu.items[1].id == "STATS",
"the mon submenu leads with STATS")
tap("a")
local screen = top()
ok(screen and screen.screenId == "Gen2SummaryMenu",
"STATS opened the summary")
return screen
end
local summary = openStats()
ok(summary.hud and summary.hud:available(),
"the HUD sheet is cached, so the bars are the cart's own tiles")
ok(summary.statsTiles and summary:statsTiles() ~= nil,
"menu_gfx.stats is cached, so the divider and caps are too")
local fraction = HpBar.expFraction(summary.mon, summary:growth(),
Mon.experienceForLevel)
ok(fraction > 0.05 and fraction < 0.95,
"the lead is part way to its next level, so the exp bar is half drawn")
ok((summary.mon.hp or 0) > 0 and summary.mon.hp < (summary.mon.maxHp or 0),
"and hurt, so the HP bar has empty cells as well as full ones")
inspect(summary, "01-pink-page")
tap("right")
ok(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page")
inspect(summary, "02-green-page")
tap("right")
ok(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page")
inspect(summary, "03-blue-page")
tap("right")
ok(summary.page == SummaryMenu.PINK_PAGE, "and wrapped back to pink")
-- ---- the two screens that must stay white --------------------------------
tap("b")
ok(top() == party, "b came back to the list")
tap("down")
tap("down")
ok(party.index == 3, "the cursor reached the egg")
local egg = openStats()
ok(egg.mon and egg.mon.isEgg == true, "on the egg")
local eggTint, eggWhite = scan(frameOf(egg), tintFor(SummaryMenu.PINK_PAGE),
LOWER[1], LOWER[2], LOWER[3], LOWER[4])
report("the egg's rows 8-17", eggTint, eggWhite)
-- EggStatsScreen never calls LoadStatsScreenPals (stats_screen.asm:786)
ok(eggTint <= 1 and eggWhite >= 40, "the egg page stayed white")
shot("04-egg-white")
tap("b")
tap("up")
tap("up")
ok(setColor("dmg") == "dmg", "COLOR switched to DMG")
local dmg = openStats()
local dmgTint, dmgWhite = scan(frameOf(dmg), tintFor(dmg.page),
LOWER[1], LOWER[2], LOWER[3], LOWER[4])
report("DMG's rows 8-17", dmgTint, dmgWhite)
-- LoadStatsScreenPals opens `call CheckCGB / ret z` (color.asm:373-375)
ok(dmgTint <= 1 and dmgWhite >= 40, "DMG flattened the tint back to white")
shot("05-dmg-white")
tap("b")
ok(setColor("gbc") == "gbc", "COLOR is back on GBC for the handoff")
-- ---- what a reader is looking at -----------------------------------------
openStats()
U.wait(4)
if fails > 0 then
U.log(("%d check(s) failed above. do not judge the colours -- the run is"):
format(fails))
U.log("not showing you what it claims to.")
end
U.log("shots are in " .. out .. ". the summary is open on the pink page and")
U.log("left/right turn it: everything under the horizontal rule should be")
U.log("washed pink, then green, then cyan, matching whichever page square is")
U.log("the large one, while the pic, nickname and level stay white.")
U.log("a partial fix leaves white in one of three places -- an 8px stripe")
U.log("down the divider column, the bands above and below the HP bar's fill,")
U.log("or the exp bar's interior inside its black frame.")
while true do coroutine.yield() end
end
@@ -0,0 +1,338 @@
-- #1483: the TM/HM jingle under `verbosegiveitem`, and the item jingle beside it.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_tm_fanfare_bug1483_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-tm-fanfare \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- GiveItemScript is `waitsfx / specialsound / waitbutton`, the drain sitting
-- ABOVE the sound (engine/overworld/scripting.asm:441-449). Without it PlaySFX
-- drops SFX_GET_TM ($9b) under the beep the box rang on its own press,
-- SFX_READ_TEXT_2 ($08) -- while SFX_ITEM ($01) outranks that beep and survives.
--
-- No POKEPORT_SPEED here on purpose: audio runs on its own real-time
-- accumulator, so fast-forward slides the jingle off the press it belongs to,
-- and the ordering is the whole thing being judged.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Sound = require("src.core.Sound")
-- ../pokegold/maps/Route32.asm:866 -- Route32RoarTMGuyScript, `verbosegiveitem
-- TM_ROAR`, gated on nothing but its own EVENT_GOT_TM05_ROAR latch.
local TM_GIVER = { map = "ROUTE_32", x = 15, y = 13, item = "TM_ROAR" }
-- ../pokegold/maps/Route32Pokecenter1F.asm:109 -- the fishing guru,
-- `verbosegiveitem OLD_ROD`, one warp away and the control for the same gate.
local ITEM_GIVER =
{ map = "ROUTE_32_POKECENTER_1F", x = 1, y = 4, item = "OLD_ROD" }
-- side the player stands on, and the way they have to look from there
local SIDES = {
{ dx = -1, dy = 0, face = "right" },
{ dx = 1, dy = 0, face = "left" },
{ dx = 0, dy = -1, face = "down" },
{ dx = 0, dy = 1, face = "up" },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-tm-fanfare"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function stop()
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to listen to")
while true do coroutine.yield() end
end
local data = game.data
-- ---- everything that fails as silence --------------------------------------
local audio = data.audio or {}
local ids = {}
for i, name in ipairs(audio.sfxOrder or {}) do ids[name] = i - 1 end
for _, name in ipairs({ "Sfx_GetTm", "Sfx_Item", "Sfx_ReadText2" }) do
claim(ids[name] ~= nil and audio.sfx and audio.sfx[name] ~= nil,
("the cache can play %s (id %s)"):format(name, tostring(ids[name])))
end
-- The gate is a numeric comparison, so the whole bug only exists while these
-- three ids sit in this order (constants/sfx_constants.asm:4, :11).
claim((ids.Sfx_Item or 0) < (ids.Sfx_ReadText2 or 0)
and (ids.Sfx_ReadText2 or 0) < (ids.Sfx_GetTm or 0),
"and SFX_ITEM outranks the box beep, which outranks SFX_GET_TM")
claim(type(Sound.waitSfxDone) == "function",
"Sound.waitSfxDone exists for specialsound's `waitsfx` to call")
local vol = game.save.options and game.save.options.sfxVol
claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol)))
if vol == 0 then
U.log("SFX VOL is ZERO -- a muted run sounds exactly like the bug.")
U.log("turn it up in OPTION before trusting anything you hear here.")
end
for _, spot in ipairs({ TM_GIVER, ITEM_GIVER }) do
local def = data.items and data.items[spot.item]
claim(def ~= nil, ("the cache names the item %s"):format(spot.item))
spot.pocket = def and def.pocket
spot.index = def and def.index
spot.want = (spot.pocket == "TM_HM") and "Sfx_GetTm" or "Sfx_Item"
end
claim(TM_GIVER.pocket == "TM_HM",
("TM_ROAR sits in the TM_HM pocket (got %s)"):format(
tostring(TM_GIVER.pocket)))
claim(ITEM_GIVER.pocket ~= "TM_HM",
("OLD_ROD does not (got %s)"):format(tostring(ITEM_GIVER.pocket)))
-- ---- the recorder ----------------------------------------------------------
--
-- Sound.play returns nil when the priority gate DROPS the request, which is
-- the bug itself: the sound neither rings nor ducks the music.
local calls, frame, inSpecial = {}, 0, false
local realPlay = Sound.play
Sound.play = function(d, name)
local src = realPlay(d, name)
calls[#calls + 1] = {
name = Sound.resolve(d, name), frame = frame,
started = src ~= nil, special = inSpecial,
}
return src
end
local drains = 0
local realDrain = Sound.waitSfxDone
Sound.waitSfxDone = function()
if inSpecial then drains = drains + 1 end
return realDrain()
end
local realSpecial = world.specialSound
world.specialSound = function(self, itemIndex)
inSpecial = true
local ok, err = pcall(realSpecial, self, itemIndex)
inSpecial = false
if not ok then error(err) end
end
local function tap(button, gap)
table.insert(game.input.pressQueue, button)
game.input.state[button] = true
frame = frame + 1
coroutine.yield()
game.input.state[button] = false
for _ = 1, (gap or 12) do frame = frame + 1 coroutine.yield() end
end
local function walk(button, n)
for _ = 1, n do
table.insert(game.input.pressQueue, button)
game.input.state[button] = true
frame = frame + 1
coroutine.yield()
end
game.input.state[button] = false
U.wait(6)
end
-- The VM finishes its last `showRaw` and stops while the boxes it queued are
-- still on the stack, so "the script ended" is not "the conversation ended":
-- the stack has to be back where it was before A was ever pressed.
local baseDepth = #game.stack.states
local function idle()
return #game.stack.states <= baseDepth
and not world.vm:running() and not world:busy()
end
-- Press first, then look: at rest the Gold overworld holds nothing on the
-- stack at all, so a leading idle() check would return before the A that
-- starts the conversation was ever sent.
local function mash(limit, gap)
for _ = 1, (limit or 40) do
tap("a", gap)
if idle() then return true end
end
return idle()
end
local function clear(limit)
for _ = 1, (limit or 12) do
if idle() then return end
tap("b", 8)
end
end
-- ---- walk up to one giver and take what they hand over ---------------------
local function visit(spot, shots)
clear() -- nothing from the last giver may still be on the stack
world:applyPlayerState(FieldMoves.PLAYER_NORMAL)
-- Land on the map first so the collision under the giver is this map's.
world:setMap(spot.map, spot.x, spot.y + 1, "up")
U.wait(20)
world.noWildEncounters = true
-- A later map edit must degrade to a different side rather than park the
-- player in a wall, so the standing tile is picked from the live map.
local side
for _, s in ipairs(SIDES) do
local sx, sy = spot.x + s.dx, spot.y + s.dy
if not side and world.map:isWalkable(sx, sy)
and not world:npcAt(sx, sy) then
side = s
end
end
if not claim(side ~= nil,
("a free tile beside the giver at (%d,%d) on %s"):format(
spot.x, spot.y, spot.map)) then
return nil
end
-- Start two cells back and walk in, so the approach is the player's own.
local steps, sx, sy = 0, spot.x + side.dx, spot.y + side.dy
for n = 2, 3 do
local tx, ty = spot.x + side.dx * n, spot.y + side.dy * n
if steps == n - 2 and world.map:isWalkable(tx, ty)
and not world:npcAt(tx, ty) then
steps, sx, sy = n - 1, tx, ty
end
end
spot.side, spot.steps, spot.sx, spot.sy = side, steps, sx, sy
world:setMap(spot.map, sx, sy, side.face)
U.wait(20)
world.noWildEncounters = true
if steps > 0 then walk(side.face, 20 * steps) end
U.wait(10)
local npc = world:facingObject()
local key = npc and npc.def and npc.def.scriptKey
local script = key and world.vm.scripts and world.vm.scripts[key]
if not claim(type(script) == "table",
("%s has a script to run at (%d,%d)"):format(spot.map, spot.x, spot.y)) then
return nil
end
-- The give is behind a `checkevent` latch (EVENT_GOT_TM05_ROAR,
-- EVENT_GOT_OLD_ROD); those are the only flags either script reads.
local gives = nil
spot.events = {}
for _, row in ipairs(script) do
if type(row) == "table" then
if row.event then
world.events:set(row.event, false)
spot.events[#spot.events + 1] = row.event
end
if row.op == "verbosegiveitem" then gives = row.item end
end
end
claim(gives == spot.index,
("its `verbosegiveitem` hands over item %s, wanted %s (%s)"):format(
tostring(gives), tostring(spot.index), spot.item))
if gives ~= spot.index then return nil end
game.save.inventory = game.save.inventory or {}
game.save.inventory[spot.item] = nil
local first = #calls + 1
U.shot(game, shots[1])
-- Mash A the way a player does: through the intro line, the yes/no the
-- guru asks (YES is the resting cursor), the received line and the pocket
-- line. Nothing here waits for the jingle; that is the point.
local function rang()
for i = first, #calls do
if calls[i].name == spot.want and calls[i].special then return true end
end
return false
end
for _ = 1, 40 do
tap("a")
if rang() or idle() then break end
end
U.shot(game, shots[2]) -- the box the jingle is meant to be ringing under
local finished = mash(40)
claim(finished, ("the %s conversation ran to the end"):format(spot.item))
U.wait(30)
local got = (game.save.inventory[spot.item] or 0) > 0
claim(got, ("the %s reached the pack"):format(spot.item))
local heard, want, blipBefore = {}, nil, false
for i = first, #calls do
local c = calls[i]
heard[#heard + 1] = ("%s@%d%s"):format(c.name, c.frame,
c.started and "" or "(dropped)")
if c.name == spot.want and c.special then want = want or c end
if c.name == "Sfx_ReadText2" and c.started and not want then
blipBefore = true
end
end
U.log(("sfx across the %s hand-over: %s"):format(
spot.item, table.concat(heard, " ")))
return { want = want, blipBefore = blipBefore, heard = heard }
end
local tm = visit(TM_GIVER,
{ out .. "/01-tm-giver.png", out .. "/02-tm-jingle.png" })
if not tm then
U.log("could not reach the TM giver; stopping rather than faking it")
Sound.play = realPlay
Sound.waitSfxDone = realDrain
stop()
end
claim(tm.blipBefore,
"the box rang its own beep before the TM jingle was asked for")
claim(drains > 0, "specialsound drained the channels first, as `waitsfx` does")
claim(tm.want ~= nil, "Sfx_GetTm was asked for from specialsound at all")
claim(tm.want and tm.want.started,
"and the priority gate let it start instead of dropping it")
local item = visit(ITEM_GIVER,
{ out .. "/03-item-giver.png", out .. "/04-item-jingle.png" })
if item then
claim(item.want ~= nil, "Sfx_Item was asked for from specialsound too")
claim(item.want and item.want.started, "and it started, as it always did")
end
Sound.play = realPlay
Sound.waitSfxDone = realDrain
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("something above is FAIL, so do not spend time listening")
end
-- ---- the replay, live ------------------------------------------------------
--
-- EVENT_GOT_TM05_ROAR is the only latch the Roar guy reads, so clearing what
-- his own script checked puts the whole hand-over back.
clear()
for _, ev in ipairs(TM_GIVER.events or {}) do world.events:set(ev, false) end
game.save.inventory[TM_GIVER.item] = nil
world:setMap(TM_GIVER.map, TM_GIVER.sx, TM_GIVER.sy, TM_GIVER.side.face)
world.noWildEncounters = true
U.wait(30)
U.log("the Roar guy is a couple of steps away; the replay starts in three")
U.log("seconds and mashes A the whole way through.")
U.wait(180)
if TM_GIVER.steps > 0 then walk(TM_GIVER.side.face, 20 * TM_GIVER.steps) end
U.wait(10)
mash(40, 16)
U.wait(60)
U.log("what right sounds like: the route music cuts out on the received line")
U.log("and the TM jingle rings under it, then the music comes back.")
U.log("dead silence with the music still playing is the drop this fixes.")
U.log("expect the jingle to be cut short by the beep on the next box -- that")
U.log("is the missing trailing WaitSFX, not this fix failing.")
U.log("the guru in the Route 32 centre is the control: same beep, but his")
U.log("OLD ROD jingle rang before the fix as well.")
U.log("the controls are yours.")
while true do coroutine.yield() end
end
@@ -0,0 +1,213 @@
-- #1716: a whirlpool tile force-turns the player instead of being swum through.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-whirlpool \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- DoPlayerMovement's .CheckTile runs CheckWhirlpoolTile above the nybble ladder
-- and answers PLAYERMOVEMENT_FORCE_TURN (engine/overworld/player_movement.asm
-- :117-123), which is Script_ForcedMovement: step_dig 16, turn_in <back>,
-- step_dig 16, turn_head <back>, step_end (engine/events/forced_movement.asm
-- :25-51). turn_in `jp TurningStep`, i.e. InitStep under OBJECT_ACTION_SPIN --
-- a spinning one-cell step, not a facing change.
--
-- No POKEPORT_SPEED here on purpose: the spin, the step and the settle are what
-- is being watched, and fast-forward scales only the logic clock.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Movement = require("src.script.gen2.Movement")
local Mon = require("src.battle.gen2.Mon")
local Permissions = require("src.world.gen2.Permissions")
-- data/generated/maps.lua, ROUTE_41: four whirlpools, the first at (22,12) with
-- plain water above and below it. Verified live below, and re-derived by
-- scanning the map if a re-import ever moves it.
local MAP = "ROUTE_41"
local WHIRL = { x = 22, y = 12 }
local DELTA = {
up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-whirlpool"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to look at")
while true do coroutine.yield() end
end
-- ---- the things a human's eyes cannot check ------------------------------
--
-- All of these fail silently and look exactly like "the fix did nothing".
claim(Movement.decodeByte(0x24).kind == "step",
"$24 turn_in decodes as a step, not a facing change")
claim(Movement.decodeByte(0x24).spin == true,
"and it carries OBJECT_ACTION_SPIN")
claim(Movement.STEP_DIG == 0x4f and Movement.STEP_DIG_FRAMES == 16,
"$4f step_dig is modelled, 16 frames")
claim(type(world.runForcedMovement) == "function",
"World:runForcedMovement exists for .CheckTile to call")
claim(type(world.player.scriptSpin) == "function",
"Player:scriptSpin exists for step_dig to drive")
-- SURF but deliberately NOT WHIRLPOOL, and no GLACIERBADGE: TryWhirlpoolOW
-- must never get a look in, or what we would be watching is the prompt.
local badges = game.save.player.badges or {}
game.save.player.badges = badges
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = nil end
badges[FieldMoves.BADGE.SURF] = true
local swimmer = Mon.new(game.data, "LAPRAS", 30,
{ moves = { { id = "SURF" } } })
claim(swimmer ~= nil, "a LAPRAS that knows SURF and nothing else")
game.save.party = { swimmer }
claim(not FieldMoves.hasBadge(game.save, FieldMoves.BADGE.WHIRLPOOL),
"no GLACIERBADGE, so no whirlpool prompt can fire")
-- ---- find the whirlpool --------------------------------------------------
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
U.wait(10)
world.noWildEncounters = true
local map = world.map
local function isWhirl(x, y)
return map:inBounds(x, y) and Permissions.isWhirlpool(map:cellCollision(x, y))
end
local function isOpenWater(x, y)
return map:inBounds(x, y)
and Permissions.isWater(map:cellCollision(x, y))
and not Permissions.isWhirlpool(map:cellCollision(x, y))
end
local target, approach = nil, nil
if isWhirl(WHIRL.x, WHIRL.y) then target = { x = WHIRL.x, y = WHIRL.y } end
if not target then
-- A re-import moved it; take any whirlpool on the map instead of parking
-- the player on open sea with nothing to look at.
local def = world.maps[MAP]
for y = 0, (def.height or 0) * 2 - 1 do
for x = 0, (def.width or 0) * 2 - 1 do
if not target and isWhirl(x, y) then target = { x = x, y = y } end
end
end
end
if target then
for _, dir in ipairs({ "down", "up", "right", "left" }) do
local d = DELTA[dir]
if not approach and isOpenWater(target.x - d[1], target.y - d[2]) then
approach = { x = target.x - d[1], y = target.y - d[2], dir = dir }
end
end
end
claim(target ~= nil, ("%s still has a whirlpool tile on it"):format(MAP))
claim(approach ~= nil, "with open water beside it to swim in from")
if not (target and approach) then
for _, line in ipairs(lines) do U.log(line) end
U.log("nothing to drive; stopping here rather than faking the moment")
while true do coroutine.yield() end
end
if target.x ~= WHIRL.x or target.y ~= WHIRL.y then
U.log(("note: using the whirlpool at (%d,%d), not the (%d,%d) in the header")
:format(target.x, target.y, WHIRL.x, WHIRL.y))
end
-- ---- swim into it --------------------------------------------------------
world:setMap(MAP, approach.x, approach.y, approach.dir)
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world.noWildEncounters = true
U.wait(20)
claim(FieldMoves.isSurfing(world.playerState), "on the water, on the Lapras")
U.shot(game, out .. "/01-before.png")
local p = world.player
local d = DELTA[approach.dir]
local beyond = { x = target.x + d[1], y = target.y + d[2] }
local seen = { onWhirl = false, spun = false, busy = false, past = false }
local shot = {}
local function press(dir)
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
for _ = 1, 300 do
press(approach.dir)
if p.cellX == target.x and p.cellY == target.y then
seen.onWhirl = true
end
if p.spinFrames then seen.spun = true end
if world:busy() then seen.busy = true end
if p.cellX == beyond.x and p.cellY == beyond.y then seen.past = true end
if not shot.spin and seen.onWhirl and p.spinFrames then
shot.spin = true
game.input.state[approach.dir] = false
U.shot(game, out .. "/02-whirling.png")
end
if shot.spin and not shot.back
and (p.cellX ~= target.x or p.cellY ~= target.y) then
shot.back = true
game.input.state[approach.dir] = false
U.shot(game, out .. "/03-thrown-back.png")
end
end
game.input.state[approach.dir] = false
-- Let whatever is still running settle before reading the resting facing.
for _ = 1, 180 do
if not world:busy() and not p.moving then break end
coroutine.yield()
end
U.shot(game, out .. "/04-settled.png")
local BACK = { up = "down", down = "up", left = "right", right = "left" }
claim(seen.onWhirl,
("the step onto the whirlpool at (%d,%d) happened -- the cart allows it")
:format(target.x, target.y))
claim(seen.busy, "the forced movement took the world off the d-pad")
claim(seen.spun, "step_dig put the player under OBJECT_ACTION_SPIN")
claim(not seen.past,
("300 frames of %s never reached (%d,%d) on the far side")
:format(approach.dir:upper(), beyond.x, beyond.y))
claim(p.cellX == approach.x and p.cellY == approach.y,
("the player was dragged back to (%d,%d), and is at (%d,%d)")
:format(approach.x, approach.y, p.cellX, p.cellY))
claim(p.facing == BACK[approach.dir],
("turn_head settled the facing to %s, and it is %s")
:format(BACK[approach.dir], tostring(p.facing)))
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("something above is FAIL, so do not spend time watching the replay")
end
-- ---- the replay, live ----------------------------------------------------
U.log("shots are in " .. out .. "; 02 is the whirl, 03 the drag back out.")
U.log("the replay starts in three seconds and runs on its own.")
world:setMap(MAP, approach.x, approach.y, approach.dir)
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world.noWildEncounters = true
U.wait(180)
for _ = 1, 200 do press(approach.dir) end
game.input.state[approach.dir] = false
U.log("the Lapras swims one cell into the whirlpool, whirls on the spot,")
U.log("is dragged back out still whirling, and stops facing away from it.")
U.log("a bounce with no whirl means scriptSpin never fired; a whirl that")
U.log("never leaves the cell means $24 is still decoding as a turn.")
U.log("the controls are yours -- the d-pad cannot cross that cell.")
while true do coroutine.yield() end
end
@@ -0,0 +1,240 @@
-- #1717: the surf wash PlayWhirlpoolSound rings as a whirlpool drains.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_whirlpool_sound_bug1717_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-whirlpool-sfx \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- PlayWhirlpoolSound is WaitSFX / PlaySFX SFX_SURF / WaitSFX
-- (engine/events/field_moves.asm:5-10), never a bare PlaySFX. The LEADING wait
-- is what makes it audible: SFX_SURF is $53 and PlaySFX drops any id above the
-- sound still on ch5-ch8 (home/audio.asm), so the beep that dismissed the text
-- box swallowed it. The TRAILING wait is why the water is still washing when
-- DisappearWhirlpool hands back, above `closetext`.
--
-- No POKEPORT_SPEED here on purpose: audio runs on its own real-time
-- accumulator, so fast-forward slides the sound off the moment it belongs to,
-- and the ordering is the whole thing being judged.
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Mon = require("src.battle.gen2.Mon")
local Permissions = require("src.world.gen2.Permissions")
local Sound = require("src.core.Sound")
-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30) and
-- (28,48). Stand one cell north of the first and face it.
local MAP = "ROUTE_41"
local WHIRL = { x = 22, y = 12 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-whirlpool-sfx"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to listen to")
while true do coroutine.yield() end
end
-- ---- everything that fails as silence ------------------------------------
local audio = game.data.audio or {}
local hasLabel = false
for _, name in ipairs(audio.sfxOrder or {}) do
if name == "Sfx_Surf" then hasLabel = true end
end
claim(hasLabel, "the cache knows the sfx label Sfx_Surf")
claim(audio.sfx and audio.sfx.Sfx_Surf ~= nil,
"and it has a loadable definition for it")
claim(type(world.playWhirlpoolSound) == "function",
"World:playWhirlpoolSound exists for DisappearWhirlpool to call")
local vol = game.save.options and game.save.options.sfxVol
claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol)))
if vol == 0 then
U.log("SFX VOL is ZERO -- a muted run sounds exactly like the bug.")
U.log("turn it up in OPTION before trusting anything you hear here.")
end
-- SURF to get there and WHIRLPOOL to use, with the badges TryWhirlpoolOW
-- gates on: the gate has its own checks in tests/gen2_world_test.lua, and
-- what is under test here is the sound, not the refusal.
local badges = game.save.player.badges or {}
game.save.player.badges = badges
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end
local swimmer = Mon.new(game.data, "LAPRAS", 30,
{ moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } })
claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL")
game.save.party = { swimmer }
-- ---- stand on the water facing the whirlpool -----------------------------
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
U.wait(15)
world.noWildEncounters = true
local ctx = world:fieldContext()
local facingWhirl = Permissions.isWhirlpool(ctx.facingColl)
if not facingWhirl then
-- A re-import moved it: take any whirlpool with open water above it rather
-- than ringing a sound at a patch of empty sea.
local def = world.maps[MAP]
for y = 1, (def.height or 0) * 2 - 1 do
for x = 0, (def.width or 0) * 2 - 1 do
if not facingWhirl
and Permissions.isWhirlpool(world.map:cellCollision(x, y))
and Permissions.isWater(world.map:cellCollision(x, y - 1)) then
WHIRL.x, WHIRL.y = x, y
world:setMap(MAP, x, y - 1, "down")
world:applyPlayerState(FieldMoves.PLAYER_SURF)
U.wait(10)
ctx = world:fieldContext()
facingWhirl = Permissions.isWhirlpool(ctx.facingColl)
U.log(("note: using the whirlpool at (%d,%d)"):format(x, y))
end
end
end
end
claim(facingWhirl,
("facing the whirlpool at (%d,%d) on %s"):format(WHIRL.x, WHIRL.y, MAP))
local blocks = world.maps[MAP] and world.maps[MAP].blocks
local index = ctx.facingBlockIndex
local before = blocks and index and blocks[index]
claim(before ~= nil, "and the block behind it is readable")
if not (facingWhirl and before) then
for _, line in ipairs(lines) do U.log(line) end
U.log("nothing to drive; stopping here rather than faking the moment")
while true do coroutine.yield() end
end
U.shot(game, out .. "/01-facing.png")
-- ---- record every sfx the drain rings ------------------------------------
--
-- Sound.play returns nil when the priority gate DROPS the request, which is
-- the bug itself: the sound neither sounds nor ducks the music.
local calls, frame = {}, 0
local realPlay = Sound.play
Sound.play = function(data, name)
local src = realPlay(data, name)
calls[#calls + 1] = {
name = name, frame = frame, started = src ~= nil,
phase = world.fieldMove and world.fieldMove.phase,
}
return src
end
local function tap(button, gap)
table.insert(game.input.pressQueue, button)
game.input.state[button] = true
frame = frame + 1
coroutine.yield()
game.input.state[button] = false
for _ = 1, (gap or 8) do frame = frame + 1 coroutine.yield() end
end
-- Press A into the whirlpool, then answer the ask box. YES is the default
-- cursor, so A all the way down is what a player does.
local drainedAt, closedAt = nil, nil
for _ = 1, 24 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end
if blocks[index] ~= before and not closedAt then closedAt = frame end
tap("a")
end
if blocks[index] ~= before and not closedAt then closedAt = frame end
drainedAt = frame
-- From here on nothing is pressed: the trailing WaitSFX is supposed to hold
-- the world by itself.
local busyAfter, phaseFrames = 0, 0
for _ = 1, 300 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then
phaseFrames = phaseFrames + 1
end
if world:busy() then busyAfter = busyAfter + 1 else break end
frame = frame + 1
coroutine.yield()
end
Sound.play = realPlay
U.shot(game, out .. "/02-cleared.png")
-- ---- what was rung -------------------------------------------------------
local surf, surfStarted, surfInPhase = nil, false, false
local heard = {}
for _, c in ipairs(calls) do
heard[#heard + 1] = ("%s@%d%s"):format(c.name, c.frame,
c.started and "" or "(dropped)")
if c.name == "Sfx_Surf" then
surf = c
surfStarted = surfStarted or c.started
surfInPhase = surfInPhase or c.phase == "whirlpoolsfx"
end
end
claim(blocks[index] ~= before,
"the whirlpool block was replaced, so the drain really ran")
claim(surf ~= nil, "Sfx_Surf was asked for at all")
claim(surfStarted,
"and the priority gate let it start instead of dropping it")
claim(surfInPhase,
"it was rung from PlayWhirlpoolSound, not from somewhere else")
claim(closedAt == nil or (surf and surf.frame >= closedAt),
"it came after the text box took its button, not before")
claim(busyAfter >= 1,
("the world stayed shut for %d frames after the box closed"):format(
busyAfter))
-- The phase counts down from 180 whatever happens, so 180 flat is the cap
-- firing rather than the sound ending.
claim(phaseFrames > 0 and phaseFrames < 180,
("the wash ended on its own after %d frames, inside the 180-frame cap")
:format(phaseFrames))
if phaseFrames >= 180 then
U.log("the 180-frame cap is doing the work: Sound.sfxBusy never cleared,")
U.log("so check curSfx bookkeeping rather than raising the cap.")
elseif phaseFrames > 165 then
U.log(("note: only %d frames of margin under the cap"):format(
180 - phaseFrames))
end
for _, line in ipairs(lines) do U.log(line) end
U.log("sfx rung across the drain: " .. table.concat(heard, " "))
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("something above is FAIL, so do not spend time listening")
end
-- ---- the replay, live ----------------------------------------------------
--
-- LoadMapAttributes refills the block buffer from ROM, so the whirlpool is
-- back on the next map load and the whole thing can be run again by ear.
world:setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(10)
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
world.noWildEncounters = true
U.wait(30)
U.log("the whirlpool is back; the replay starts in three seconds.")
U.wait(180)
for _ = 1, 24 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end
tap("a", 14)
end
for _ = 1, 240 do
if not world:busy() then break end
coroutine.yield()
end
U.log("what right sounds like: the box takes its A-press beep, then the same")
U.log("wave wash SURF plays when you get on the water, and control only comes")
U.log("back once the wash has finished.")
U.log("silence with the block gone is the drop; a wash that starts under the")
U.log("beep, or control back in the same frame, is the wrong half fixed.")
U.log("the controls are yours -- the other whirlpools are further south.")
while true do coroutine.yield() end
end
@@ -0,0 +1,211 @@
-- Manual check of the Pewter museum clerk's back-entrance branches (#1690).
-- scripts/Museum1F.asm:45 reads wYCoord/wXCoord before the ticket flag, so
-- (13,4) and (12,3) behind the counter get the AMBER question and row 4 in
-- front of it gets the ¥50 ask. This walks all three by itself.
-- Do not add POKEPORT_SPEED: fast-forward scales only the logic clock, and
-- what is being judged here is page order and the choice box timing.
-- POKEPORT_DRIVER=tests/drivers/museum_back_entrance_bug1690_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red POKEPORT_IDENTITY=bug1690 SHOT_DIR=/tmp/museum1690 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "MUSEUM_1F"
local CLERK_TEXT = "TEXT_MUSEUM1F_SCIENTIST1"
-- pokered data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 at (12,4),
-- counter column x==11, back door warps in at (16,7)/(17,7)
local BEHIND_EAST = { x = 13, y = 4, facing = "left" }
local BEHIND_NORTH = { x = 12, y = 3, facing = "down" }
local IN_FRONT = { x = 10, y = 4, facing = "right" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- every one of these failing looks exactly like the bug on screen: a
-- missing string, a renamed key or an unwalkable cell all end in silence
local t = game.data.text or {}
for _, key in ipairs({
"_Museum1FScientist1DoYouKnowWhatAmberIsText",
"_Museum1FScientist1TheresALabSomewhereText",
"_Museum1FScientist1AmberIsFossilizedTreeSapText",
"_Museum1FScientist1GoToOtherSideText",
"_Museum1FScientist1WouldYouLikeToComeInText",
}) do
check(key .. " is in the text catalog",
type(t[key]) == "string" and t[key] ~= "")
end
local function clerkIn(ow)
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.text == CLERK_TEXT then return n end
end
return nil
end
local function boxText(box)
local shown = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
return table.concat(shown, " / ")
end
-- talking works either straight at the clerk or across the counter tile
local function facingTheClerk()
local ow = game.overworld
local clerk = clerkIn(ow)
if not clerk then return false end
local fx, fy = ow.player:facingCell()
if ow:npcAtCell(fx, fy) == clerk then return true end
if ow.map:isCounterCell(fx, fy) then
local Collision = require("src.world.Collision")
local bx, by = Collision.target(fx, fy, ow.player.facing)
return ow:npcAtCell(bx, by) == clerk
end
return false
end
-- teleport to `spot`, and if a map edit moved the counter fall back to any
-- free walkable neighbour of the clerk so this degrades instead of parking
-- the player at a wall
local function standAt(spot, label)
U.teleport(game, MAP, spot.x, spot.y, spot.facing)
U.wait(10)
if facingTheClerk() then return check(label .. ": in position", true) end
local ow = game.overworld
local clerk = clerkIn(ow)
if clerk then
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = clerk.cellX + s[1], clerk.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d,%d) does not reach the clerk, standing on")
:format(spot.x, spot.y), cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
return check(label .. ": in position", facingTheClerk())
end
-- press A and wait for the dialogue box to actually mount
local function talk()
U.tap(game, "a")
for _ = 1, 60 do
local top = game.stack:top()
if getmetatable(top) == TextBox then return top end
U.wait(1)
end
return nil
end
-- page through until the YES/NO box pops (the clerk's question is two
-- pages, so one A press sits between the box opening and the choice)
local function toChoice()
for _ = 1, 20 do
if getmetatable(game.stack:top()) == ChoiceBox then
return game.stack:top()
end
U.tap(game, "a")
U.wait(20)
end
return getmetatable(game.stack:top()) == ChoiceBox and game.stack:top() or nil
end
-- ChoiceBox starts on YES; NO is one step down
local function answer(yes)
if not yes then U.tap(game, "down") U.wait(10) end
U.tap(game, "a")
for _ = 1, 60 do
local top = game.stack:top()
if getmetatable(top) == TextBox then return top end
U.wait(1)
end
return nil
end
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil
standAt(BEHIND_EAST, "east of the clerk (13,4)")
local box = talk()
if check("A behind the counter opens a box", box ~= nil) then
U.log("box reads:", boxText(box))
check("it is the can't-sneak-in-the-back-way line",
boxText(box):find("sneak", 1, true) ~= nil)
check("no money window rides along with it", box.money == nil)
U.shot(game, SHOT_DIR .. "/bug1690_a_sneak.png")
end
local choice = toChoice()
if check("the AMBER question offers YES/NO", choice ~= nil) then
U.shot(game, SHOT_DIR .. "/bug1690_b_amber_choice.png")
local answered = answer(true)
if check("YES opens a follow-up box", answered ~= nil) then
U.log("YES reads:", boxText(answered))
check("YES is the resurrection lab line",
boxText(answered):find("lab", 1, true) ~= nil)
U.shot(game, SHOT_DIR .. "/bug1690_c_lab.png")
end
end
-- with the ticket already bought the coordinate check still wins: a
-- ticket holder round the back is told off, not thanked (asm:45 runs
-- before the CheckEvent at asm:59)
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
standAt(BEHIND_NORTH, "north of the clerk (12,3), ticket in hand")
box = talk()
if check("A north of the clerk opens a box", box ~= nil) then
U.log("box reads:", boxText(box))
check("a ticket holder round the back still gets told off",
boxText(box):find("sneak", 1, true) ~= nil)
U.shot(game, SHOT_DIR .. "/bug1690_d_ticket_holder_sneak.png")
end
choice = toChoice()
if check("it still offers YES/NO", choice ~= nil) then
local answered = answer(false)
if check("NO opens a follow-up box", answered ~= nil) then
U.log("NO reads:", boxText(answered))
check("NO is the fossilized tree sap line",
boxText(answered):find("tree sap", 1, true) ~= nil)
U.shot(game, SHOT_DIR .. "/bug1690_e_tree_sap.png")
end
end
-- the control case: the public side of the counter is untouched
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil
game.save.money = 3000
standAt(IN_FRONT, "in front of the counter (10,4)")
box = talk()
if check("A across the counter opens a box", box ~= nil) then
U.log("box reads:", boxText(box))
check("the front of the counter is still the ¥50 ticket ask",
boxText(box):find("50", 1, true) ~= nil)
check("and it still raises the money window", box.money ~= nil)
U.shot(game, SHOT_DIR .. "/bug1690_f_ticket_ask.png")
end
U.log("shots are in " .. SHOT_DIR .. ", in the order they were taken.")
U.log("behind the counter the clerk should say \"You can't sneak in the back")
U.log("way!\", then \"Oh, whatever! Do you know what AMBER is?\" with YES/NO,")
U.log("and never a money window. YES talks about a lab resurrecting ancient")
U.log("POKeMON, NO says AMBER is fossilized tree sap. The box left on screen")
U.log("now is the last case: standing at (10,4) with no ticket, which must")
U.log("still be the ¥50 ask with the money window in the corner.")
U.log("the near misses to watch for: a money window sitting behind the AMBER")
U.log("question, or \"Take your time, and enjoy it all!\" at (12,3) once the")
U.log("ticket flag is set, which would mean the ticket check ran first.")
U.log("Yellow shares this script, so POKEPORT_VERSION=yellow should read")
U.log("exactly the same at all three spots.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,303 @@
-- Talks to all six pet Pokemon for you and lets each one cry: #1687
-- (S.S. Anne WIGGLYTUFF + MACHOKE) and #1649 (Vermilion PIDGEY, Vermilion
-- City MACHOP, Fan Club PIKACHU + SEEL). pokered/scripts/*.asm, e.g.
-- VermilionPidgeyHouse.asm:15 text_far then PlayCry at :19. Never add
-- POKEPORT_SPEED: only the logic clock scales, so the cry lands in the
-- wrong place relative to the typing this run exists to judge.
-- POKEPORT_DRIVER=tests/drivers/pet_cries_bug1687_1649_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red POKEPORT_IDENTITY=petcries SHOT_DIR=/tmp/petcries love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local Runtime = require("src.mods.Runtime")
local GameVersion = require("src.core.GameVersion")
local init = require("data.scripts.init")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local yellow = GameVersion.isYellow()
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- own tick counter: the ordering claim is "cry after the last character",
-- so the cry and the end of typing have to be stamped on the same clock
local ticks = 0
local function step() ticks = ticks + 1; coroutine.yield() end
local function waitTicks(n) for _ = 1, n do step() end end
local function tap(btn) U.tap(game, btn); ticks = ticks + 1 end
-- sound.played is the engine's own cue feed (src/core/Sound.lua played()),
-- which is the only way a script can tell "no cry" from "a cry nobody
-- could hear"; both sound identical on a quiet machine.
local cries = {}
local heard = false
if type(Runtime.events.on) == "function" then
Runtime.events:on("sound.played", function(p)
if p and p.kind == "cry" then
heard = true
cries[#cries + 1] = { name = p.name, species = p.species, tick = ticks }
end
end, 0, "driver")
end
-- pokered/data/maps/objects, all cell coordinates: VermilionPidgeyHouse
-- PIDGEY (3,5), VermilionCity MACHOP (29,9), PokemonFanClub PIKACHU (6,4)
-- and SEEL (1,4), SSAnne1FRooms WIGGLYTUFF (3,11), SSAnneB1FRooms MACHOKE
-- (11,12). The two S.S. Anne rooms maps slide their objects between
-- cabins at load (src/world/SsAnneLayout.lua), so every position below is
-- read back off the live map instead of trusted from the list.
local PETS = {
{ map = "VERMILION_PIDGEY_HOUSE", object = "VERMILIONPIDGEYHOUSE_PIDGEY",
const = "TEXT_VERMILIONPIDGEYHOUSE_PIDGEY", species = "PIDGEY",
label = "_VermilionPidgeyHousePidgeyText",
control = "VERMILIONPIDGEYHOUSE_YOUNGSTER" },
{ map = "SS_ANNE_1F_ROOMS", object = "SSANNE1FROOMS_WIGGLYTUFF",
const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF",
label = "_SSAnne1FRoomsWigglytuffText",
control = "SSANNE1FROOMS_LITTLE_GIRL" },
{ map = "SS_ANNE_B1F_ROOMS", object = "SSANNEB1FROOMS_MACHOKE",
const = "TEXT_SSANNEB1FROOMS_MACHOKE", species = "MACHOKE",
label = "_SSAnneB1FRoomsMachokeText" },
{ map = "POKEMON_FAN_CLUB", object = "POKEMONFANCLUB_SEEL",
const = "TEXT_POKEMONFANCLUB_SEEL", species = "SEEL",
label = "_PokemonFanClubSeelText" },
-- two boxes, cry on the first (VermilionCity.asm:224, PlayCry at :228)
{ map = "VERMILION_CITY", object = "VERMILIONCITY_MACHOP",
const = "TEXT_VERMILIONCITY_MACHOP", species = "MACHOP",
label = "_VermilionCityMachopText", extraBoxes = 1 },
}
if yellow then
-- pokeyellow/data/maps/objects/PokemonFanClub.asm: the pet is a CLEFAIRY
PETS[#PETS + 1] = { map = "POKEMON_FAN_CLUB",
object = "POKEMONFANCLUB_CLEFAIRY",
const = "TEXT_POKEMONFANCLUB_CLEFAIRY", species = "CLEFAIRY",
label = "_PokemonFanClubClefairyText" }
else
PETS[#PETS + 1] = { map = "POKEMON_FAN_CLUB",
object = "POKEMONFANCLUB_PIKACHU",
const = "TEXT_POKEMONFANCLUB_PIKACHU", species = "PIKACHU",
label = "_PokemonFanClubPikachuText" }
end
-- ---------------------------------------------------------------- checks
-- Everything below produces the same silence the bug did, so it is worth
-- knowing before anyone bothers listening.
local vol = game.save and game.save.options and game.save.options.sfxVol
U.log("sfx volume is", tostring(vol), "of 7")
if not vol or vol == 0 then
check("sfx volume is above zero (a muted run sounds exactly like the bug)",
false)
end
if yellow and game.save.options.pikaVol == 0 then
U.log("pikaVol is 0: Yellow's Pikachu clips are muted independently")
end
for _, pet in ipairs(PETS) do
local tag = pet.species .. " on " .. pet.map
local script = init.talkScript(pet.map, pet.const)
if check(tag .. " has a ported talk script", type(script) == "table") then
local cryAt
for i, row in ipairs(script) do
if type(row) == "table" and row[1] == "play_cry" then cryAt = i end
end
if check(tag .. " has a play_cry row", cryAt ~= nil) then
check(tag .. " cries " .. pet.species, script[cryAt][2] == pet.species)
check(tag .. " uses the waitForButton form", script[cryAt][3] == true)
local next_ = script[cryAt + 1]
check(tag .. " arms " .. pet.label .. " on the very next row",
type(next_) == "table" and next_[1] == "show_text"
and next_[2] == pet.label)
end
end
check(tag .. " has a cry program in this cache",
game.data.audio.cries and game.data.audio.cries[pet.species] ~= nil)
check(pet.label .. " resolves to text",
type(game.data.text[pet.label]) == "string")
end
-- Sound.playCry sends PIKACHU to the PCM clips whenever the cache carries
-- them; that voiced "Pikachuuu" is Yellow's, and hearing it in Red is the
-- failure #1649 warned about up front.
if not yellow then
check("this cache has no Pikachu voice clips (Red must get the chip cry)",
game.data.audio.pikaCries == nil)
end
if failures > 0 then
U.log(failures, "checks failed above, so do not bother listening yet;")
U.log("a missing row or an unresolved label sounds exactly like the bug.")
end
-- ---------------------------------------------------------------- walking
local function findNpc(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- {dx, dy, facing}: the offset from the pet to the cell to stand on plus
-- the direction that looks back at it, so +1 on y means stand below.
local SIDES = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
local function approach(ow, npc)
for _, s in ipairs(SIDES) do
local cx, cy = npc.cellX + s[1], npc.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
return cx, cy, s[3]
end
end
return nil
end
-- PIDGEY and MACHOP are WALK objects and drift off their spawn cell, so
-- pin them once the final teleport has rebuilt the npc list.
local function pin(npc)
npc.wanders = false
npc:resetToSpawn()
npc.wanders = false
end
local function standAt(mapId, objName)
local def
for _, o in ipairs((game.data.maps[mapId] or {}).objects or {}) do
if o.name == objName then def = o end
end
if not def then return nil end
-- first hop just mounts the map so the live cells can be read
U.teleport(game, mapId, def.x, math.max(0, def.y + 1), "up")
waitTicks(6)
local ow = game.overworld
local npc = ow and findNpc(ow, objName)
if not npc then return nil end
pin(npc)
local cx, cy, facing = approach(ow, npc)
if not cx then return nil end
U.teleport(game, mapId, cx, cy, facing)
waitTicks(6)
ow = game.overworld
npc = findNpc(ow, objName)
if not npc then return nil end
pin(npc)
local fx, fy = ow.player:facingCell()
if ow:npcAtCell(fx, fy) ~= npc then return nil end
return npc
end
-- Opens the box, holds still while it types and cries, then reports where
-- the cry landed against the last typed character.
local function talk(name)
local before = #cries
tap("a")
local box, armed, doneTick
for _ = 1, 300 do
local top = game.stack:top()
if getmetatable(top) == TextBox then
box = top
-- box.auto is cleared the moment the cry finishes, so snapshot it
if armed == nil then armed = top.auto or false end
if top.done and not doneTick then doneTick = ticks end
end
if doneTick and #cries > before then break end
step()
end
if not check(name .. ": pressing A opened a text box", box ~= nil) then
return nil
end
local lines = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
U.log("box reads:", table.concat(lines, " / "))
return box, armed, doneTick, before
end
U.log("about to walk to each pet and talk to it; turn the volume up now.")
waitTicks(180)
for _, pet in ipairs(PETS) do
local tag = pet.species .. " on " .. pet.map
U.log("walking to the", pet.species)
local npc = standAt(pet.map, pet.object)
if not check(tag .. ": standing face to face with it", npc ~= nil) then
U.log("skipping", pet.species, "-- could not reach it")
else
local box, armed, doneTick, before = talk(tag)
if box then
-- the dropped `true` shows up here: without it the box pops itself
-- when the cry ends and never blinks its arrow
check(tag .. ": the box was armed with a cry",
type(armed) == "table" and type(armed.sound) == "function")
check(tag .. ": the cry keeps the A/B wait (waitForButton)",
type(armed) == "table" and armed.wait == true)
local fired = cries[before + 1]
if check(tag .. ": a cry actually played", fired ~= nil) then
check(tag .. ": the cry was " .. pet.species,
fired.species == pet.species)
-- near miss: a cry that fires as the line starts typing
check(tag .. ": it played after the last character, not before",
doneTick ~= nil and fired.tick >= doneTick)
if pet.species == "PIKACHU" and not yellow then
check("PIKACHU used the chip cry, not Yellow's voice clip",
fired.name == "PIKACHU")
end
check(tag .. ": exactly one cry for this box",
#cries == before + 1)
end
waitTicks(90)
U.shot(game, SHOT_DIR .. "/pet_cry_" .. pet.species:lower() .. ".png")
check(tag .. ": the box is still up waiting for A",
getmetatable(game.stack:top()) == TextBox)
for _ = 1, (pet.extraBoxes or 0) + 1 do
tap("a")
waitTicks(40)
end
if pet.extraBoxes then
U.log("the MACHOP's second box is the stomping line; it has no cry")
end
waitTicks(30)
end
end
-- a neighbour that is meant to stay silent, so a run with no audio
-- device at all cannot pass itself off as a working cry
if pet.control then
local silent = standAt(pet.map, pet.control)
if silent then
local mark = #cries
talk(pet.control .. " (control)")
check(pet.control .. ": the silent neighbour played no cry",
#cries == mark)
tap("a")
waitTicks(30)
tap("a")
waitTicks(20)
end
end
end
-- ---------------------------------------------------------------- verdict
U.log("talked to all", #PETS, "pets;", #cries, "cries fired.")
if not heard then
U.log("no cry reached the mixer at all, which is the #1687 / #1649 bug")
end
U.log(failures == 0 and "all checks passed" or (failures .. " checks FAILED"))
U.log("what you should have heard, once per pet: the box types the line,")
U.log("then a short cry, and only then the down arrow starts blinking.")
U.log("a cry that starts while the line is still typing is wrong, and so")
U.log("is a box that closes itself when the cry ends instead of waiting.")
U.log("in Red the Fan Club PIKACHU is a short chip cry; the long voiced")
U.log("\"Pikachuuu\" belongs to Yellow, where the pet is a CLEFAIRY.")
U.log("re-run with POKEPORT_VERSION=blue and POKEPORT_VERSION=yellow.")
U.log("screenshots are in", SHOT_DIR)
-- parked in front of the last pet, so another A press repeats its cry
while true do
coroutine.yield()
end
end
@@ -90,11 +90,11 @@ return function(game)
if r[2] == "SS_Anne_Horn" then horns = horns + 1 end
end
check("the departure blows the horn twice", horns == 2)
local slide = 0
for _, w in ipairs(rowsOfKind(dockRows, "wait")) do
if w[2] == 20 then slide = slide + 1 end
end
check("she sails in eight beats, not one frame", slide == 8)
check("she sails as one blocking slide, not a block shuffle",
#rowsOfKind(dockRows, "ss_anne_departs") == 1
and #rowsOfKind(dockRows, "replace_block") == 0)
check("he keeps facing down until he walks out",
(rowsOfKind(dockRows, "face_player_dir")[1] or {})[2] == "down")
check("no keepMusic override into the city", (function()
for _, r in ipairs(rowsOfKind(dockRows, "play_music")) do
if r[3] and r[3].keep then return false end
@@ -130,10 +130,12 @@ return function(game)
and game.save.flags.EVENT_SS_ANNE_LEFT == nil
and game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL == nil)
U.log("Watch for: she idles a couple of seconds with smoke off the funnel,")
U.log("one horn, then travels WEST a block at a time with the water closing")
U.log("in behind her, a second horn once she is gone, and the surf loop")
U.log("gives way to the Vermilion theme the moment you cross into town.")
U.log("Watch for: he turns to face DOWN and stays that way, she idles two")
U.log("seconds, one horn, then she slides WEST smoothly for about seventeen")
U.log("seconds with white smoke puffing off the front funnel and drifting")
U.log("east, the dock row he stands on never moving; a second horn once she")
U.log("is gone, the gangway stub still under his feet, then he walks up and")
U.log("the surf loop gives way to the Vermilion theme as you cross in.")
U.teleport(game, DOCK, DOCK_CELL.x, DOCK_CELL.y, "up")
local ow = game.overworld
+404
View File
@@ -0,0 +1,404 @@
-- Driver: Wrap's animation length and the blank tile-load beat that opens every
-- subanimation row (#1653). PlayAnimation calls LoadMoveAnimationTiles once per
-- row (engine/battle/animations.asm:252) and CopyVideoData blocks c/8 + 1 frames
-- (home/copy2.asm:62), so WrapAnim's three rows (data/moves/animations.asm:401)
-- cost 3 x 10 blank frames on top of the 30 the port already played.
-- No POKEPORT_SPEED: it scales only the logic clock while audio runs real-time,
-- which desyncs the sfx-against-pulse ordering this driver exists to judge.
-- POKEPORT_DRIVER=tests/drivers/wrap_anim_bug1653_test.lua POKEPORT_VERSION=red POKEPORT_TOUCH=0 POKEPORT_IDENTITY=bug1653 SHOT_DIR=/tmp/wrap love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local AnimPlayer = require("src.battle.AnimPlayer")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the eye and ear cannot check ---------------------------------
U.log("#1653 Wrap animation length: machine checks")
local ba = game.data.battle_anims
check("battle_anims is in the cache", ba ~= nil)
local anims = (ba or {}).moveAnims or {}
check("WRAP is in the move table", game.data.moves.WRAP ~= nil)
check("WRAP has an animation", anims.WRAP ~= nil)
-- anim_tileset 79 / 79 / 64 (engine/battle/animations.asm:383): 79 tiles is
-- nine 8-tile chunks plus the tail frame, so tileset 0 blocks 10 frames
local sheet0 = (ba or {}).tilesheets and ba.tilesheets[0]
check(("tileset 0 declares %s tiles (anim_tileset 79)")
:format(tostring(sheet0 and sheet0.tiles)),
sheet0 ~= nil and sheet0.tiles == 79)
-- WrapAnim is three identical rows: SUBANIM_0_BIND on tileset 0, delay 4
local seq = (anims.WRAP or {}).seq or {}
check(("WRAP is %d subanimation rows (want 3)"):format(#seq), #seq == 3)
local rowsOk = #seq == 3
for _, row in ipairs(seq) do
rowsOk = rowsOk and row.effect == nil and row.tileset == 0
and row.delay == 4 and row.sound == "WRAP"
end
check("every WRAP row is a tileset-0 subanimation with sound WRAP", rowsOk)
-- The move id in the row resolves to a real sfx program through moves.lua
local sfxName = (game.data.moves.WRAP or {}).anim
and game.data.moves.WRAP.anim.sound
check("WRAP's row sound resolves to a program (" .. tostring(sfxName) .. ")",
sfxName ~= nil and ((game.data.audio or {}).sfx or {})[sfxName] ~= nil)
-- ---- the compiled timeline ---------------------------------------------
local function compile(move)
if not (ba and anims[move]) then return nil end
local p = AnimPlayer.new(ba)
local ok = pcall(p.start, p, move, false)
if not ok then return nil end
return p
end
local function total(p)
local n = 0
for _, s in ipairs(p.steps) do n = n + s.dur end
return n
end
local function shapeOf(p)
local out = {}
for _, s in ipairs(p.steps) do
out[#out + 1] = ("%d/%d"):format(s.dur, #s.sprites)
end
return table.concat(out, " ")
end
-- which step is on screen at `elapsed`, mirroring AnimPlayer:update
local function stepAt(p, elapsed)
local acc = 0
for _, s in ipairs(p.steps) do
acc = acc + s.dur
if elapsed < acc then return s end
end
return nil
end
local wrap = compile("WRAP")
check("WRAP compiles into a timeline", wrap ~= nil)
if wrap then
U.log(" WRAP steps: " .. shapeOf(wrap))
check(("WRAP is %d frames long (want 60, half-length bug gives 30)")
:format(total(wrap)), total(wrap) == 60)
check(("WRAP compiles to %d steps (want 9: three rows of load + 2 blocks)")
:format(#wrap.steps), #wrap.steps == 9)
local shapeOk = #wrap.steps == 9
for i = 1, 9 do
local s = wrap.steps[i]
if not s then shapeOk = false break end
if i % 3 == 1 then
shapeOk = shapeOk and s.dur == 10 and #s.sprites == 0
else
shapeOk = shapeOk and s.dur == 5 and #s.sprites == 8
end
end
check("steps 1, 4 and 7 are 10 blank frames and the other six are 5 frames "
.. "of 8 sprites", shapeOk)
-- Near-miss guard. Paying the 10 frames AFTER a row's blocks also totals
-- 60, but it opens on sprites and ends on a blank tail, and every sound
-- lands 10 frames early.
local first, last = wrap.steps[1], wrap.steps[#wrap.steps]
check("the blank beat OPENS the animation (step 1 is blank, 10 frames)",
first ~= nil and #first.sprites == 0 and first.dur == 10)
check("...and does not close it (the last step draws sprites)",
last ~= nil and #last.sprites > 0)
local sounds = {}
for _, ev in ipairs(wrap.events) do
if ev.sound then sounds[#sounds + 1] = ev end
end
check(("WRAP queues %d row sounds (want 3)"):format(#sounds), #sounds == 3)
local want = { 10, 30, 50 }
for i = 1, 3 do
local ev = sounds[i]
check(("sound %d fires at frame %s (want %d, after that row's tile load)")
:format(i, ev and tostring(ev.frame) or "nil", want[i]),
ev ~= nil and ev.frame == want[i] and ev.sound == "WRAP")
if ev then
local st = stepAt(wrap, ev.frame)
check(("sound %d lands on a frame with sprites on screen, not on the "
.. "blank beat"):format(i),
st ~= nil and #st.sprites > 0)
end
end
end
-- ---- the same load, elsewhere: this was never Wrap-specific ------------
-- POUND is one tileset-0 row, so it gains the same 10 frames.
local pound = compile("POUND")
if pound then
local s = pound.steps[1]
check(("POUND also opens on a 10-frame blank load (%s)")
:format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"),
s ~= nil and s.dur == 10 and #s.sprites == 0)
end
-- Tileset 2 declares 64 tiles, which is eight chunks plus the tail: 9 frames.
local ball = compile("TRADE_BALL_DROP_ANIM")
if ball then
local s = ball.steps[1]
check(("a tileset-2 animation opens on 9 blank frames instead of 10 (%s)")
:format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"),
s ~= nil and s.dur == 9 and #s.sprites == 0)
end
-- Control: TACKLE's animation is two special-effect rows and no subanimation,
-- so PlayAnimation never reaches LoadMoveAnimationTiles and nothing changes.
local tackle = compile("TACKLE")
if tackle then
check(("TACKLE, which has no subanimation row, is still %d frames "
.. "(the load is not charged blindly)"):format(total(tackle)),
total(tackle) == 6)
end
-- ---- options that would fake a failure ---------------------------------
local opts = game.save.options or {}
check("battle animations are on in OPTION", opts.animations ~= false)
if opts.animations == false then
U.log(" ANIMATION is off, which skips the whole queued row: turn it on")
U.log(" in OPTION before judging anything below")
end
local vol = opts.sfxVol
if vol == 0 then
U.log(" sfxVol is 0: all three WRAP sfx will be SILENT, which sounds")
U.log(" exactly like the bug. Raise SFX in OPTION before listening")
else
U.log(" sfxVol " .. tostring(vol) .. ", so the three squeezes should be audible")
end
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
if fail > 0 then
U.log("something above says FAIL, so do not spend time watching the screen")
end
if not game.data.moves.WRAP then
U.log("no WRAP in the move table, so there is nothing to put on screen")
while true do coroutine.yield() end
end
-- ---- put a Wrap on screen ----------------------------------------------
-- data/generated/maps.lua ROUTE_1 is 10x18 blocks of open route, and pokered
-- data/maps/objects/Route1.asm parks its two youngsters at (5, 24) and
-- (15, 13), so the north end is empty. (5, 5) sits in the fence line in the
-- current cache, so the neighbour fallback below is the one that runs; the
-- battle is pushed straight in either way.
local MAP, SX, SY = "ROUTE_1", 5, 5
local ekans = Pokemon.new(game.data, "EKANS", 30)
ekans.moves = { { id = "WRAP", pp = game.data.moves.WRAP.pp } }
game.save.party = { ekans }
game.save.player.name = "RED"
U.teleport(game, MAP, SX, SY, "down")
U.wait(12)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(SX, SY) then
-- a map edit blocked the cell: take the nearest free neighbour instead
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = SX + d[1], SY + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on %d, %d"):format(SX, SY, cx, cy))
U.teleport(game, MAP, cx, cy, "down")
U.wait(12)
ow = game.overworld
break
end
end
end
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
local battle = BattleState.newWild(game, "PIDGEY", 20)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
-- a foe that survives the wrap chain, so the animation can repeat
battle.enemy.mon.stats.hp = 500
battle.enemy.mon.hp = 500
battle.enemy.shownHP = 500
-- a wild PIDGEY that knows WHIRLWIND ends the fight the moment it picks it
for i = #battle.enemy.mon.moves, 1, -1 do
local id = battle.enemy.mon.moves[i].id
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
table.remove(battle.enemy.mon.moves, i)
end
end
-- ---- watch the live playback -------------------------------------------
-- every frame the player is playing, record whether sprites are on screen,
-- so the blank load beats show up as runs in the trace
local runs, cur = {}, nil
if battle.animPlayer then
local realStart = battle.animPlayer.start
battle.animPlayer.start = function(self, moveId, isPlayer, o)
local r = realStart(self, moveId, isPlayer, o)
cur = { move = moveId, marks = {} }
runs[#runs + 1] = cur
return r
end
end
-- U.shot spins frames of its own, which would punch a hole in the trace, so
-- the measuring turn runs with `shooting` off and a later turn takes the shot
local shooting, shot = false, false
local function sample()
if not (battle.animPlaying and cur and battle.animPlayer) then return end
local ap = battle.animPlayer
local st = ap.steps[ap.stepIndex]
if not st then return end
local lit = #st.sprites > 0
cur.marks[#cur.marks + 1] = lit and 1 or 0
if shooting and not shot and lit and cur.move == "WRAP" then
shot = true
U.shot(game, DIR .. "/bug1653_wrap_squeeze.png")
end
end
local function runLengths(marks)
local out, run, val = {}, 0, nil
for _, m in ipairs(marks) do
if m ~= val then
if val ~= nil then
out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run)
end
val, run = m, 0
end
run = run + 1
end
if val ~= nil then
out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run)
end
return table.concat(out, ", ")
end
-- step n frames, sampling each one, pressing A only while a box is up: an A
-- on the FIGHT menu opens the move list and fires a move behind our back
local function pump(n, mash, stop)
for i = 1, n do
if mash and i % mash == 0 and battle.phase == "messages" then
table.insert(game.input.pressQueue, "a")
end
U.wait(1)
game.input.state.a = false
sample()
if stop and stop() then return end
end
end
local function toMenu()
pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
return battle.phase == "menu"
end
-- FIGHT is menuIndex 1 of the 2x2 grid, and WRAP is the only move on the list
local function useWrap()
for _ = 1, 80 do
if battle.phase == "moveSelect" then break end
if battle.phase == "menu" then
if battle.menuIndex ~= 1 then
U.tap(game, battle.menuIndex > 2 and "up" or "left")
else
U.tap(game, "a")
end
else
U.tap(game, "a")
end
for _ = 1, 3 do U.wait(1) sample() end
end
if battle.phase ~= "moveSelect" then return false end
for _ = 1, 20 do
if battle.phase ~= "moveSelect" then return true end
U.tap(game, "a")
for _ = 1, 3 do U.wait(1) sample() end
end
return false
end
-- the foe animates too, so pick the player's WRAP row out of the list
local function wrapRun()
for i = #runs, 1, -1 do
if runs[i].move == "WRAP" and #runs[i].marks > 0 then return runs[i] end
end
return nil
end
check("the battle reached its FIGHT menu", toMenu())
local sent = false
for _ = 1, 6 do
-- WRAP is 85% accurate and the foe leans on SAND-ATTACK, so hand the
-- accuracy back each try rather than reading a whiff as a regression
battle.player.stages.accuracy = 0
sent = useWrap()
if not sent then break end
pump(900, 8, function()
return wrapRun() ~= nil and not battle.animPlaying
end)
if wrapRun() then break end
U.log(" WRAP did not connect that turn; using it again")
toMenu()
end
check("chose WRAP from the move menu", sent)
local played = wrapRun()
check("WRAP's animation actually played on screen", played ~= nil)
if played then
U.log((" live playback: %d frames, %s")
:format(#played.marks, runLengths(played.marks)))
-- the sampler runs once per fixed step and can slip a frame against the
-- battle's own update, so the compiled timeline above is the exact
-- authority; this only has to rule out the 30-frame version
check(("live playback is around 60 frames, not 30 (measured %d)")
:format(#played.marks), #played.marks >= 50)
end
-- WRAP traps the foe, so the next turn replays it: measurement is done, so
-- now let the sampler stop and catch a squeeze mid-coil for the record
shooting = true
for _ = 1, 4 do
if shot then break end
if battle.phase == "menu" then
battle.player.stages.accuracy = 0
if not useWrap() then break end
end
pump(600, 8, function() return shot end)
end
if not shot then U.log(" no lit WRAP frame to capture") end
U.shot(game, DIR .. "/bug1653_wrap_after.png")
-- ---- hand off ----------------------------------------------------------
U.log("the pad is yours in a battle where the EKANS knows only WRAP (#1653),")
U.log("so pick FIGHT then WRAP and watch. the coils should squeeze the PIDGEY")
U.log("three separate times, each squeeze about a sixth of a second, with a")
U.log("clear blank beat of the same length before each one -- roughly a second")
U.log("end to end. the old bug ran the three squeezes together as one half-")
U.log("second smear with no gaps.")
U.log("listen as well as look: each of the three WRAP sfx should land as the")
U.log("coils appear. an sfx that fires while the screen is still blank, with")
U.log("the pause moved to the end of the animation, is the near miss that")
U.log("still measures 60 frames.")
U.log("wrap traps the foe, so the animation repeats over the next few turns")
U.log("and you can watch it more than once. any other move works the same")
U.log("way: every animation in the game now opens on that blank beat.")
U.log("screenshots: " .. DIR .. "/bug1653_*.png")
while true do
coroutine.yield()
end
end