mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user