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
|
||||
@@ -0,0 +1,244 @@
|
||||
-- The BICYCLE is used straight off the item list, with no USE/TOSS box
|
||||
-- (#1705).
|
||||
--
|
||||
-- StartMenu_Item's .choseItem reads wCurItem and, before it ever loads
|
||||
-- USE_TOSS_MENU_TEMPLATE into wTextBoxID, does `cp BICYCLE / jp z,
|
||||
-- .useOrTossItem` (engine/menus/start_sub_menus.asm:340-342). So the bike
|
||||
-- mounts, dismounts or refuses on one A press, and -- having no TOSS row to
|
||||
-- reach -- can never be thrown away from the bag at all. Every other item,
|
||||
-- key items included, still gets the option box.
|
||||
--
|
||||
-- The port pushed the USE/TOSS Menu for every field item, so the bike took
|
||||
-- two presses and offered a TOSS the cart does not.
|
||||
-- luajit tests/engine/bag_bicycle_no_options_bug1705.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||
local music = {}
|
||||
package.loaded["src.core.Music"] = {
|
||||
playMap = function(_, mapId, biking) music[#music + 1] = { mapId, biking } end,
|
||||
}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
soundOpts = function() return nil end,
|
||||
}
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
-- the fixture item table carries neither, and BagMenu reads only
|
||||
-- name/keyItem/machine off a def; the branches under test key on the id
|
||||
Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0,
|
||||
keyItem = true }
|
||||
Data.items.TOWN_MAP = { id = "TOWN_MAP", index = 5, name = "TOWN MAP",
|
||||
price = 0, keyItem = true }
|
||||
Data.items.FIX_POTION = Data.items.FIX_POTION
|
||||
or { id = "FIX_POTION", index = 20, name = "FIX POTION", price = 300 }
|
||||
|
||||
local BIKE_MAP = { id = "FIX_TOWN", def = { tileset = "OVERWORLD" } }
|
||||
|
||||
local function freshGame(extras)
|
||||
local game = {
|
||||
data = Data,
|
||||
save = {
|
||||
party = {},
|
||||
player = { name = "RED", id = 1 },
|
||||
inventory = {},
|
||||
options = {},
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = { pressed = nil }
|
||||
function game.input:wasPressed(b) return self.pressed == b end
|
||||
function game.input:isDown() return false end
|
||||
-- IsBikeRidingAllowed reads the tileset off the loaded map, and the
|
||||
-- mount/dismount lines read the player's name off the save
|
||||
game.overworld = { map = BIKE_MAP, player = { surfing = false } }
|
||||
Bag.add(game.save, "FIX_POTION", 3)
|
||||
Bag.add(game.save, "BICYCLE", 1)
|
||||
Bag.add(game.save, "TOWN_MAP", 1)
|
||||
for k, v in pairs(extras or {}) do game.save[k] = v end
|
||||
return game
|
||||
end
|
||||
|
||||
local function rowFor(list, id)
|
||||
for i, r in ipairs(list.items) do
|
||||
if r.value == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function isMenu(s) return getmetatable(s) == Menu end
|
||||
local function isBox(s) return type(s) == "table" and s.textBox == true end
|
||||
|
||||
local function inStack(game, pred)
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Open the bag, put the cursor on `id` and press A once. Returns the list
|
||||
-- and every state the press left on the stack above it, so a USE/TOSS box
|
||||
-- that appears and is then replaced is still caught.
|
||||
local function chooseOnce(game, id)
|
||||
local list = BagMenu.new(game, {})
|
||||
game.stack:push(list)
|
||||
local row = rowFor(list, id)
|
||||
if not row then return nil, nil, "no " .. id .. " row in the bag" end
|
||||
list.index = row
|
||||
local seen = {}
|
||||
local realPush = game.stack.push
|
||||
game.stack.push = function(self, s)
|
||||
seen[#seen + 1] = s
|
||||
return realPush(self, s)
|
||||
end
|
||||
game.input.pressed = "a"
|
||||
list:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
game.stack.push = realPush
|
||||
return list, seen
|
||||
end
|
||||
|
||||
local function sawMenu(seen)
|
||||
for _, s in ipairs(seen or {}) do
|
||||
if isMenu(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function boxText(game)
|
||||
local top = game.stack:top()
|
||||
return isBox(top) and top.text or nil
|
||||
end
|
||||
|
||||
-- on foot, somewhere cycling is allowed: one A press mounts
|
||||
do
|
||||
music = {}
|
||||
local game = freshGame()
|
||||
local list, seen, why = chooseOnce(game, "BICYCLE")
|
||||
if check(list ~= nil, "the bag opened on the BICYCLE row: " .. tostring(why)) then
|
||||
check(not sawMenu(seen),
|
||||
"no USE/TOSS Menu was pushed, not even for a frame (:340-342)")
|
||||
eq(game.save.onBike, true, "the single press mounted the bike")
|
||||
local said = boxText(game)
|
||||
if check(said ~= nil, "and printed a line") then
|
||||
check(said:find("got on", 1, true) ~= nil,
|
||||
"which is the mount text: " .. tostring(said))
|
||||
end
|
||||
eq(game.save.inventory.BICYCLE, 1, "the bike is still in the bag")
|
||||
eq(#music, 1, "the bike theme was cued once")
|
||||
check(music[1] and music[1][2] == true, "as the riding track")
|
||||
end
|
||||
end
|
||||
|
||||
-- already riding: the same single press dismounts
|
||||
do
|
||||
music = {}
|
||||
local game = freshGame({ onBike = true })
|
||||
local list, seen = chooseOnce(game, "BICYCLE")
|
||||
if check(list ~= nil, "the bag opened while riding") then
|
||||
check(not sawMenu(seen), "still no option box on the way off the bike")
|
||||
eq(game.save.onBike, false, "one press dismounted")
|
||||
local said = boxText(game)
|
||||
if check(said ~= nil, "and printed a line") then
|
||||
check(said:find("got off", 1, true) ~= nil,
|
||||
"which is the dismount text: " .. tostring(said))
|
||||
end
|
||||
check(music[1] and music[1][2] == false, "and the map theme came back")
|
||||
end
|
||||
end
|
||||
|
||||
-- Cycling Road: the refusal is reached on the same single press, and the
|
||||
-- item list stays open behind it (`jp ItemMenuLoop`, #513)
|
||||
do
|
||||
local game = freshGame({ onBike = true, forcedBike = true })
|
||||
local list, seen = chooseOnce(game, "BICYCLE")
|
||||
if check(list ~= nil, "the bag opened on the Cycling Road") then
|
||||
check(not sawMenu(seen), "the refusal is not behind an option box either")
|
||||
eq(game.save.onBike, true, "and the player is still riding")
|
||||
local said = boxText(game)
|
||||
if check(said ~= nil, "the refusal printed") then
|
||||
check(said:find("get off", 1, true) ~= nil,
|
||||
"with _CannotGetOffHereText: " .. tostring(said))
|
||||
end
|
||||
check(inStack(game, function(s) return s == list end),
|
||||
"the bag list is still on the stack under it (#513)")
|
||||
end
|
||||
end
|
||||
|
||||
-- somewhere cycling is not allowed: still one press, still no box
|
||||
do
|
||||
local game = freshGame()
|
||||
game.overworld.map = { id = "FIX_INDOORS", def = { tileset = "FIX_HOUSE" } }
|
||||
local list, seen = chooseOnce(game, "BICYCLE")
|
||||
if check(list ~= nil, "the bag opened indoors") then
|
||||
check(not sawMenu(seen), "the no-cycling refusal skips the box too")
|
||||
eq(game.save.onBike, nil, "and nothing was mounted")
|
||||
local said = boxText(game)
|
||||
check(said ~= nil and said:find("cycling", 1, true) ~= nil,
|
||||
"NoCyclingAllowedHere printed: " .. tostring(said))
|
||||
end
|
||||
end
|
||||
|
||||
-- the control cases: everything else still gets USE/TOSS, key items included
|
||||
do
|
||||
local game = freshGame()
|
||||
local list, seen = chooseOnce(game, "FIX_POTION")
|
||||
if check(list ~= nil, "the bag opened on a plain item") then
|
||||
check(sawMenu(seen), "an ordinary item still opens the option box")
|
||||
check(isMenu(game.stack:top()), "and it is what the A press left on top")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local game = freshGame()
|
||||
local list, seen = chooseOnce(game, "TOWN_MAP")
|
||||
if check(list ~= nil, "the bag opened on the TOWN MAP") then
|
||||
check(sawMenu(seen),
|
||||
"another key item still gets the box: pokered special-cases the "
|
||||
.. "BICYCLE by id, not key items as a class")
|
||||
end
|
||||
end
|
||||
|
||||
-- the box the bike no longer opens is the only route to TOSS, so the bike
|
||||
-- cannot be thrown away from the bag at all
|
||||
do
|
||||
local game = freshGame()
|
||||
local list, seen = chooseOnce(game, "BICYCLE")
|
||||
if check(list ~= nil, "the bag opened on the BICYCLE row") then
|
||||
local rows = {}
|
||||
for _, s in ipairs(seen or {}) do
|
||||
for _, it in ipairs((isMenu(s) and s.items) or {}) do
|
||||
rows[#rows + 1] = tostring(it.label)
|
||||
end
|
||||
end
|
||||
eq(#rows, 0, "the press offered no menu rows at all, TOSS included")
|
||||
eq(game.save.inventory.BICYCLE, 1, "and the bike survives the press")
|
||||
end
|
||||
end
|
||||
|
||||
package.loaded["src.core.Sound"] = nil
|
||||
package.loaded["src.core.Music"] = nil
|
||||
package.loaded["src.render.TextBox"] = nil
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,253 @@
|
||||
-- The bag list ends on the terminator's CANCEL row (#1685).
|
||||
--
|
||||
-- pokered's item lists are $ff-terminated. PrintListMenuEntries walks four
|
||||
-- entries and, on the terminator, `jp z, .printCancelMenuItem` -- which is
|
||||
-- `ld de, ListMenuCancelText / jp PlaceString`, a TAIL jump that RETURNS
|
||||
-- from PrintListMenuEntries (home/list_menu.asm:371-372, 523-528). The '▼'
|
||||
-- at :518-522 is only reached by the fall-through, so any page showing
|
||||
-- CANCEL shows no down arrow. DisplayListMenuIDLoop compares the selection
|
||||
-- against wListCount and takes ExitListMenu when it is the row past the last
|
||||
-- item (:105-110), which is the same exit B takes.
|
||||
--
|
||||
-- The port emitted one row per bag entry and stopped, so the list had no
|
||||
-- CANCEL to walk onto and printed an invented "Nothing here." when the bag
|
||||
-- was empty.
|
||||
-- luajit tests/engine/bag_cancel_row_bug1685.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- Font wants a real atlas and this suite is about what lands on which row,
|
||||
-- so it records the calls instead (the shape tests/engine/
|
||||
-- bag_item_box_bug1521.lua uses). ListMenu and Theme bind Font at require
|
||||
-- time, and BagMenu binds ListMenu and TextBox at require time.
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
|
||||
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
|
||||
width = function(text) return #tostring(text) * 8 end,
|
||||
-- Menu.new sizes its box off split(); reaching it at all is the failure
|
||||
-- this suite reports, so the stub answers instead of erroring
|
||||
split = function(text)
|
||||
local spans = {}
|
||||
for i = 1, #tostring(text) do spans[i] = { from = i, to = i, code = 0 } end
|
||||
return spans
|
||||
end,
|
||||
}
|
||||
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
soundOpts = function() return nil end,
|
||||
}
|
||||
package.loaded["src.ui.ListMenu"] = nil
|
||||
package.loaded["src.ui.Theme"] = nil
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local CANCEL = Strings("CANCEL")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
for i = 1, 6 do
|
||||
local id = "FIXITEM_" .. i
|
||||
Data.items[id] = { id = id, index = 100 + i, name = "FIX ITEM " .. i,
|
||||
price = 100, tossable = true }
|
||||
end
|
||||
|
||||
local function freshGame(count)
|
||||
local game = {
|
||||
data = Data,
|
||||
save = {
|
||||
party = {},
|
||||
player = { name = "RED", id = 1 },
|
||||
inventory = {},
|
||||
options = {},
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
-- one button edge per update, the way Input reports a fixed step
|
||||
game.input = { pressed = nil }
|
||||
function game.input:wasPressed(b) return self.pressed == b end
|
||||
function game.input:isDown() return false end
|
||||
for i = 1, count do Bag.add(game.save, "FIXITEM_" .. i, i) end
|
||||
return game
|
||||
end
|
||||
|
||||
local function openBag(game, opts)
|
||||
local list = BagMenu.new(game, opts)
|
||||
game.stack:push(list)
|
||||
return list
|
||||
end
|
||||
|
||||
local function found(kind, pred)
|
||||
for _, c in ipairs(calls) do
|
||||
if c[1] == kind and pred(c) then return c end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function drawnAt(text, x, y)
|
||||
return found("draw", function(c)
|
||||
return c[2] == text and c[3] == x and (y == nil or c[4] == y)
|
||||
end)
|
||||
end
|
||||
|
||||
local function press(list, btn)
|
||||
list.game.input.pressed = btn
|
||||
list:update(1 / 60)
|
||||
list.game.input.pressed = nil
|
||||
end
|
||||
|
||||
-- the row itself: one CANCEL after the last bag entry, carrying no id and no
|
||||
-- quantity (home/list_menu.asm:523-528)
|
||||
do
|
||||
local game = freshGame(6)
|
||||
local list = openBag(game)
|
||||
eq(#list.items, 7, "six bag entries plus the terminator's CANCEL row")
|
||||
local last = list.items[#list.items]
|
||||
eq(last.label, CANCEL, "the last row is CANCEL")
|
||||
eq(last.cancel, true, "flagged as the terminator row, not an item")
|
||||
eq(last.value, nil, "with no item id behind it")
|
||||
eq(last.right, nil, "and no 'xN' count (PrintListMenuEntries never gets "
|
||||
.. "that far for the terminator)")
|
||||
eq(CANCEL, "CANCEL", "ListMenuCancelText reads CANCEL")
|
||||
for i = 1, 6 do
|
||||
eq(list.items[i].value, "FIXITEM_" .. i, "row " .. i .. " is still its item")
|
||||
end
|
||||
end
|
||||
|
||||
-- page one of a long list: four real names, the '▼', no CANCEL yet
|
||||
do
|
||||
local game = freshGame(6)
|
||||
local list = openBag(game)
|
||||
calls = {}
|
||||
list:draw()
|
||||
for row = 1, 4 do
|
||||
local y = 32 + (row - 1) * 16
|
||||
check(drawnAt("FIX ITEM " .. row, 48, y) ~= nil,
|
||||
"name " .. row .. " sits at (48, " .. y .. ")")
|
||||
end
|
||||
check(found("draw", function(c) return c[2] == CANCEL end) == nil,
|
||||
"CANCEL is below the fold on page one, not printed on it")
|
||||
check(found("code", function(c)
|
||||
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
|
||||
end) ~= nil, "four real rows fill the page, so the '▼' prints (:518-522)")
|
||||
end
|
||||
|
||||
-- walking down: the cursor reaches CANCEL and stops there, and the page it
|
||||
-- lands on drops the '▼'
|
||||
do
|
||||
local game = freshGame(6)
|
||||
local list = openBag(game)
|
||||
for _ = 1, 6 do press(list, "down") end
|
||||
eq(list.index, 7, "six downs put the cursor on the CANCEL row")
|
||||
eq(list.scroll, 4, "with the scroll capped at wListCount - 2")
|
||||
press(list, "down")
|
||||
eq(list.index, 7, "and a seventh down goes nowhere: CANCEL is the last row")
|
||||
eq(list.scroll, 4, "the list does not scroll past it either")
|
||||
|
||||
calls = {}
|
||||
list:draw()
|
||||
-- rows 5, 6, CANCEL, then the terminator's return
|
||||
check(drawnAt("FIX ITEM 5", 48, 32) ~= nil, "FIX ITEM 5 leads the last page")
|
||||
check(drawnAt(CANCEL, 48, 64) ~= nil,
|
||||
"CANCEL sits one row below the last item, at the item names' x")
|
||||
check(found("code", function(c)
|
||||
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 64
|
||||
end) ~= nil, "the cursor is on it, in wTopMenuItemX's column")
|
||||
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
|
||||
"and the '▼' is gone: .printCancelMenuItem returns before it")
|
||||
end
|
||||
|
||||
-- the subtle one: three items plus CANCEL fills all four printed rows, so
|
||||
-- `shown == rows` alone would still paint the arrow next to CANCEL
|
||||
do
|
||||
local game = freshGame(3)
|
||||
local list = openBag(game)
|
||||
eq(#list.items, 4, "three items and CANCEL exactly fill the four rows")
|
||||
calls = {}
|
||||
list:draw()
|
||||
check(drawnAt(CANCEL, 48, 80) ~= nil, "CANCEL is the fourth printed row")
|
||||
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
|
||||
"a full page whose last row is CANCEL still prints no '▼' (:371-372)")
|
||||
end
|
||||
|
||||
-- an empty bag is a box with CANCEL alone, which is what the cart shows
|
||||
do
|
||||
local game = freshGame(0)
|
||||
local list = openBag(game)
|
||||
eq(#list.items, 1, "no items: the terminator is the whole list")
|
||||
eq(list.items[1] and list.items[1].cancel, true, "and that row is CANCEL")
|
||||
calls = {}
|
||||
list:draw()
|
||||
check(drawnAt(CANCEL, 48, 32) ~= nil, "printed on the first row")
|
||||
check(found("draw", function(c)
|
||||
return tostring(c[2]):find("Nothing here", 1, true) ~= nil
|
||||
end) == nil, "the port's invented \"Nothing here.\" line is gone")
|
||||
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
|
||||
"one row, no '▼'")
|
||||
end
|
||||
|
||||
-- A on CANCEL is ExitListMenu: the same exit B takes (:105-110)
|
||||
do
|
||||
local game = freshGame(6)
|
||||
local cancels = 0
|
||||
local list = openBag(game, { onCancel = function() cancels = cancels + 1 end })
|
||||
for _ = 1, 6 do press(list, "down") end
|
||||
eq(list.index, 7, "cursor on CANCEL")
|
||||
press(list, "a")
|
||||
check(game.stack:top() ~= list, "A on CANCEL leaves the item list")
|
||||
eq(#game.stack.states, 0, "with nothing pushed over it: no USE/TOSS box, "
|
||||
.. "no message")
|
||||
eq(cancels, 1, "and the caller's onCancel ran, exactly as for B")
|
||||
end
|
||||
|
||||
do
|
||||
local game = freshGame(6)
|
||||
local cancels = 0
|
||||
local list = openBag(game, { onCancel = function() cancels = cancels + 1 end })
|
||||
press(list, "b")
|
||||
check(game.stack:top() ~= list, "B still exits the list")
|
||||
eq(cancels, 1, "through the same callback")
|
||||
end
|
||||
|
||||
-- SELECT swaps two bag entries; the terminator is not one
|
||||
-- (engine/menus/swap_items.asm:19-22)
|
||||
do
|
||||
local game = freshGame(6)
|
||||
local list = openBag(game)
|
||||
for _ = 1, 6 do press(list, "down") end
|
||||
press(list, "select")
|
||||
eq(list.swapIndex, nil, "SELECT on the CANCEL row starts no swap")
|
||||
press(list, "up")
|
||||
press(list, "select")
|
||||
eq(list.swapIndex, 6, "while SELECT on a real row still does")
|
||||
end
|
||||
|
||||
package.loaded["src.render.Font"] = nil
|
||||
package.loaded["src.core.Sound"] = nil
|
||||
package.loaded["src.render.TextBox"] = nil
|
||||
package.loaded["src.ui.ListMenu"] = nil
|
||||
package.loaded["src.ui.Theme"] = nil
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
T.finish()
|
||||
@@ -96,6 +96,38 @@ eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart")
|
||||
eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite")
|
||||
eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none")
|
||||
|
||||
-- A .g1rcart dropped into the carts folder by hand, with no registry entry:
|
||||
-- the launcher must adopt it, not read past it. _refreshCarts used to call
|
||||
-- CartStore.index (registry only) and never saw one.
|
||||
do
|
||||
local stray = CartManifest.parse(cartTable({
|
||||
id = "dropped_in", title = "Dropped In", version = "2.0.0" }))
|
||||
check(stray ~= nil, "the stray fixture parses")
|
||||
local opts = SaveData.loadOptions()
|
||||
opts[CartStore.OPTIONS_KEY] = nil
|
||||
SaveData.saveOptions(opts)
|
||||
love.filesystem.createDirectory(CartStore.DIR)
|
||||
love.filesystem.write(CartStore.fileFor("dropped_in"),
|
||||
CartManifest.encode(stray))
|
||||
|
||||
local fresh = freshLauncher()
|
||||
local found
|
||||
for _, row in ipairs(fresh:_ensureCarts("red")) do
|
||||
if row.id == "dropped_in" then found = row end
|
||||
end
|
||||
check(found ~= nil, "the launcher lists a cart dropped into the folder")
|
||||
if found then
|
||||
eq(found.title, "Dropped In", "and reads its title from the file")
|
||||
eq(found.base, "red", "and its base game")
|
||||
end
|
||||
eq(CartStore.index()[1] ~= nil, true,
|
||||
"listing healed the registry so the cheap index sees it too")
|
||||
|
||||
-- Put the fixture set back: later blocks assert on the picker's own layout,
|
||||
-- and a third red cart changes its height.
|
||||
CartStore.uninstall("dropped_in")
|
||||
end
|
||||
|
||||
imp.tab = "red"
|
||||
imp.ready.red = true
|
||||
imp._cartPopup = "red"
|
||||
@@ -109,8 +141,9 @@ check(picker:find("Johto Lite", 1, true) == nil,
|
||||
check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version")
|
||||
check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state")
|
||||
check(picker:find("open", 1, true) ~= nil, "including an open one")
|
||||
check(picker:find("Get more carts", 1, true) ~= nil,
|
||||
"the last row is the browse placeholder")
|
||||
check(picker:find("Import a cart", 1, true) ~= nil
|
||||
or picker:find("Get more carts", 1, true) ~= nil,
|
||||
"the last row imports a cart, by picker or by folder")
|
||||
|
||||
imp._cartPopup = nil
|
||||
local vanillaColors = drawColors(imp)
|
||||
@@ -305,14 +338,14 @@ local maker = freshLauncher()
|
||||
maker.tab = "red"
|
||||
maker.ready.red = true
|
||||
maker:_setModScope("red")
|
||||
eq(maker:_cartCaptureCount("red"), 2,
|
||||
"the control counts only the mods enabled for this game")
|
||||
eq(maker:_cartCaptureCount("red"), 3,
|
||||
"the control counts every mod the capture would pin, on or off")
|
||||
|
||||
maker:_beginCartSave("red")
|
||||
check(maker._cartSave ~= nil, "Save as cart opens a form")
|
||||
eq(maker._cartSave.count, 2, "the form reports the captured mod count")
|
||||
eq(maker._cartSave.count, 3, "the form reports the captured mod count")
|
||||
eq(maker._cartSave.version, "red", "scoped to the game the panel is showing")
|
||||
eq(#maker._cartSave.unresolved, 1, "capture reports one pin it could not resolve")
|
||||
eq(#maker._cartSave.unresolved, 2, "capture reports the pins it could not resolve")
|
||||
eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to")
|
||||
check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil,
|
||||
"and why it could only be pinned locally")
|
||||
@@ -345,7 +378,13 @@ eq(made.base, "red", "based on the game the panel was showing")
|
||||
eq(made.version, "1.0.0", "at the default cart version")
|
||||
eq(made.seal, "sealed", "sealed by default")
|
||||
eq(made.shell, "#ff3c48", "wearing the base game's rail colour")
|
||||
eq(#made.mods, 2, "pinning exactly the enabled mods")
|
||||
eq(#made.mods, 3, "pinning every installed mod, on or off")
|
||||
local madeOff
|
||||
for _, entry in ipairs(made.mods) do
|
||||
if entry.id == "off_mod" then madeOff = entry end
|
||||
end
|
||||
check(madeOff ~= nil, "including the one the player has switched off")
|
||||
eq(madeOff.enabled, false, "which is pinned switched off rather than dropped")
|
||||
|
||||
local listedNow = false
|
||||
for _, row in ipairs(maker:_ensureCarts("red")) do
|
||||
@@ -472,9 +511,513 @@ eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false,
|
||||
"starts sealed again")
|
||||
eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one")
|
||||
|
||||
install({ id = "plus_cart", title = "Plus Cart", seal = "sealed+",
|
||||
shell = "#445566" })
|
||||
LauncherMods.list = function() return FULL_MODS end
|
||||
local plusCart = freshLauncher()
|
||||
plusCart.tab = "red"
|
||||
plusCart.ready.red = true
|
||||
plusCart._cartPopup = "red"
|
||||
local plusPicker = drawAndCapture(plusCart)
|
||||
check(plusPicker:find("sealed+", 1, true) ~= nil,
|
||||
"the picker spells a sealed+ cart's seal out")
|
||||
plusCart._cartPopup = nil
|
||||
plusCart:_selectCart("red", "plus_cart")
|
||||
local plusPlan = plusCart:cartPlan("red")
|
||||
eq(plusPlan.seal, "sealed+", "the plan carries the sealed+ seal")
|
||||
eq(plusPlan.sealed, true, "sealed+ is a sealed cart")
|
||||
eq(plusPlan.enforced, true, "and enforces its mod set")
|
||||
local plusText = drawAndCapture(plusCart)
|
||||
check(plusText:find("Sealed", 1, true) ~= nil, "the page says it is sealed")
|
||||
check(plusText:find("switch any of them on or off", 1, true) ~= nil,
|
||||
"and that the player may switch the mods it pins")
|
||||
check(plusText:find("Break the seal", 1, true) ~= nil,
|
||||
"while still offering the escape hatch")
|
||||
eq(SaveData.slotSealBroken("plus_cart",
|
||||
plusCart.activeSlot[plusCart:slotScope("red")] or "slot1"), false,
|
||||
"and reading the page breaks nothing")
|
||||
|
||||
-- ------- installing the mods a cart pins, instead of defeating its seal
|
||||
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local realFetchBegin, realFetchPump =
|
||||
ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases
|
||||
local realDlBegin, realDlPump =
|
||||
ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip
|
||||
local realInstallDownloaded = LauncherMods.installDownloadedZip
|
||||
|
||||
love.data = love.data or {}
|
||||
local savedData = { hash = love.data.hash, encode = love.data.encode }
|
||||
-- The archive's digest IS its bytes here, so a fixture writes the hash it
|
||||
-- wants the downloaded zip to have.
|
||||
love.data.hash = function(_, data) return tostring(data) end
|
||||
love.data.encode = function(_, _, digest) return tostring(digest) end
|
||||
|
||||
local SHA_ONE = ("0123abcd"):rep(8)
|
||||
local SHA_TWO = ("dead9876"):rep(8)
|
||||
|
||||
local net = { versions = {}, bytes = {}, err = nil, repos = {} }
|
||||
ModUpdate.beginFetchReleases = function(repo, modId)
|
||||
net.repos[modId] = repo
|
||||
return { modId = modId, repo = repo }
|
||||
end
|
||||
ModUpdate.pumpFetchReleases = function(h)
|
||||
if net.err then return true, nil, net.err end
|
||||
local out = {}
|
||||
for _, v in ipairs(net.versions[h.modId] or {}) do
|
||||
out[#out + 1] = { version = v, tag = "v" .. v,
|
||||
zip = { url = h.modId .. "@" .. v, name = h.modId .. ".zip" } }
|
||||
end
|
||||
return true, out
|
||||
end
|
||||
ModUpdate.beginDownloadZip = function(url, destName)
|
||||
love.filesystem.write(destName, net.bytes[url] or "not-the-pinned-archive")
|
||||
return { path = destName }
|
||||
end
|
||||
ModUpdate.pumpDownloadZip = function(h) return true, h.path end
|
||||
|
||||
local haveMods = {}
|
||||
LauncherMods.installDownloadedZip = function(modId, localPath, version)
|
||||
haveMods[modId] = version or "?"
|
||||
pcall(love.filesystem.remove, localPath)
|
||||
return true, haveMods[modId]
|
||||
end
|
||||
LauncherMods.list = function()
|
||||
local rows = {}
|
||||
for id, v in pairs(haveMods) do
|
||||
rows[#rows + 1] = fakeRow({ id = id, name = id, version = v })
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
install({ id = "fill_cart", title = "Fill Cart", shell = "#227744",
|
||||
mods = {
|
||||
{ id = "fill_one", source = "github", repo = "ren/fill-one",
|
||||
version = "1.0.0", sha256 = SHA_ONE },
|
||||
{ id = "fill_two", source = "github", repo = "ren/fill-two",
|
||||
version = "2.1.0", sha256 = SHA_TWO },
|
||||
},
|
||||
load_order = { "fill_one", "fill_two" } })
|
||||
install({ id = "odd_cart", title = "Odd Cart", shell = "#772244",
|
||||
mods = {
|
||||
{ id = "banana_mod", source = "gamebanana", mod = 4821, file = 99123,
|
||||
md5 = ("ab"):rep(16) },
|
||||
{ id = "here_mod", source = "local", version = "0.0.0" },
|
||||
},
|
||||
load_order = { "banana_mod", "here_mod" } })
|
||||
|
||||
local function fillPage(cartId)
|
||||
local page = freshLauncher()
|
||||
page.tab = "red"
|
||||
page.ready.red = true
|
||||
page:_selectCart("red", cartId)
|
||||
return page
|
||||
end
|
||||
|
||||
local function runFill(page)
|
||||
page:pressInstallCartMods("red")
|
||||
local guard = 0
|
||||
while page._cartFill and guard < 500 do
|
||||
page:_pumpModInstall()
|
||||
page:_pumpCartFill()
|
||||
guard = guard + 1
|
||||
end
|
||||
check(page._cartFill == nil, "the install run finishes")
|
||||
return page.cartFillNotice or {}
|
||||
end
|
||||
|
||||
local function failureText(notice)
|
||||
return table.concat(notice.failures or {}, " | ")
|
||||
end
|
||||
|
||||
net.versions.fill_one = { "1.0.0", "0.9.0" }
|
||||
net.versions.fill_two = { "2.1.0" }
|
||||
net.bytes["fill_one@1.0.0"] = SHA_ONE
|
||||
net.bytes["fill_two@2.1.0"] = SHA_TWO
|
||||
|
||||
-- A cart whose pins are all installed offers nothing to install.
|
||||
haveMods = { fill_one = "1.0.0", fill_two = "2.1.0" }
|
||||
local readyFill = fillPage("fill_cart")
|
||||
eq(readyFill:cartPlan("red").refused, false, "every pin installed, so it plays")
|
||||
eq(#readyFill:cartFillRows("red"), 0, "with nothing left to install")
|
||||
local readyText = drawAndCapture(readyFill)
|
||||
check(readyText:find("Install required mods", 1, true) == nil
|
||||
and readyText:find("required mods", 1, true) == nil,
|
||||
"so a ready cart never offers the install button")
|
||||
check(readyText:find("Break the seal", 1, true) ~= nil,
|
||||
"while the seal control is where it always was")
|
||||
|
||||
-- Nothing installed: the button appears, named for the count, beside the seal.
|
||||
haveMods = {}
|
||||
local gapFill = fillPage("fill_cart")
|
||||
eq(#gapFill:cartFillRows("red"), 2, "both uninstalled pins queue up")
|
||||
local gapFillText = drawAndCapture(gapFill)
|
||||
check(gapFillText:find("Install 2 required mods", 1, true) ~= nil,
|
||||
"a refused cart offers to install what it pins, counted")
|
||||
check(gapFillText:find("the way its author built it", 1, true) ~= nil,
|
||||
"saying plainly that this is the way to play it as built")
|
||||
check(gapFillText:find("Break the seal", 1, true) ~= nil,
|
||||
"with breaking the seal still there as the fallback")
|
||||
|
||||
-- A pin installed at the WRONG version is a gap too: the cart wants its own.
|
||||
haveMods = { fill_one = "0.9.0", fill_two = "2.1.0" }
|
||||
local skewFill = fillPage("fill_cart")
|
||||
eq(skewFill:cartPlan("red").mismatched[1].id, "fill_one",
|
||||
"a pin at another version is a mismatch")
|
||||
eq(#skewFill:cartFillRows("red"), 1, "which queues for install like a gap")
|
||||
eq(skewFill:cartFillRows("red")[1].version, "1.0.0",
|
||||
"at the version the cart pins, not the one on disk")
|
||||
local skewText = drawAndCapture(skewFill)
|
||||
check(skewText:find("Install required mods", 1, true) ~= nil,
|
||||
"a single gap drops the count from the label")
|
||||
|
||||
-- THE HASH GATE. An archive that is not the one the cart recorded installs
|
||||
-- nothing, however well the id and version line up.
|
||||
haveMods = {}
|
||||
net.bytes["fill_one@1.0.0"] = "some other build entirely"
|
||||
local badHash = fillPage("fill_cart")
|
||||
local badNotice = runFill(badHash)
|
||||
eq(haveMods.fill_one, nil, "a hash mismatch installs nothing at all")
|
||||
eq(badNotice.ok, false, "and the run reports as failed")
|
||||
check(failureText(badNotice):find("fill_one", 1, true) ~= nil,
|
||||
"naming the mod whose archive did not match")
|
||||
check(failureText(badNotice):find("sha256", 1, true) ~= nil,
|
||||
"and saying it was the hash: " .. failureText(badNotice))
|
||||
eq(badHash:cartPlan("red").refused, true, "so the cart still refuses")
|
||||
net.bytes["fill_one@1.0.0"] = SHA_ONE
|
||||
|
||||
-- Partial: one archive verifies, the other does not.
|
||||
haveMods = {}
|
||||
net.bytes["fill_two@2.1.0"] = "wrong bytes for the second mod"
|
||||
local partial = fillPage("fill_cart")
|
||||
local partialNotice = runFill(partial)
|
||||
eq(haveMods.fill_one, "1.0.0", "the pin that verified is installed")
|
||||
eq(haveMods.fill_two, nil, "the pin that did not is not")
|
||||
eq(partialNotice.ok, false, "a partial run reads as a failure")
|
||||
check(tostring(partialNotice.text):find("1 of 2", 1, true) ~= nil,
|
||||
"counting what landed: " .. tostring(partialNotice.text))
|
||||
check(failureText(partialNotice):find("fill_two", 1, true) ~= nil,
|
||||
"and naming only the mod that failed")
|
||||
check(failureText(partialNotice):find("fill_one", 1, true) == nil,
|
||||
"not the one that worked")
|
||||
local partialText = drawAndCapture(partial)
|
||||
check(partialText:find("fill_two", 1, true) ~= nil,
|
||||
"the card reports the failure where the player pressed the button")
|
||||
net.bytes["fill_two@2.1.0"] = SHA_TWO
|
||||
|
||||
-- The whole run, verified, with the seal untouched at the end of it.
|
||||
haveMods = {}
|
||||
local good = fillPage("fill_cart")
|
||||
local goodScope = good:slotScope("red")
|
||||
good:_ensureSlots(goodScope)
|
||||
local goodSlot = good.activeSlot[goodScope]
|
||||
eq(good:cartPlan("red").refused, true, "the cart refuses before the run")
|
||||
local goodNotice = runFill(good)
|
||||
eq(goodNotice.ok, true, "the run reports success: " .. tostring(goodNotice.text))
|
||||
eq(haveMods.fill_one, "1.0.0", "the first pin lands at its pinned version")
|
||||
eq(haveMods.fill_two, "2.1.0", "and the second at its own")
|
||||
eq(net.repos.fill_one, "ren/fill-one", "each pin was looked up in its own repo")
|
||||
eq(net.repos.fill_two, "ren/fill-two", "including the second")
|
||||
eq(good:cartPlan("red").refused, false,
|
||||
"and the cart is ready to play with no further presses")
|
||||
eq(SaveData.slotSealBroken("fill_cart", goodSlot or "slot1"), false,
|
||||
"installing never breaks the cart's seal")
|
||||
eq(SaveData.isSealBroken(), false, "nor the session's")
|
||||
local goodText = drawAndCapture(good)
|
||||
check(goodText:find("Sealed", 1, true) ~= nil,
|
||||
"the card flips to ready without another click")
|
||||
check(goodText:find("required mods", 1, true) == nil,
|
||||
"and stops offering the install button")
|
||||
|
||||
-- No release carrying that version.
|
||||
haveMods = {}
|
||||
net.versions.fill_one = { "0.9.0" }
|
||||
local noRel = fillPage("fill_cart")
|
||||
local noRelNotice = runFill(noRel)
|
||||
eq(haveMods.fill_one, nil, "a version the repo does not publish installs nothing")
|
||||
check(failureText(noRelNotice):find("v1.0.0", 1, true) ~= nil,
|
||||
"and the failure names the version it wanted: " .. failureText(noRelNotice))
|
||||
net.versions.fill_one = { "1.0.0", "0.9.0" }
|
||||
|
||||
-- No .zip on the matching release.
|
||||
haveMods = {}
|
||||
local savedPump = ModUpdate.pumpFetchReleases
|
||||
ModUpdate.pumpFetchReleases = function(h)
|
||||
return true, { { version = "1.0.0", tag = "v1.0.0" },
|
||||
{ version = "2.1.0", tag = "v2.1.0" } }
|
||||
end
|
||||
local noZip = fillPage("fill_cart")
|
||||
local noZipNotice = runFill(noZip)
|
||||
eq(haveMods.fill_one, nil, "a release with no archive installs nothing")
|
||||
check(failureText(noZipNotice):find(".zip", 1, true) ~= nil,
|
||||
"and says so: " .. failureText(noZipNotice))
|
||||
ModUpdate.pumpFetchReleases = savedPump
|
||||
|
||||
-- Network down.
|
||||
haveMods = {}
|
||||
net.err = "no network transport on this platform"
|
||||
local offline = fillPage("fill_cart")
|
||||
local offlineNotice = runFill(offline)
|
||||
eq(haveMods.fill_one, nil, "an offline run installs nothing")
|
||||
eq(offlineNotice.ok, false, "and reports as failed")
|
||||
check(failureText(offlineNotice):find("no network transport", 1, true) ~= nil,
|
||||
"carrying the transport's own words: " .. failureText(offlineNotice))
|
||||
net.err = nil
|
||||
|
||||
-- Sources the launcher cannot fetch are refused per mod, not as one silence.
|
||||
haveMods = {}
|
||||
local odd = fillPage("odd_cart")
|
||||
eq(#odd:cartFillRows("red"), 2, "both odd pins are gaps")
|
||||
local oddText = drawAndCapture(odd)
|
||||
check(oddText:find("Install 2 required mods", 1, true) ~= nil,
|
||||
"the button is offered even when a pin may not be fetchable")
|
||||
local oddNotice = runFill(odd)
|
||||
eq(oddNotice.ok, false, "neither can be installed")
|
||||
eq(#(oddNotice.failures or {}), 2, "and each is reported on its own line")
|
||||
check(failureText(oddNotice):find("banana_mod", 1, true) ~= nil
|
||||
and failureText(oddNotice):find("GameBanana", 1, true) ~= nil,
|
||||
"the gamebanana pin says what it is pinned to: " .. failureText(oddNotice))
|
||||
check(failureText(oddNotice):find("here_mod", 1, true) ~= nil
|
||||
and failureText(oddNotice):find("nothing to download", 1, true) ~= nil,
|
||||
"and the local pin says there is nothing to fetch")
|
||||
eq(next(haveMods), nil, "with nothing installed either way")
|
||||
local oddAfter = drawAndCapture(odd)
|
||||
check(oddAfter:find("Break the seal", 1, true) ~= nil,
|
||||
"and the seal is still the fallback it was")
|
||||
|
||||
-- Breaking the seal still works exactly as it did, install button or not.
|
||||
local sealStill = fillPage("odd_cart")
|
||||
local oddScope = sealStill:slotScope("red")
|
||||
sealStill:_newSlot(oddScope)
|
||||
local oddSlot = sealStill.activeSlot[oddScope]
|
||||
check(type(oddSlot) == "string", "the cart page has a loaded save slot")
|
||||
eq(sealStill:pressBreakSeal("red"), false, "the first press still only arms")
|
||||
eq(SaveData.slotSealBroken("odd_cart", oddSlot), false, "breaking nothing")
|
||||
eq(sealStill:pressBreakSeal("red"), true, "and the second still breaks it")
|
||||
eq(SaveData.slotSealBroken("odd_cart", oddSlot), true, "on that slot")
|
||||
eq(sealStill:cartPlan("red").refused, false, "so the cart plays")
|
||||
|
||||
love.data.hash, love.data.encode = savedData.hash, savedData.encode
|
||||
ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases =
|
||||
realFetchBegin, realFetchPump
|
||||
ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip =
|
||||
realDlBegin, realDlPump
|
||||
LauncherMods.installDownloadedZip = realInstallDownloaded
|
||||
-- Later blocks audit the picker's layout, which counts the carts on red.
|
||||
CartStore.uninstall("fill_cart")
|
||||
CartStore.uninstall("odd_cart")
|
||||
|
||||
LauncherMods.list = realModList
|
||||
window(1280, 720)
|
||||
|
||||
-- ------- the MODS tab under an active cart
|
||||
|
||||
-- the rows the base game's own list would hand back, so the panel with no
|
||||
-- cart active has something real to disagree with the carts about
|
||||
local PIN_MODS = {
|
||||
fakeRow({ id = "pin_on", name = "Pin On", version = "1.0.0" }),
|
||||
fakeRow({ id = "pin_off", name = "Pin Off", version = "1.0.0",
|
||||
enabled = false,
|
||||
enabledByVersion = { red = false, blue = false, yellow = false,
|
||||
gold = false, silver = false } }),
|
||||
}
|
||||
|
||||
local function localPin(id, off)
|
||||
local entry = { id = id, source = "local", version = "1.0.0" }
|
||||
if off then entry.enabled = false end
|
||||
return entry
|
||||
end
|
||||
|
||||
local PIN_SET = { localPin("pin_on"), localPin("pin_off", true) }
|
||||
local PIN_ORDER = { "pin_on", "pin_off" }
|
||||
|
||||
install({ id = "plus_mods", title = "Plus Mods", seal = "sealed+",
|
||||
shell = "#2b8a3e", mods = PIN_SET, load_order = PIN_ORDER })
|
||||
install({ id = "hard_mods", title = "Hard Mods", seal = "sealed",
|
||||
shell = "#8a2b3e", mods = PIN_SET, load_order = PIN_ORDER })
|
||||
install({ id = "gap_mods", title = "Gap Mods", seal = "sealed+",
|
||||
shell = "#3e2b8a",
|
||||
mods = { localPin("pin_on"), localPin("ghost_mod") },
|
||||
load_order = { "pin_on", "ghost_mod" } })
|
||||
|
||||
local realSetEnabled, realSetAllEnabled = LauncherMods.setEnabled, LauncherMods.setAllEnabled
|
||||
local perGameWrites = 0
|
||||
LauncherMods.setEnabled = function(...)
|
||||
perGameWrites = perGameWrites + 1
|
||||
return realSetEnabled(...)
|
||||
end
|
||||
LauncherMods.setAllEnabled = function(...)
|
||||
perGameWrites = perGameWrites + 1
|
||||
return realSetAllEnabled(...)
|
||||
end
|
||||
LauncherMods.list = function() return PIN_MODS end
|
||||
|
||||
-- the base game's own answer for the same mod, which nothing below may move
|
||||
local seeded = SaveData.loadOptions()
|
||||
SaveData.setModEnabled(seeded, "pin_off", false, "red")
|
||||
SaveData.setModEnabled(seeded, "pin_on", true, "red")
|
||||
SaveData.saveOptions(seeded)
|
||||
|
||||
local function rowsById(imp)
|
||||
local byId = {}
|
||||
for _, row in ipairs(imp.mods or {}) do byId[row.id] = row end
|
||||
return byId
|
||||
end
|
||||
|
||||
local plain = freshLauncher()
|
||||
plain.tab = "mods"
|
||||
plain.ready.red = true
|
||||
plain:_selectCart("red", nil)
|
||||
plain:_setModScope("red")
|
||||
eq(#plain.mods, 2, "with no cart the panel lists the installed mods")
|
||||
eq(plain.mods[1].cartPin, nil, "which are not marked as any cart's")
|
||||
check(type(plain.mods[1].enabledByVersion) == "table",
|
||||
"and still carry their per-game answers")
|
||||
perGameWrites = 0
|
||||
plain:_toggleMod("pin_on", nil, "red")
|
||||
eq(perGameWrites, 1, "a toggle with no cart still writes the per-game flag")
|
||||
eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), false,
|
||||
"which is what changed")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_on"), nil,
|
||||
"and no cart scope was touched")
|
||||
SaveData.setModEnabled(seeded, "pin_on", true, "red")
|
||||
SaveData.saveOptions(seeded)
|
||||
|
||||
local pins = freshLauncher()
|
||||
pins.tab = "mods"
|
||||
pins.ready.red = true
|
||||
pins:_selectCart("red", "plus_mods")
|
||||
pins:_setModScope("red")
|
||||
eq(#pins.mods, 2, "an active cart makes the panel list its pins")
|
||||
local pinRows = rowsById(pins)
|
||||
eq(pinRows.pin_on.cartPin, true, "each row is marked as the cart's")
|
||||
eq(pinRows.pin_on.cartId, "plus_mods", "naming the cart it came from")
|
||||
eq(pinRows.pin_on.enabled, true, "a pin shipped switched on shows on")
|
||||
eq(pinRows.pin_off.enabled, false, "a pin shipped switched off shows off")
|
||||
eq(pinRows.pin_off.cartTogglable, true, "sealed+ hands every pin's switch over")
|
||||
eq(pinRows.pin_on.enabledByVersion, nil,
|
||||
"and a cart row carries no per-game answer, because a cart is one game")
|
||||
|
||||
local pinText = drawAndCapture(pins)
|
||||
check(pinText:find("PINNED", 1, true) ~= nil,
|
||||
"the list says on every row that these are the cart's mods")
|
||||
check(pinText:find("Plus Mods", 1, true) ~= nil, "and names the cart above them")
|
||||
check(pinText:find("In this cart:", 1, true) ~= nil,
|
||||
"with a switch that answers the cart, not the game")
|
||||
|
||||
perGameWrites = 0
|
||||
pins:_toggleMod("pin_off", nil, "red")
|
||||
local afterOn = SaveData.loadOptions()
|
||||
eq(SaveData.cartModEnabled(afterOn, "plus_mods", "pin_off"), true,
|
||||
"a sealed+ toggle writes the player's answer into the cart's scope")
|
||||
eq(perGameWrites, 0, "and never through the per-game path")
|
||||
eq(SaveData.modEnabled(afterOn, "pin_off", "red"), false,
|
||||
"so the base game's flag for that mod is untouched")
|
||||
eq(SaveData.modEnabled(afterOn, "pin_on", "red"), true, "as is every other")
|
||||
eq(rowsById(pins).pin_off.enabled, true, "the row follows the new answer")
|
||||
|
||||
local pinScope = pins:slotScope("red")
|
||||
pins:_ensureSlots(pinScope)
|
||||
local pinSlot = pins.activeSlot[pinScope]
|
||||
eq(SaveData.slotSealBroken("plus_mods", pinSlot or "slot1"), false,
|
||||
"switching a sealed+ pin does not break the cart's seal")
|
||||
eq(SaveData.isSealBroken(), false, "nor the session's")
|
||||
eq(pins:cartPlan("red").broken, false, "and the plan still reads it as intact")
|
||||
|
||||
pins:_toggleMod("pin_off", nil, "red")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
|
||||
"pressing again switches it back off, still in the cart's scope")
|
||||
|
||||
perGameWrites = 0
|
||||
pins:_setAllMods(true)
|
||||
eq(perGameWrites, 0, "Enable all cannot reach a cart's mod set")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
|
||||
"so no pin moved")
|
||||
check(tostring(pins.modNotice.text):find("Plus Mods", 1, true) ~= nil,
|
||||
"and the panel says which cart is deciding")
|
||||
eq(pins.modNotice.ok, false, "as a refusal")
|
||||
pins:_setAllMods(false)
|
||||
eq(perGameWrites, 0, "Disable all cannot either")
|
||||
eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), true,
|
||||
"and the base game's list is where it was")
|
||||
|
||||
pins:_toggleMod("wide_gym", nil, "red")
|
||||
check(tostring(pins.modNotice.text):find("cannot be added to", 1, true) ~= nil,
|
||||
"a mod the cart does not pin cannot be switched on from here")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "wide_gym"), nil,
|
||||
"and nothing is written for it")
|
||||
|
||||
pins.safeMode = true
|
||||
pins:_toggleMod("pin_off", nil, "red")
|
||||
check(tostring(pins.modNotice.text):find("Safe mode", 1, true) ~= nil,
|
||||
"safe mode still refuses a cart toggle first")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
|
||||
"and writes nothing")
|
||||
pins.safeMode = false
|
||||
|
||||
local sealedPins = freshLauncher()
|
||||
sealedPins.tab = "mods"
|
||||
sealedPins.ready.red = true
|
||||
sealedPins:_selectCart("red", "hard_mods")
|
||||
sealedPins:_setModScope("red")
|
||||
local hardRows = rowsById(sealedPins)
|
||||
eq(hardRows.pin_off.enabled, false, "a sealed cart's off pin shows off")
|
||||
eq(hardRows.pin_off.cartTogglable, false, "and hands no switch over")
|
||||
eq(hardRows.pin_on.cartTogglable, false, "nor does the pin it ships on")
|
||||
perGameWrites = 0
|
||||
sealedPins:_toggleMod("pin_off", nil, "red")
|
||||
eq(perGameWrites, 0, "a sealed pin never reaches the per-game path either")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "hard_mods", "pin_off"), nil,
|
||||
"and a press under a sealed cart writes nothing")
|
||||
eq(sealedPins.modNotice.ok, false, "the press is refused")
|
||||
check(tostring(sealedPins.modNotice.text):find("sealed", 1, true) ~= nil,
|
||||
"with the panel saying why rather than doing nothing")
|
||||
check(tostring(sealedPins.modNotice.text):find("Break the seal", 1, true) ~= nil,
|
||||
"and pointing at the one way to change it")
|
||||
local hardScope = sealedPins:slotScope("red")
|
||||
sealedPins:_ensureSlots(hardScope)
|
||||
eq(SaveData.slotSealBroken("hard_mods",
|
||||
sealedPins.activeSlot[hardScope] or "slot1"), false,
|
||||
"a refused press breaks no seal of its own")
|
||||
local hardText = drawAndCapture(sealedPins)
|
||||
check(hardText:find("Pinned, sealed:", 1, true) ~= nil,
|
||||
"and the row itself reads as locked")
|
||||
|
||||
local gapPins = freshLauncher()
|
||||
gapPins.tab = "mods"
|
||||
gapPins.ready.red = true
|
||||
gapPins:_selectCart("red", "gap_mods")
|
||||
gapPins:_setModScope("red")
|
||||
local gapRows = rowsById(gapPins)
|
||||
eq(#gapPins.mods, 2, "a pin that is not installed is still listed")
|
||||
eq(gapRows.ghost_mod.status, "missing", "as missing")
|
||||
eq(gapRows.ghost_mod.enabled, false, "and switched off")
|
||||
gapPins:_toggleMod("ghost_mod", nil, "red")
|
||||
check(tostring(gapPins.modNotice.text):find("not installed", 1, true) ~= nil,
|
||||
"switching it says so instead of writing an answer for a mod that is absent")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "gap_mods", "ghost_mod"), nil,
|
||||
"and writes nothing")
|
||||
|
||||
pins:_selectCart("red", nil)
|
||||
pins:_setModScope("red")
|
||||
local backRows = rowsById(pins)
|
||||
eq(#pins.mods, 2, "choosing the base game lists the player's own mods again")
|
||||
eq(backRows.pin_on.cartPin, nil, "with no cart marks left on them")
|
||||
check(type(backRows.pin_on.enabledByVersion) == "table",
|
||||
"and their per-game answers back")
|
||||
eq(backRows.pin_off.enabled, false,
|
||||
"reading exactly what the base game's flags say")
|
||||
perGameWrites = 0
|
||||
pins:_toggleMod("pin_off", nil, "red")
|
||||
eq(perGameWrites, 1, "and a toggle is a per-game write once more")
|
||||
eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_off", "red"), true,
|
||||
"which lands in the game's own flags")
|
||||
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
|
||||
"leaving the cart's answer where the player left it")
|
||||
|
||||
LauncherMods.setEnabled, LauncherMods.setAllEnabled = realSetEnabled, realSetAllEnabled
|
||||
LauncherMods.list = realModList
|
||||
|
||||
local function clipped(r)
|
||||
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
|
||||
if r.clip then
|
||||
@@ -540,6 +1083,22 @@ for _, size in ipairs(SIZES) do
|
||||
if ok then
|
||||
auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"),
|
||||
"Custom Carts")
|
||||
-- The refused card carries two chips now, so both have to stay on it.
|
||||
if cart then
|
||||
local sawFill = false
|
||||
for _, r in ipairs(Kit.audit or {}) do
|
||||
local label = tostring(r.label)
|
||||
if label == "Install required mods" or label == "Break the seal" then
|
||||
sawFill = sawFill or label == "Install required mods"
|
||||
check(r.x >= -0.5 and r.x + r.w <= W + 0.5
|
||||
and r.y >= -0.5 and r.y + r.h <= H + 0.5,
|
||||
("%dx%d cart card: %q stays inside the window")
|
||||
:format(W, H, label))
|
||||
end
|
||||
end
|
||||
check(sawFill,
|
||||
("%dx%d cart card: drew Install required mods"):format(W, H))
|
||||
end
|
||||
end
|
||||
Kit.audit = nil
|
||||
|
||||
@@ -595,6 +1154,303 @@ for _, size in ipairs(SIZES) do
|
||||
end
|
||||
Kit.audit = nil
|
||||
end
|
||||
LauncherMods.list = function() return PIN_MODS end
|
||||
for _, size in ipairs(SIZES) do
|
||||
local W, H = size[1], size[2]
|
||||
window(W, H)
|
||||
for _, cart in ipairs({ "plus_mods", "hard_mods" }) do
|
||||
local panel = freshLauncher()
|
||||
panel.tab = "mods"
|
||||
panel.ready.red = true
|
||||
panel:_selectCart("red", cart)
|
||||
panel:_setModScope("red")
|
||||
LauncherView.draw(panel)
|
||||
Kit.audit = {}
|
||||
local ok, err = pcall(LauncherView.draw, panel)
|
||||
Kit.audit = ok and Kit.audit or nil
|
||||
check(ok, ("%dx%d %s mods draws: %s"):format(W, H, cart, tostring(err)))
|
||||
if ok then auditFrame(("%dx%d %s mods"):format(W, H, cart)) end
|
||||
Kit.audit = nil
|
||||
end
|
||||
end
|
||||
LauncherMods.list = realModList
|
||||
|
||||
-- ------- FIND tab: browsing the index's carts instead of its mods
|
||||
--
|
||||
-- The feed carries carts beside mods at the same schema_version, so the panel
|
||||
-- has a Mods / Carts switch. Installing a cart from a listing goes through
|
||||
-- CartStore.install with the downloaded bytes, never the mod installer.
|
||||
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local INDEX_CART = {
|
||||
id = "indexed_cart", title = "Indexed Cart", author = "Ren",
|
||||
version = "1.4.0", base = "silver", seal = "sealed",
|
||||
repo = "https://github.com/ren/indexed-cart",
|
||||
github = "ren/indexed-cart",
|
||||
mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
||||
version = "0.4.1", sha256 = SHA } },
|
||||
update_check = "ok",
|
||||
latest = { version = "1.4.0", tag = "v1.4.0",
|
||||
zip = { name = "indexed_cart-1.4.0.zip",
|
||||
url = "https://example.test/indexed_cart-1.4.0.zip" } },
|
||||
}
|
||||
|
||||
local INDEX_FEED = ModIndex.parse(Json.encode({
|
||||
schema_version = 1,
|
||||
categories = { "GAMEPLAY" },
|
||||
base_games = { "red", "blue", "yellow", "gold", "silver" },
|
||||
mods = {
|
||||
{ id = "rare_soda", title = "Rare Soda", author = "Ren", version = "0.4.1",
|
||||
categories = { "GAMEPLAY" }, update_check = "off" },
|
||||
{ id = "true_colour", title = "True Colour", author = "Sam",
|
||||
version = "1.0.0", categories = { "ART" }, update_check = "off" },
|
||||
},
|
||||
carts = {
|
||||
INDEX_CART,
|
||||
{ id = "gold_rush", title = "Gold Rush", author = "Sam", version = "3.0.0",
|
||||
base = "gold", seal = "open", repo = "https://github.com/sam/gold-rush",
|
||||
mods = { { id = "steps", source = "github", repo = "sam/steps",
|
||||
version = "1.0.0", sha256 = SHA } },
|
||||
update_check = "off" },
|
||||
},
|
||||
}))
|
||||
check(INDEX_FEED ~= nil, "the find-tab fixture feed parses")
|
||||
|
||||
window(1280, 720)
|
||||
|
||||
local find = freshLauncher()
|
||||
eq(find.findKind, "mods", "the Find tab browses mods by default")
|
||||
check(find.findBase == nil, "with no base-game filter armed")
|
||||
find.tab = "find"
|
||||
find.modScope = nil
|
||||
find.findLoaded = true
|
||||
find.findSources = { { feed = "https://example.test/data/index.json",
|
||||
base = "https://example.test/",
|
||||
label = "example/index" } }
|
||||
find.findIndex = { mods = INDEX_FEED.mods, carts = INDEX_FEED.carts,
|
||||
categories = { "GAMEPLAY", "ART" },
|
||||
baseGames = ModIndex.baseGamesIn(INDEX_FEED) }
|
||||
|
||||
eq(#find:_findRows(), 2, "the default rows are the feed's mods")
|
||||
eq(find:_findRows()[1].id, "rare_soda", "and they are the mod entries")
|
||||
|
||||
local modsFind = drawAndCapture(find)
|
||||
check(modsFind:find("Mods (2)", 1, true) ~= nil,
|
||||
"the switch says how many mods the feed lists")
|
||||
check(modsFind:find("Carts (2)", 1, true) ~= nil, "and how many carts")
|
||||
check(modsFind:find("Rare Soda", 1, true) ~= nil, "mods are what is listed")
|
||||
|
||||
find:_setFindKind("carts")
|
||||
eq(find.findKind, "carts", "the switch flips to carts")
|
||||
eq(#find:_findRows(), 2, "and the rows become the feed's carts")
|
||||
check(ModIndex.isCart(find:_findRows()[1]), "which are cart entries")
|
||||
|
||||
local cartsFind = drawAndCapture(find)
|
||||
check(cartsFind:find("Indexed Cart", 1, true) ~= nil,
|
||||
"a cart listing is drawn on the Carts half")
|
||||
check(cartsFind:find("Rare Soda", 1, true) == nil,
|
||||
"and the mod listings are gone")
|
||||
check(cartsFind:find("Search carts", 1, true) ~= nil,
|
||||
"the search field asks for carts")
|
||||
|
||||
-- search spans the same fields it does for a mod
|
||||
find.findQuery = "gold"
|
||||
eq(#find:_findRows(), 1, "searching a cart title narrows the list")
|
||||
eq(find:_findRows()[1].id, "gold_rush", "to that cart")
|
||||
find.findQuery = "Ren"
|
||||
eq(find:_findRows()[1].id, "indexed_cart", "searching by author works too")
|
||||
find.findQuery = ""
|
||||
|
||||
-- the cart-side filter is by base game, and the popup offers only the base
|
||||
-- games the feed's carts actually play as
|
||||
find.findBase = "gold"
|
||||
eq(#find:_findRows(), 1, "filtering by base game keeps that game's carts")
|
||||
eq(find:_findRows()[1].id, "gold_rush", "and only those")
|
||||
local filterText
|
||||
do
|
||||
find._filterPopup = true
|
||||
filterText = drawAndCapture(find)
|
||||
find._filterPopup = nil
|
||||
end
|
||||
check(filterText:find("Filter by base game", 1, true) ~= nil,
|
||||
"the filter popup filters carts by base game")
|
||||
check(filterText:find("Filter by category", 1, true) == nil,
|
||||
"not by a category no cart has")
|
||||
find.findBase = nil
|
||||
|
||||
-- MODS-tab scope still applies: a cart plays as exactly one game
|
||||
find.modScope = "silver"
|
||||
eq(#find:_findRows(), 1, "a scoped launcher lists only that game's carts")
|
||||
eq(find:_findRows()[1].id, "indexed_cart", "the one based on silver")
|
||||
find.modScope = nil
|
||||
|
||||
find:_setFindKind("mods")
|
||||
eq(#find:_findRows(), 2, "switching back restores the mod rows")
|
||||
find:_setFindKind("carts")
|
||||
|
||||
-- ------- installing a cart from a listing
|
||||
|
||||
local INDEX_CART_BYTES = CartManifest.encode(CartManifest.parse(cartTable({
|
||||
id = "indexed_cart", title = "Indexed Cart", version = "1.4.0",
|
||||
base = "silver", shell = "#d8c24a" })))
|
||||
|
||||
eq(#find:_ensureCarts("silver"), 0, "silver has no carts before the install")
|
||||
|
||||
local entry
|
||||
for _, row in ipairs(find:_findRows()) do
|
||||
if row.id == "indexed_cart" then entry = row end
|
||||
end
|
||||
check(entry ~= nil, "the cart listing is on screen to install")
|
||||
|
||||
local realBegin, realPump = ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip
|
||||
local asked = {}
|
||||
ModUpdate.beginDownloadZip = function(url, name)
|
||||
asked[#asked + 1] = { url = url, name = name }
|
||||
return { name = name }
|
||||
end
|
||||
ModUpdate.pumpDownloadZip = function(h)
|
||||
love.filesystem.write(h.name, INDEX_CART_BYTES)
|
||||
return true, h.name
|
||||
end
|
||||
|
||||
find:_findConfirmInstall(entry)
|
||||
check(find._modConfirm ~= nil, "installing a cart arms a confirm first")
|
||||
eq(find._modConfirm.title, "Install cart", "and it says it is a cart")
|
||||
eq(find._modConfirm.indexEntry, entry, "carrying the listing it will install")
|
||||
do
|
||||
local lines = table.concat(find._modConfirm.lines or {}, "\n")
|
||||
check(lines:find("Pins 1 mod", 1, true) ~= nil,
|
||||
"the confirm says how many mods the cart pins")
|
||||
check(lines:find("installed separately", 1, true) ~= nil,
|
||||
"and that installing the cart does not install them")
|
||||
end
|
||||
find._modConfirm = nil
|
||||
|
||||
find:_findInstall(entry)
|
||||
check(find._cartInstall ~= nil, "the cart download is in flight")
|
||||
check(find._modInstall == nil, "and it is not a mod install")
|
||||
eq(asked[1].url, "https://example.test/indexed_cart-1.4.0.zip",
|
||||
"the release the listing resolves is what gets downloaded")
|
||||
check(asked[1].name:find(".g1rcart", 1, true) ~= nil,
|
||||
"into a cart file, not a mod zip")
|
||||
|
||||
-- single in flight: a mod install cannot start on top of a cart download
|
||||
find:_beginModInstall({ modId = "rare_soda", name = "Rare Soda", notice = "find",
|
||||
release = { version = "0.4.1",
|
||||
zip = { url = "https://example.test/rare_soda.zip" } } })
|
||||
check(find._modInstall == nil, "a mod install cannot race a cart install")
|
||||
eq(#asked, 1, "and nothing else was downloaded")
|
||||
|
||||
find:_pumpCartInstall()
|
||||
check(find._cartInstall == nil, "the cart install completes")
|
||||
check(find.findNotice ~= nil and find.findNotice.ok,
|
||||
"and reports success on the Find tab")
|
||||
|
||||
local installedCart = CartStore.get("indexed_cart")
|
||||
check(installedCart ~= nil, "the cart is installed through CartStore")
|
||||
if installedCart then
|
||||
eq(installedCart.title, "Indexed Cart", "with its own title")
|
||||
eq(installedCart.base, "silver", "and the game it plays as")
|
||||
end
|
||||
|
||||
-- the per-version cache is the whole point: without invalidating it the new
|
||||
-- cart would not show in Custom Carts until a relaunch
|
||||
eq(#find.carts["silver"], 1,
|
||||
"the cached cart list for that game is refreshed in place")
|
||||
eq(find.carts["silver"][1].id, "indexed_cart", "with the new cart in it")
|
||||
eq(find:_findInstalledCarts()["indexed_cart"], "1.4.0",
|
||||
"and the Find rows now read it as installed")
|
||||
|
||||
-- the Custom Carts picker draws the new cart in the same session, with no
|
||||
-- refresh call from here and no relaunch
|
||||
do
|
||||
local picker = freshLauncher()
|
||||
picker.tab = "silver"
|
||||
picker.ready.silver = true
|
||||
picker._cartPopup = "silver"
|
||||
local pickerText = drawAndCapture(picker)
|
||||
check(pickerText:find("Indexed Cart", 1, true) ~= nil,
|
||||
"the Custom Carts picker lists the freshly installed cart")
|
||||
end
|
||||
do
|
||||
local same = drawAndCapture(find)
|
||||
check(same ~= nil, "the Find tab still draws after an install")
|
||||
end
|
||||
|
||||
-- ------- the pins prompt
|
||||
--
|
||||
-- Installing a cart never installs its mods behind the player's back, but a
|
||||
-- cart whose pins are missing will not start, so it asks once and routes a
|
||||
-- yes at the existing hash-verified fill queue.
|
||||
|
||||
check(find._modConfirm ~= nil, "a cart with missing pins prompts after install")
|
||||
eq(find._modConfirm.kind, "cartPins", "through the launcher's confirm modal")
|
||||
eq(find._modConfirm.version, "silver", "for the game the cart plays as")
|
||||
eq(find._modConfirm.id, "indexed_cart", "naming the cart just installed")
|
||||
do
|
||||
local lines = table.concat(find._modConfirm.lines or {}, "\n")
|
||||
check(lines:find("Indexed Cart pins 1 mod", 1, true) ~= nil,
|
||||
"the prompt names the cart and how many pins are missing")
|
||||
end
|
||||
check(find.activeCart["silver"] == nil,
|
||||
"and asking does not select the cart on its own")
|
||||
|
||||
-- yes routes at pressInstallCartMods, which owns the hash check
|
||||
do
|
||||
local realFetchRel = ModUpdate.beginFetchReleases
|
||||
local realPumpRel = ModUpdate.pumpFetchReleases
|
||||
local asked_repos = {}
|
||||
ModUpdate.beginFetchReleases = function(repo)
|
||||
asked_repos[#asked_repos + 1] = repo
|
||||
return { repo = repo }
|
||||
end
|
||||
ModUpdate.pumpFetchReleases = function() return true, nil, "no releases" end
|
||||
find._modConfirm = nil
|
||||
find:_installCartPins("silver", "indexed_cart")
|
||||
eq(find.activeCart["silver"], "indexed_cart",
|
||||
"saying yes selects the cart the pins belong to")
|
||||
for _ = 1, 8 do find:_pumpCartFill() end
|
||||
eq(asked_repos[1], "ren/rare-soda",
|
||||
"and the fill queue resolves the cart's own pin")
|
||||
check(find.cartFillNotice ~= nil and not find.cartFillNotice.ok,
|
||||
"reporting per-mod failures through the existing notice")
|
||||
find:_selectCart("silver", nil)
|
||||
find.cartFillNotice = nil
|
||||
ModUpdate.beginFetchReleases = realFetchRel
|
||||
ModUpdate.pumpFetchReleases = realPumpRel
|
||||
end
|
||||
|
||||
-- a cart whose pins are all installed must not prompt at all
|
||||
do
|
||||
local realList = LauncherMods.list
|
||||
LauncherMods.list = function()
|
||||
return { { id = "rare_soda", version = "0.4.1",
|
||||
manifest = { id = "rare_soda", version = "0.4.1" } } }
|
||||
end
|
||||
local satisfied = freshLauncher()
|
||||
eq(#satisfied:_cartPinsMissing("silver", "indexed_cart"), 0,
|
||||
"a cart with every pin installed is missing none")
|
||||
satisfied:_offerCartPins({ id = "indexed_cart", title = "Indexed Cart",
|
||||
base = "silver" })
|
||||
check(satisfied._modConfirm == nil, "so it does not prompt")
|
||||
LauncherMods.list = realList
|
||||
end
|
||||
|
||||
-- a download that is not a cart at all fails loudly instead of installing
|
||||
ModUpdate.pumpDownloadZip = function(h)
|
||||
love.filesystem.write(h.name, "not a cart at all")
|
||||
return true, h.name
|
||||
end
|
||||
find.findNotice = nil
|
||||
find:_findInstall(entry)
|
||||
find:_pumpCartInstall()
|
||||
check(find._cartInstall == nil, "a junk download ends the job")
|
||||
check(find.findNotice ~= nil and not find.findNotice.ok,
|
||||
"and says so rather than installing anything")
|
||||
|
||||
ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip = realBegin, realPump
|
||||
|
||||
T.finish("cart launcher")
|
||||
|
||||
@@ -198,8 +198,102 @@ rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
|
||||
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
|
||||
"does not pin", "a load_order naming an unpinned mod")
|
||||
|
||||
rejects(function(c) c.mods[1].enabled = "no" end, "enabled must be",
|
||||
"a string enabled flag")
|
||||
rejects(function(c) c.mods[1].enabled = 0 end, "enabled must be",
|
||||
"a numeric enabled flag")
|
||||
|
||||
rejects(function(c) c.options = "fast" end, "cart options must be a table",
|
||||
"a non-table cart options block")
|
||||
rejects(function(c) c.options = { [2] = 1 } end, "cart option keys",
|
||||
"a numeric cart option key")
|
||||
rejects(function(c) c.options = { textSpeed = { 1 } } end,
|
||||
"must be a string, number or boolean", "a table cart option value")
|
||||
|
||||
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
|
||||
|
||||
-- ------- a pin the cart ships switched off
|
||||
|
||||
T.eq(cart.mods[1].enabled, nil, "a pin that says nothing carries no enabled flag")
|
||||
T.eq(CartManifest.modEnabled(cart.mods[1]), true, "and defaults to enabled")
|
||||
T.eq(CartManifest.modEnabled(nil), false, "modEnabled refuses a non-entry")
|
||||
|
||||
local offRaw = baseCart()
|
||||
offRaw.mods[1].enabled = false
|
||||
local offCart = CartManifest.parse(offRaw)
|
||||
T.check(offCart ~= nil, "a pin switched off parses")
|
||||
T.eq(offCart.mods[1].enabled, false, "and keeps the flag")
|
||||
T.eq(CartManifest.modEnabled(offCart.mods[1]), false, "which reads back as off")
|
||||
T.eq(offCart.mods[1].sha256, SHA, "a switched-off pin is pinned like any other")
|
||||
T.eq(offCart.mods[1].options.flavour, "grape", "with its options captured")
|
||||
T.same(CartManifest.decode(CartManifest.encode(offCart)), offCart,
|
||||
"a switched-off pin survives the file round trip")
|
||||
T.neq(CartManifest.hash(offCart), CartManifest.hash(cart),
|
||||
"and is part of the cart hash")
|
||||
|
||||
local onRaw = baseCart()
|
||||
onRaw.mods[1].enabled = true
|
||||
T.eq(CartManifest.canonical(CartManifest.parse(onRaw)),
|
||||
CartManifest.canonical(cart),
|
||||
"an explicit enabled = true is the same cart as saying nothing")
|
||||
|
||||
-- ------- settings the cart ships
|
||||
|
||||
local optRaw = baseCart()
|
||||
optRaw.options = { textSpeed = 1, animations = false, ruleset = "gen1_faithful" }
|
||||
local optCart, optErr = CartManifest.parse(optRaw)
|
||||
T.check(optCart ~= nil, "a cart that ships settings parses: " .. tostring(optErr))
|
||||
T.eq(optCart.options.textSpeed, 1, "the shipped number survives")
|
||||
T.eq(optCart.options.animations, false, "the shipped boolean survives")
|
||||
T.eq(optCart.options.ruleset, "gen1_faithful", "the shipped string survives")
|
||||
T.eq(cart.options, nil, "a cart that ships none carries no options table")
|
||||
T.same(CartManifest.decode(CartManifest.encode(optCart)), optCart,
|
||||
"shipped settings survive the file round trip")
|
||||
T.neq(CartManifest.hash(optCart), CartManifest.hash(cart),
|
||||
"and are part of the cart hash")
|
||||
T.neq(optCart.options, optCart.mods[1].options,
|
||||
"a cart's own settings are not a mod's")
|
||||
|
||||
-- ------- the sealed+ seal
|
||||
|
||||
local plusRaw = baseCart()
|
||||
plusRaw.seal = "sealed+"
|
||||
local plusCart, plusErr = CartManifest.parse(plusRaw)
|
||||
T.check(plusCart ~= nil, "a sealed+ cart parses: " .. tostring(plusErr))
|
||||
T.eq(plusCart.seal, "sealed+", "the seal survives")
|
||||
T.same(CartManifest.decode(CartManifest.encode(plusCart)), plusCart,
|
||||
"a sealed+ cart round trips through a file")
|
||||
T.neq(CartManifest.hash(plusCart), CartManifest.hash(cart),
|
||||
"and sealed+ is part of the cart hash")
|
||||
T.eq(CartManifest.SEALS["sealed+"], true, "sealed+ is a known seal")
|
||||
|
||||
-- ------- the launcher fields the file round trip used to drop
|
||||
|
||||
local dressed = baseCart()
|
||||
dressed.finish = "sparkle+holo"
|
||||
dressed.speeds = { 1, 2 }
|
||||
local dressedCart = CartManifest.parse(dressed)
|
||||
local reread = CartManifest.decode(CartManifest.encode(dressedCart))
|
||||
T.eq(reread.finish, "sparkle+holo", "a cart's finish survives the file round trip")
|
||||
T.same(reread.speeds, { 1, 2 }, "and so does its speed ladder")
|
||||
T.eq(CartManifest.hash(reread), CartManifest.hash(dressedCart),
|
||||
"so an installed copy hashes the same as the one that was written")
|
||||
|
||||
-- ------- a cart that uses none of the above serializes exactly as it always did
|
||||
|
||||
T.eq(CartManifest.canonical(cart),
|
||||
"[cart].6:author$3:Ren.4:base$3:red.6:engine$7:>=1.4.0.2:id$10:kanto_plus"
|
||||
.. ".5:label$13:art/label.png.4:repo$14:ren/kanto-plus.4:seal$6:sealed"
|
||||
.. ".5:shell$7:#3fa9f5.7:summary$20:A sealed set of five.5:title$10:Kanto Plus"
|
||||
.. ".7:version$5:1.2.0[mods]@9:rare_soda.2:id$9:rare_soda"
|
||||
.. ".4:repo$13:ren/rare-soda.6:sha256$64:" .. SHA
|
||||
.. ".6:source$6:github.7:version$5:0.4.1[options].5:fizzyT.7:flavour$5:grape"
|
||||
.. ".9:sweetness#3@9:hard_mode.4:file#99123.2:id$9:hard_mode.3:md5$32:" .. MD5
|
||||
.. ".3:mod#4821.6:source$10:gamebanana[options][order]@9:rare_soda@9:hard_mode",
|
||||
"the canonical string of a cart using no new field is byte for byte the old one")
|
||||
T.eq(CartManifest.hash(cart), "2a8fbbacbd1df3f349b6a6ed387fa036",
|
||||
"so its hash, and every save stamped with it, is unchanged")
|
||||
|
||||
local open = baseCart()
|
||||
open.seal = "open"
|
||||
open.repo = nil
|
||||
|
||||
@@ -264,6 +264,90 @@ do
|
||||
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
|
||||
end
|
||||
|
||||
-- ------- cart-scoped settings
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
local base = SaveData.loadOptions()
|
||||
base.textSpeed = 5
|
||||
base.musicVol = 2
|
||||
base.speedBattle = 1
|
||||
T.check(SaveData.saveOptions(base), "the base game writes its own settings")
|
||||
local plain = files["options.lua"]
|
||||
|
||||
SaveData.setCart("nuzlocke", "hash1")
|
||||
local inCart = SaveData.loadOptions()
|
||||
T.eq(inCart.textSpeed, 5, "a cart starts from the global value")
|
||||
T.eq(inCart.musicVol, 2, "for every key, scoped or not")
|
||||
inCart.textSpeed = 1
|
||||
inCart.musicVol = 7
|
||||
inCart.speedBattle = 4
|
||||
T.check(SaveData.saveOptions(inCart), "change some settings inside the cart")
|
||||
|
||||
local stored = options(files)
|
||||
T.eq(stored.textSpeed, 5, "the global text speed is untouched")
|
||||
T.eq(stored.cartOptions.nuzlocke.textSpeed, 1, "the cart keeps its own")
|
||||
T.eq(stored.cartOptions.nuzlocke.speedBattle, 4, "and its own battle speed")
|
||||
T.eq(stored.musicVol, 7, "an unscoped setting is written globally")
|
||||
T.eq(stored.cartOptions.nuzlocke.musicVol, nil, "and never lands in the cart")
|
||||
|
||||
T.eq(SaveData.loadOptions().textSpeed, 1, "the cart reads its value back")
|
||||
T.eq(SaveData.loadOptions().musicVol, 7, "and the machine's shared volume")
|
||||
|
||||
SaveData.setCart("marathon", "hash2")
|
||||
T.eq(SaveData.loadOptions().textSpeed, 5,
|
||||
"another cart does not see the first cart's setting")
|
||||
local other = SaveData.loadOptions()
|
||||
other.textSpeed = 3
|
||||
T.check(SaveData.saveOptions(other), "the second cart sets its own")
|
||||
T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1,
|
||||
"which leaves the first cart's alone")
|
||||
|
||||
SaveData.setCart(nil)
|
||||
T.eq(SaveData.loadOptions().textSpeed, 5, "and the base game keeps the global")
|
||||
T.eq(SaveData.loadOptions().speedBattle, 1, "for every scoped key")
|
||||
|
||||
T.check(plain:find("cartOptions", 1, true) ~= nil,
|
||||
"the options file declares the overlay")
|
||||
T.check(plain:find("nuzlocke", 1, true) == nil,
|
||||
"but an install that never ran a cart names no cart in it")
|
||||
SaveData.setCart(nil)
|
||||
local vanilla = SaveData.loadOptions()
|
||||
vanilla.textSpeed = 4
|
||||
T.check(SaveData.saveOptions(vanilla), "a base-game write after playing carts")
|
||||
T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1,
|
||||
"leaves every cart's overlay intact")
|
||||
T.eq(options(files).cartOptions.marathon.textSpeed, 3, "for every cart")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.setCart("author_cart", "hash3")
|
||||
T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false,
|
||||
musicVol = 0, nonesuch = 3 }), true, "a cart seeds its shipped settings")
|
||||
local seeded = SaveData.loadOptions()
|
||||
T.eq(seeded.textSpeed, 1, "the author's text speed applies")
|
||||
T.eq(seeded.animations, false, "and the author's battle animation choice")
|
||||
T.eq(seeded.musicVol, 7, "a machine setting is never seeded")
|
||||
T.eq(options(files).cartOptions.author_cart.musicVol, nil, "not even into the cart")
|
||||
T.eq(options(files).cartOptions.author_cart.nonesuch, nil,
|
||||
"and a key outside the cart-scoped set is ignored")
|
||||
T.eq(options(files).cartOptionsSeeded.author_cart, true, "the seed is recorded")
|
||||
|
||||
local player = SaveData.loadOptions()
|
||||
player.textSpeed = 5
|
||||
T.check(SaveData.saveOptions(player), "the player then picks their own speed")
|
||||
T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false }), false,
|
||||
"a second boot re-seeds nothing")
|
||||
T.eq(SaveData.loadOptions().textSpeed, 5, "so the player's change survives")
|
||||
|
||||
SaveData.setCart(nil)
|
||||
T.eq(SaveData.loadOptions().textSpeed, 3,
|
||||
"and the base game still has the shipped default of its own")
|
||||
T.eq(SaveData.seedCartOptions({ textSpeed = 1 }), false,
|
||||
"seeding with no cart active does nothing")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("cart_saves")
|
||||
|
||||
@@ -98,6 +98,12 @@ local function pin(id, version, options)
|
||||
options = options }
|
||||
end
|
||||
|
||||
local function offPin(id, version, options)
|
||||
local p = pin(id, version, options)
|
||||
p.enabled = false
|
||||
return p
|
||||
end
|
||||
|
||||
local function boot(files)
|
||||
local data = { pokemon = {} }
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
@@ -175,6 +181,171 @@ do
|
||||
T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing")
|
||||
end
|
||||
|
||||
-- ------- a pin the cart ships switched off
|
||||
|
||||
local function withFlags(files, flags)
|
||||
local opts = SaveSerializer.decode(files["options.lua"]) or {}
|
||||
opts.mods = opts.mods or {}
|
||||
for id, on in pairs(flags) do opts.mods[id] = on end
|
||||
files["options.lua"] = SaveSerializer.encode(opts)
|
||||
return files
|
||||
end
|
||||
|
||||
local function withCartFlags(files, cartId, flags)
|
||||
local opts = SaveSerializer.decode(files["options.lua"]) or {}
|
||||
opts.cartMods = opts.cartMods or {}
|
||||
opts.cartMods[cartId] = opts.cartMods[cartId] or {}
|
||||
for id, on in pairs(flags) do opts.cartMods[cartId][id] = on end
|
||||
files["options.lua"] = SaveSerializer.encode(opts)
|
||||
return files
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("shipped_off", "sealed",
|
||||
{ pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) },
|
||||
{ "alpha", "gamma" }))
|
||||
SaveData.setCart("shipped_off", "hashA")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a sealed cart that ships a mod switched off loads")
|
||||
T.eq(names(loader.order), "alpha", "and runs only the pins it ships switched on")
|
||||
T.eq(data.pokemon.GAMMA, nil, "the switched-off pin does not run")
|
||||
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
|
||||
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
|
||||
T.check(loader.mods.gamma ~= nil, "while still being installed")
|
||||
T.eq(loader:cartStatus().pins.gamma.options.tint, "cart",
|
||||
"carrying the settings the cart shipped it with")
|
||||
end
|
||||
|
||||
do
|
||||
local files = withCartFlags(install(), "welded", { gamma = true })
|
||||
writeCart(files, cartTable("welded", "sealed",
|
||||
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
|
||||
SaveData.setCart("welded", "hashB")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a sealed cart loads with the player asking for the off pin")
|
||||
T.eq(data.pokemon.GAMMA, nil, "but sealed refuses the toggle")
|
||||
T.eq(loader.mods.gamma.enabled, false, "the pin stays exactly as the cart shipped it")
|
||||
end
|
||||
|
||||
-- ------- sealed+ : the same fixed set, switchable
|
||||
|
||||
do
|
||||
local files = withCartFlags(install(), "plus", { gamma = true })
|
||||
writeCart(files, cartTable("plus", "sealed+",
|
||||
{ pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) },
|
||||
{ "alpha", "gamma" }))
|
||||
SaveData.setCart("plus", "hashC")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a sealed+ cart loads")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.seal, "sealed+", "the report carries the new seal")
|
||||
T.eq(report.sealed, true, "which is a sealed cart")
|
||||
T.eq(report.enforced, true, "and is enforced like one")
|
||||
T.eq(names(loader.order), "alpha,gamma",
|
||||
"the player switched the cart's off pin on and it runs")
|
||||
T.eq(data.pokemon.GAMMA.name, "cart",
|
||||
"still with the cart's frozen option value, not the player's")
|
||||
T.eq(data.pokemon.BETA, nil, "a mod the cart does not pin cannot be added")
|
||||
T.eq(loader.mods.beta.enabled, false, "and stays switched off")
|
||||
end
|
||||
|
||||
do
|
||||
local files = withCartFlags(install(), "plus_off", { alpha = false })
|
||||
writeCart(files, cartTable("plus_off", "sealed+",
|
||||
{ pin("alpha"), pin("gamma") }, { "alpha", "gamma" }))
|
||||
SaveData.setCart("plus_off", "hashD")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a sealed+ cart loads with a pin the player switched off")
|
||||
T.eq(names(loader.order), "gamma", "which does not run")
|
||||
T.eq(data.pokemon.ALPHA, nil, "so its content is absent")
|
||||
T.eq(loader.mods.alpha.enabled, false, "and the row reads as off")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("plus_gap", "sealed+",
|
||||
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
|
||||
SaveData.setCart("plus_gap", "hashE")
|
||||
|
||||
local loader, _, ok = boot(files)
|
||||
T.check(not ok, "sealed+ still refuses a pin that is not installed")
|
||||
T.eq(#loader.order, 0, "and plays no subset of itself")
|
||||
T.eq(loader:cartStatus().refused, true, "the report refuses it")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
writeCart(files, cartTable("plus_seal", "sealed+",
|
||||
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
|
||||
SaveData.setCart("plus_seal", "hashF")
|
||||
local slot = SaveData.createCartSlot("plus_seal")
|
||||
SaveData.setActiveCartSlot("plus_seal", slot)
|
||||
|
||||
local loader = boot(files)
|
||||
T.check(loader:setEnabled("gamma", true), "switch a sealed+ pin on")
|
||||
T.eq(SaveData.isSealBroken(), false, "which does not break the session seal")
|
||||
T.eq(SaveData.slotSealBroken("plus_seal", slot), false,
|
||||
"nor mark the save slot modified")
|
||||
T.eq(SaveData.adoptCartSeal("plus_seal"), false,
|
||||
"so the next boot still adopts an intact seal")
|
||||
|
||||
local again, data, ok = boot(files)
|
||||
T.check(ok, "and the cart loads again")
|
||||
T.eq(names(again.order), "alpha,gamma", "with the mod the player switched on")
|
||||
T.eq(data.pokemon.BETA, nil, "and still nothing the cart does not pin")
|
||||
end
|
||||
|
||||
do
|
||||
local files = withFlags(install(), { gamma = false })
|
||||
writeCart(files, cartTable("plus_split", "sealed+",
|
||||
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
|
||||
SaveData.setCart("plus_split", "hashI")
|
||||
|
||||
local loader = boot(files)
|
||||
T.eq(loader.mods.gamma.enabled, false, "a cart's off pin starts off")
|
||||
T.check(loader:setEnabled("gamma", true), "and the player switches it on")
|
||||
local opts = options(files)
|
||||
T.eq(opts.cartMods.plus_split.gamma, true,
|
||||
"the answer is stored under the cart that asked for it")
|
||||
T.eq(SaveData.modEnabled(opts, "gamma", "red"), false,
|
||||
"and the base game's own flag for that mod is untouched")
|
||||
end
|
||||
|
||||
-- ------- an open cart hands back only the pins it ships switched off
|
||||
|
||||
do
|
||||
local files = withCartFlags(install(), "open_off", { gamma = true })
|
||||
writeCart(files, cartTable("open_off", "open",
|
||||
{ pin("beta"), offPin("gamma") }, { "beta", "gamma" }))
|
||||
SaveData.setCart("open_off", "hashG")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart with a switched-off pin loads")
|
||||
T.check(data.pokemon.GAMMA ~= nil, "the player switched it on, so it runs")
|
||||
T.eq(loader.mods.beta.enabled, true,
|
||||
"while a pin the cart says nothing about is still forced on")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("open_quiet", "open",
|
||||
{ pin("beta"), offPin("gamma") }, { "beta", "gamma" }))
|
||||
SaveData.setCart("open_quiet", "hashH")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart whose off pin the player never touched loads")
|
||||
T.eq(data.pokemon.GAMMA, nil, "with that pin off by default")
|
||||
T.eq(loader.mods.gamma.enabled, false, "and reported off")
|
||||
end
|
||||
|
||||
-- ------- a missing pin: refusal when sealed, warning when open
|
||||
|
||||
do
|
||||
|
||||
+59
-19
@@ -224,6 +224,33 @@ local empty = memfs()
|
||||
T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts")
|
||||
T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install")
|
||||
|
||||
-- ------- the fields the registry rows have to carry through the store
|
||||
|
||||
local dressedFs = memfs()
|
||||
local dressed = select(2, bytesOf({ id = "dressed", title = "Dressed",
|
||||
finish = "holo", speeds = { 1, 2 }, seal = "sealed+",
|
||||
options = { textSpeed = 1 } }))
|
||||
local dressedCart, dressedHash = CartStore.install(CartManifest.encode(dressed),
|
||||
dressedFs)
|
||||
T.check(dressedCart ~= nil, "a cart with a finish, speeds and settings installs")
|
||||
T.eq(dressedHash, CartManifest.hash(dressed), "hashing the same as it was written")
|
||||
T.same(CartStore.get("dressed", dressedFs), dressed,
|
||||
"and coming back off disk unchanged")
|
||||
|
||||
local dressedReg = SaveData.loadOptions(dressedFs).carts.dressed
|
||||
T.eq(dressedReg.finish, "holo", "the registry row carries the finish")
|
||||
T.same(dressedReg.speeds, { 1, 2 }, "and the speed ladder")
|
||||
T.eq(dressedReg.seal, "sealed+", "and the seal, spelled in full")
|
||||
|
||||
local dressedRow = CartStore.list(dressedFs)[1]
|
||||
T.eq(dressedRow.finish, "holo", "the list row carries the finish")
|
||||
T.same(dressedRow.speeds, { 1, 2 }, "and the speed ladder")
|
||||
T.eq(dressedRow.seal, "sealed+", "and the seal")
|
||||
T.eq(dressedRow.cart.options.textSpeed, 1,
|
||||
"and the settings the cart ships, on the parsed cart")
|
||||
T.eq(SaveData.loadOptions(dressedFs).carts.dressed.hash, dressedHash,
|
||||
"listing does not rewrite the registry it already agrees with")
|
||||
|
||||
local function rowSet()
|
||||
return {
|
||||
{ id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true,
|
||||
@@ -258,26 +285,33 @@ T.eq(captured.title, "My Cart", "the captured cart keeps the title")
|
||||
T.eq(captured.shell, "#ff8800", "the captured shell normalises")
|
||||
T.eq(captured.seal, "open", "the captured seal is the author's choice")
|
||||
T.eq(captured.base, "red", "the captured base is the identity's")
|
||||
T.eq(#captured.mods, 3, "only the enabled mods are pinned")
|
||||
T.eq(#captured.mods, 4, "every installed mod is pinned, switched on or off")
|
||||
T.eq(captured.load_order[1], "hard_mode", "load order follows the row order")
|
||||
T.eq(captured.load_order[2], "rare_soda", "load order follows the row order")
|
||||
T.eq(captured.load_order[3], "sprite_pack", "load order follows the row order")
|
||||
for _, entry in ipairs(captured.mods) do
|
||||
T.neq(entry.id, "off_mode", "a disabled mod is never pinned")
|
||||
end
|
||||
T.eq(captured.load_order[2], "off_mode", "including the row that is switched off")
|
||||
T.eq(captured.load_order[3], "rare_soda", "load order follows the row order")
|
||||
T.eq(captured.load_order[4], "sprite_pack", "load order follows the row order")
|
||||
|
||||
T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github")
|
||||
T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo")
|
||||
T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash")
|
||||
T.eq(captured.mods[2].source, "local", "a mod with no archive hash pins locally")
|
||||
T.eq(captured.mods[2].version, "0.4.1", "the local pin keeps the installed version")
|
||||
T.eq(captured.mods[2].repo, nil, "a local pin carries no repo")
|
||||
T.eq(captured.mods[2].sha256, nil, "a local pin carries no hash")
|
||||
T.eq(captured.mods[2].options.flavour, "grape", "the author's option values are frozen in")
|
||||
T.eq(captured.mods[2].options.sweetness, 3, "every scalar option is frozen in")
|
||||
T.eq(captured.mods[2].options.nested, nil, "a table option value is dropped")
|
||||
T.eq(captured.mods[3].source, "local", "a mod with no repo pins locally")
|
||||
T.eq(captured.mods[3].version, "0.0.0", "an unparsable version pins as 0.0.0")
|
||||
T.eq(captured.mods[1].enabled, nil, "an enabled mod pins with no enabled field")
|
||||
T.eq(CartManifest.modEnabled(captured.mods[1]), true, "and reads back as enabled")
|
||||
T.eq(captured.mods[2].id, "off_mode", "the switched-off mod is pinned in place")
|
||||
T.eq(captured.mods[2].enabled, false, "and is pinned switched off")
|
||||
T.eq(CartManifest.modEnabled(captured.mods[2]), false, "which reads back as off")
|
||||
T.eq(captured.mods[2].source, "github", "a disabled pin still resolves its source")
|
||||
T.eq(captured.mods[2].sha256, SHA2, "and still carries its archive hash")
|
||||
T.eq(captured.mods[2].options.unused, true,
|
||||
"a disabled mod's options are captured like any other")
|
||||
T.eq(captured.mods[3].source, "local", "a mod with no archive hash pins locally")
|
||||
T.eq(captured.mods[3].version, "0.4.1", "the local pin keeps the installed version")
|
||||
T.eq(captured.mods[3].repo, nil, "a local pin carries no repo")
|
||||
T.eq(captured.mods[3].sha256, nil, "a local pin carries no hash")
|
||||
T.eq(captured.mods[3].options.flavour, "grape", "the author's option values are frozen in")
|
||||
T.eq(captured.mods[3].options.sweetness, 3, "every scalar option is frozen in")
|
||||
T.eq(captured.mods[3].options.nested, nil, "a table option value is dropped")
|
||||
T.eq(captured.mods[4].source, "local", "a mod with no repo pins locally")
|
||||
T.eq(captured.mods[4].version, "0.0.0", "an unparsable version pins as 0.0.0")
|
||||
T.eq(captured.mods[1].options, nil, "a mod with no options freezes none")
|
||||
|
||||
T.eq(#unresolved, 2, "capture reports every locally pinned mod")
|
||||
@@ -309,12 +343,18 @@ pinned[4] = nil
|
||||
local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions)
|
||||
T.check(full ~= nil, "a fully pinned capture builds a cart")
|
||||
T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved")
|
||||
T.eq(full.mods[2].source, "github", "a recorded hash promotes the pin to github")
|
||||
T.eq(full.mods[2].sha256, SHA2, "the promoted pin uses the recorded hash")
|
||||
T.eq(full.mods[3].source, "github", "a recorded hash promotes the pin to github")
|
||||
T.eq(full.mods[3].sha256, SHA2, "the promoted pin uses the recorded hash")
|
||||
T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable")
|
||||
|
||||
local noMods, noModsErr = CartStore.capture(identity, { rowSet()[2] }, modOptions)
|
||||
T.eq(noMods, nil, "a capture with nothing enabled is refused")
|
||||
local offOnly = CartStore.capture(identity, { rowSet()[2] }, modOptions)
|
||||
T.check(offOnly ~= nil, "a capture of nothing but switched-off mods still builds")
|
||||
T.eq(#offOnly.mods, 1, "pinning the one mod it was given")
|
||||
T.eq(offOnly.mods[1].enabled, false, "switched off")
|
||||
T.eq(offOnly.mods[1].options.unused, true, "with its settings kept")
|
||||
|
||||
local noMods, noModsErr = CartStore.capture(identity, {}, modOptions)
|
||||
T.eq(noMods, nil, "a capture with no mods at all is refused")
|
||||
T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil,
|
||||
"the empty capture says why (got " .. tostring(noModsErr) .. ")")
|
||||
T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil,
|
||||
|
||||
@@ -193,4 +193,31 @@ do
|
||||
eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu")
|
||||
end
|
||||
|
||||
-- A cart may narrow the ladder (CartManifest's `speeds`), and returning to
|
||||
-- the launcher must put it back.
|
||||
do
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, "no cart means the full ladder")
|
||||
check(not GameSpeed.isLocked(), "and nothing is pinned")
|
||||
|
||||
GameSpeed.setAllowed({ 1, 2 })
|
||||
eq(#GameSpeed.allowed(), 2, "a cart narrows the ladder")
|
||||
eq(GameSpeed.cycle(1, 1), 2, "cycling stays inside the cart's levels")
|
||||
eq(GameSpeed.cycle(2, 1), 1, "and wraps within them")
|
||||
eq(GameSpeed.clamp(100), 2, "a value past the cart's top clamps into it")
|
||||
check(not GameSpeed.isLocked(), "two levels is narrowed, not pinned")
|
||||
|
||||
GameSpeed.setAllowed({ 1 })
|
||||
check(GameSpeed.isLocked(), "one level reads as pinned")
|
||||
eq(GameSpeed.cycle(1, 1), 1, "and cycling cannot leave it")
|
||||
|
||||
GameSpeed.setAllowed({ 1, 7 })
|
||||
eq(#GameSpeed.allowed(), 1, "a level that is not on the ladder is dropped")
|
||||
|
||||
GameSpeed.setAllowed(nil)
|
||||
eq(#GameSpeed.allowed(), #GameSpeed.LEVELS,
|
||||
"leaving the cart restores the full ladder")
|
||||
eq(GameSpeed.cycle(4, 1), 10, "and cycling reaches the levels again")
|
||||
end
|
||||
|
||||
T.finish("game_speed_categories")
|
||||
|
||||
@@ -21,6 +21,21 @@ for p = 1, 3 do
|
||||
end
|
||||
end
|
||||
|
||||
-- gfx/stats/stats.pal, the colour LoadStatsScreenPals drops into colour 0 of
|
||||
-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390)
|
||||
local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } }
|
||||
for p = 1, 3 do
|
||||
for ch = 1, 3 do
|
||||
T.eq(SummaryMenu.PAGE_TINTS[p][ch], up(ROM_TINTS[p][ch]),
|
||||
("stats.pal tint %d channel %d"):format(p, ch))
|
||||
end
|
||||
local page = setmetatable({ page = p }, { __index = SummaryMenu })
|
||||
local lower = page:lowerColors()
|
||||
T.eq(lower[1][1], SummaryMenu.PAGE_TINTS[p][1],
|
||||
("lower half palette %d takes the page tint as colour 0"):format(p))
|
||||
T.eq(lower[4][1], 0, ("lower half palette %d keeps black ink"):format(p))
|
||||
end
|
||||
|
||||
-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet
|
||||
love.graphics = love.graphics or {}
|
||||
local realNewQuad = love.graphics.newQuad
|
||||
@@ -48,4 +63,4 @@ local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu })
|
||||
T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes")
|
||||
love.graphics.newQuad = realNewQuad
|
||||
|
||||
T.finish("gen2 stats tiles and page palettes (#1558)")
|
||||
T.finish("gen2 stats tiles and page palettes (#1558, #1693)")
|
||||
|
||||
@@ -265,4 +265,32 @@ check(update and update:find("_pumpSkinFetch", 1, true) ~= nil,
|
||||
check(TouchSkin.ARCHIVE_EXTS.deltaskin == true,
|
||||
"and the installer accepts the extension")
|
||||
|
||||
-- Turning skins off must leave the pad on. `enabled` is the pad's own switch,
|
||||
-- and the button that calls this only exists while a skin is active, which
|
||||
-- already requires the pad to be enabled.
|
||||
do
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.touchControls = { enabled = true, skin = "some_skin" }
|
||||
SaveData.saveOptions(opts)
|
||||
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp:_disableSkins()
|
||||
|
||||
local after = SaveData.loadOptions().touchControls
|
||||
eq(after.skin, nil, "turning skins off clears the selected skin")
|
||||
check(after.enabled ~= false, "and leaves the touch pad enabled")
|
||||
|
||||
local cfg = TouchControls.normalizeConfig(after)
|
||||
check(cfg.enabled, "so the pad is on after normalizing")
|
||||
eq(cfg.skin, nil, "with no skin behind it")
|
||||
|
||||
check(imp._skinNotice ~= nil and imp._skinNotice.ok == true,
|
||||
"and the notice reports success")
|
||||
check(tostring(imp._skinNotice.text):find("built-in pad", 1, true) ~= nil,
|
||||
"promising the built-in pad, which is now what happens")
|
||||
end
|
||||
|
||||
T.finish("launcher_skins_ux")
|
||||
|
||||
@@ -385,4 +385,152 @@ do
|
||||
eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order")
|
||||
end
|
||||
|
||||
-- ------- carts: a second array on the same feed, at the same schema_version
|
||||
--
|
||||
-- The published index added carts without bumping schema_version, so an older
|
||||
-- build has to keep reading the feed and this one has to read both halves.
|
||||
-- The fixture below is the shape carts/<Author>@<id>/meta.json validates to.
|
||||
|
||||
local OMEGA = {
|
||||
folder = "bryanthaboi@omega_random_competition",
|
||||
id = "omega_random_competition",
|
||||
title = "OMEGA RANDOM COMPETITION",
|
||||
author = "bryanthaboi",
|
||||
summary = "Bois Club Randomizer as its own cartridge.",
|
||||
version = "1.0.0",
|
||||
base = "red",
|
||||
seal = "sealed",
|
||||
shell = "#7B1B22",
|
||||
finish = "holo",
|
||||
speeds = { 1, 2 },
|
||||
tags = { "randomizer", "competition" },
|
||||
repo = "https://github.com/bryanthaboi/omega-random-competition",
|
||||
github = "bryanthaboi/omega-random-competition",
|
||||
automatic_version_check = true,
|
||||
mods = { { id = "bcr", source = "github", repo = "bryanthaboi/bcr",
|
||||
version = "1.0.0", sha256 = ("83a111b4"):rep(8) } },
|
||||
load_order = { "bcr" },
|
||||
license = "MIT",
|
||||
description_url = "data/carts/bryanthaboi@omega_random_competition/description.md",
|
||||
update_check = "pending",
|
||||
}
|
||||
|
||||
local JOHTO_CART = {
|
||||
id = "johto_run", title = "Johto Run", author = "Ren", version = "2.1.0",
|
||||
base = "gold", seal = "open",
|
||||
repo = "https://github.com/ren/johto-run",
|
||||
github = "ren/johto-run",
|
||||
mods = { { id = "steps", source = "gamebanana", mod = 42, file = 99,
|
||||
md5 = ("ab"):rep(16), enabled = false,
|
||||
options = { pace = "fast" } } },
|
||||
update_check = "ok",
|
||||
latest = { version = "2.1.0", tag = "v2.1.0",
|
||||
zip = { name = "johto_run-2.1.0.zip",
|
||||
url = "https://example.test/johto_run-2.1.0.zip" } },
|
||||
}
|
||||
|
||||
local function cartFeed(carts, overrides)
|
||||
local doc = { schema_version = 1, count = 1, cart_count = #carts,
|
||||
categories = { "GAMEPLAY" },
|
||||
base_games = { "red", "blue", "yellow", "gold", "silver" },
|
||||
mods = { NUZLOCKE }, carts = carts }
|
||||
for k, v in pairs(overrides or {}) do doc[k] = v end
|
||||
return Json.encode(doc)
|
||||
end
|
||||
|
||||
do
|
||||
local index, err = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART }))
|
||||
check(index ~= nil, "a feed carrying carts parses: " .. tostring(err))
|
||||
eq(index.schemaVersion, 1, "carts arrive at schema_version 1, unbumped")
|
||||
eq(#index.mods, 1, "the mods array still parses")
|
||||
eq(#index.carts, 2, "and the carts array parses beside it")
|
||||
local c = index.carts[1]
|
||||
eq(c.id, "omega_random_competition", "cart id")
|
||||
eq(c.title, "OMEGA RANDOM COMPETITION", "cart title")
|
||||
eq(c.base, "red", "the game the cart plays as")
|
||||
eq(c.seal, "sealed", "its seal")
|
||||
eq(c.shell, "#7B1B22", "its shell colour")
|
||||
eq(c.finish, "holo", "its finish")
|
||||
eq(c.speeds[2], 2, "its speed ladder")
|
||||
eq(c.tags[1], "randomizer", "its tags")
|
||||
eq(c.license, "MIT", "its license")
|
||||
eq(c.load_order[1], "bcr", "its load order")
|
||||
eq(#c.mods, 1, "its pinned mod set")
|
||||
eq(c.mods[1].source, "github", "a github pin keeps its source")
|
||||
eq(c.mods[1].repo, "bryanthaboi/bcr", "with the repo it comes from")
|
||||
eq(c.mods[1].version, "1.0.0", "the exact pinned version")
|
||||
eq(#c.mods[1].sha256, 64, "and the digest that gates it")
|
||||
check(ModIndex.isCart(c), "a parsed cart is marked as one")
|
||||
check(not ModIndex.isCart(index.mods[1]), "a mod is not")
|
||||
|
||||
local g = index.carts[2]
|
||||
eq(g.mods[1].source, "gamebanana", "a gamebanana pin keeps its source")
|
||||
eq(g.mods[1].mod, 42, "with its mod page id")
|
||||
eq(g.mods[1].file, 99, "and its file id")
|
||||
eq(#g.mods[1].md5, 32, "and the digest GameBanana reports")
|
||||
eq(g.mods[1].enabled, false, "a pin shipped switched off stays off")
|
||||
eq(g.mods[1].options.pace, "fast", "frozen options survive")
|
||||
eq(ModIndex.installUrl(g), "https://example.test/johto_run-2.1.0.zip",
|
||||
"a cart resolves its release asset the same way a mod does")
|
||||
end
|
||||
|
||||
-- the old-feed case: no carts key at all
|
||||
do
|
||||
local index, err = ModIndex.parse(feed({ NUZLOCKE }))
|
||||
check(index ~= nil, "a feed with no carts key still parses: " .. tostring(err))
|
||||
eq(#index.mods, 1, "its mods are unaffected")
|
||||
eq(type(index.carts), "table", "and carts is a list, never nil")
|
||||
eq(#index.carts, 0, "an absent carts array is an empty one")
|
||||
end
|
||||
|
||||
-- a broken cart row costs itself, not the whole feed
|
||||
do
|
||||
local noBase = {}
|
||||
for k, v in pairs(OMEGA) do noBase[k] = v end
|
||||
noBase.base = nil
|
||||
local noMods = {}
|
||||
for k, v in pairs(JOHTO_CART) do noMods[k] = v end
|
||||
noMods.id, noMods.mods = "empty_pins", {}
|
||||
local index, err = ModIndex.parse(cartFeed({
|
||||
noBase, "not even an object", { id = "bare" }, noMods, OMEGA }))
|
||||
check(index ~= nil, "a feed with malformed carts still parses: " .. tostring(err))
|
||||
eq(#index.carts, 1, "only the well-formed cart is listed")
|
||||
eq(index.carts[1].id, "omega_random_competition", "and it is the intact one")
|
||||
eq(#index.mods, 1, "the mods array is untouched by a bad cart")
|
||||
end
|
||||
|
||||
-- search and filter: matches() already spans title / author / summary / id,
|
||||
-- so the cart half reuses it wholesale. The category filter does not apply
|
||||
-- (a cart has none); base does.
|
||||
do
|
||||
local index = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART }))
|
||||
local carts = index.carts
|
||||
eq(#ModIndex.filter(carts, {}), 2, "no filter keeps every cart")
|
||||
eq(ModIndex.filter(carts, { query = "omega" })[1].id,
|
||||
"omega_random_competition", "search matches a cart by title")
|
||||
eq(ModIndex.filter(carts, { query = "Ren" })[1].id, "johto_run",
|
||||
"search matches a cart by author")
|
||||
eq(ModIndex.filter(carts, { query = "randomizer" })[1].id,
|
||||
"omega_random_competition", "and by summary")
|
||||
eq(#ModIndex.filter(carts, { query = "omega johto" }), 0,
|
||||
"cart search terms are ANDed too")
|
||||
eq(ModIndex.filter(carts, { base = "gold" })[1].id, "johto_run",
|
||||
"filtering by base game keeps the carts for that game")
|
||||
eq(#ModIndex.filter(carts, { base = "RED" }), 1,
|
||||
"and compares case-insensitively")
|
||||
eq(#ModIndex.filter(carts, { base = "silver" }), 0,
|
||||
"a base no cart plays as filters everything out")
|
||||
eq(#ModIndex.filter(carts, { category = "GAMEPLAY" }), 0,
|
||||
"a cart carries no categories, so a category filter never matches one")
|
||||
eq(ModIndex.filter(carts, { tag = "competition" })[1].id,
|
||||
"omega_random_competition", "cart tags filter")
|
||||
|
||||
local bases = ModIndex.baseGamesIn(index)
|
||||
eq(#bases, 2, "only base games a cart actually plays as are offered")
|
||||
eq(bases[1], "red", "and they keep the feed's declared base_games order")
|
||||
eq(bases[2], "gold", "in that order")
|
||||
eq(#ModIndex.baseGamesIn(ModIndex.parse(feed({ NUZLOCKE }))), 0,
|
||||
"a cartless feed offers no base games")
|
||||
end
|
||||
|
||||
print("ok mod_index_tests")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
-- The Pewter museum ticket clerk dispatches on the player's coordinates
|
||||
-- before it looks at the ticket flag (#1690). scripts/Museum1F.asm:45
|
||||
-- reads wYCoord/wXCoord first: (13,4) and (12,3) are behind the counter
|
||||
-- (the AMBER question), Y==4 otherwise is the ticket path, and anything
|
||||
-- else is the "go to the other side" brush-off. The port only ever had
|
||||
-- the middle branch, so two thirds of the NPC never fired.
|
||||
--
|
||||
-- tests/engine/museum_money_box_bug1335.lua and museum_1f_clerk_
|
||||
-- translation_test.lua both call the clerk with ow == nil, which stays on
|
||||
-- the ticket path either way, so neither can see the coordinate branches.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}
|
||||
|
||||
local M = assert(loadfile("data/scripts/story2.lua"))()
|
||||
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
|
||||
|
||||
-- data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 stands at (12,4)
|
||||
-- with the counter column at x==11. (13,4) is the cell east of him and
|
||||
-- (12,3) the cell north of him, both reachable only from the back door;
|
||||
-- (10,4) talks to him across the counter from the public side.
|
||||
local BEHIND = { { 13, 4 }, { 12, 3 } }
|
||||
local FRONT = { 10, 4 }
|
||||
|
||||
local pushed
|
||||
local function mkGame(cash)
|
||||
pushed = {}
|
||||
return {
|
||||
data = { text = {} },
|
||||
save = { money = cash, flags = {} },
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
}
|
||||
end
|
||||
|
||||
local function mkOw(x, y)
|
||||
return { player = { cellX = x, cellY = y } }
|
||||
end
|
||||
|
||||
-- nil-tolerant: a branch that pushes nothing at all is a failure to
|
||||
-- report, not a crash that hides every check after it
|
||||
local function has(box, needle)
|
||||
return box ~= nil and tostring(box.text):find(needle, 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- answering a box that never offered a choice is the failure mode itself,
|
||||
-- so report it rather than dying on a nil index and hiding the rest
|
||||
local function answer(box, yes)
|
||||
if box and box.opts and box.opts.choice then box.opts.choice(yes) end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ behind the counter
|
||||
for _, cell in ipairs(BEHIND) do
|
||||
local where = ("(%d,%d)"):format(cell[1], cell[2])
|
||||
|
||||
local g = mkGame(3000)
|
||||
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
|
||||
local ask = pushed[1]
|
||||
T.check(has(ask, "sneak"),
|
||||
where .. " opens the can't-sneak-in-the-back-way box")
|
||||
T.check(ask.opts ~= nil and ask.opts.choice ~= nil,
|
||||
where .. " carries the AMBER yes/no choice")
|
||||
T.check(ask.opts.money == nil,
|
||||
where .. " raises no money window behind the AMBER question")
|
||||
T.check(ask.onDone == nil, where .. " chains through the choice, not onDone")
|
||||
|
||||
-- YES: .TheresALabSomewhereText
|
||||
answer(ask, true)
|
||||
T.check(has(pushed[2], "lab"), where .. " YES gives the resurrection lab line")
|
||||
|
||||
-- NO: .AmberIsFossilizedTreeSapText
|
||||
g = mkGame(3000)
|
||||
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
|
||||
answer(pushed[1], false)
|
||||
T.check(has(pushed[2], "tree sap"), where .. " NO explains AMBER is tree sap")
|
||||
|
||||
T.eq(g.save.money, 3000, where .. " never charges the player")
|
||||
T.check(not g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET,
|
||||
where .. " never hands out a ticket")
|
||||
end
|
||||
|
||||
-- the coordinate check runs BEFORE the ticket check, so a ticket holder
|
||||
-- who wanders round the back still gets told off (asm:45 precedes asm:59)
|
||||
for _, cell in ipairs(BEHIND) do
|
||||
local where = ("(%d,%d)"):format(cell[1], cell[2])
|
||||
local g = mkGame(3000)
|
||||
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
|
||||
T.check(has(pushed[1], "sneak"),
|
||||
where .. " with a ticket is still the back-way box, not take-your-time")
|
||||
answer(pushed[1], false)
|
||||
T.check(has(pushed[2], "tree sap"),
|
||||
where .. " with a ticket still answers NO with the tree sap line")
|
||||
end
|
||||
|
||||
-- the done callback reaches the second box on both answers
|
||||
local g = mkGame(3000)
|
||||
local doneFired = false
|
||||
clerk(g, mkOw(13, 4), nil, function() doneFired = true end)
|
||||
answer(pushed[1], true)
|
||||
T.check(pushed[2].onDone ~= nil, "the AMBER answer box carries the done callback")
|
||||
pushed[2].onDone()
|
||||
T.check(doneFired, "and it chains back out of the conversation")
|
||||
|
||||
-- ------------------------------------------------ the brush-off
|
||||
-- scripts/Museum1F.asm:58: no ticket and not on row 4 means "other side"
|
||||
local OFF_ROW = { { 12, 5 }, { 13, 3 }, { 12, 2 }, { 11, 3 } }
|
||||
for _, cell in ipairs(OFF_ROW) do
|
||||
local where = ("(%d,%d)"):format(cell[1], cell[2])
|
||||
local gg = mkGame(3000)
|
||||
clerk(gg, mkOw(cell[1], cell[2]), nil, function() end)
|
||||
T.check(has(pushed[1], "other side"), where .. " gets the brush-off line")
|
||||
T.check(pushed[1].opts == nil, where .. " raises no money window")
|
||||
T.eq(gg.save.money, 3000, where .. " never charges the player")
|
||||
end
|
||||
|
||||
-- with a ticket, off-row talk falls through to take-your-time instead
|
||||
-- (asm:59 CheckEvent gates the brush-off)
|
||||
local g2 = mkGame(3000)
|
||||
g2.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||
clerk(g2, mkOw(12, 5), nil, function() end)
|
||||
T.check(has(pushed[1], "Take your time"),
|
||||
"(12,5) with a ticket gets take-your-time, not the brush-off")
|
||||
|
||||
-- ------------------------------------------------ the ticket path holds
|
||||
-- across the counter from the public side is row 4: the money box
|
||||
local g3 = mkGame(3000)
|
||||
clerk(g3, mkOw(FRONT[1], FRONT[2]), nil, function() end)
|
||||
T.check(has(pushed[1], "50"), "(10,4) still opens the child's ticket ask")
|
||||
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
|
||||
"and the ask still raises the money window")
|
||||
answer(pushed[1], true)
|
||||
T.eq(g3.save.money, 2950, "buying from the front still costs 50")
|
||||
T.check(g3.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, "and still sets the ticket flag")
|
||||
|
||||
local g4 = mkGame(3000)
|
||||
g4.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||
clerk(g4, mkOw(FRONT[1], FRONT[2]), nil, function() end)
|
||||
T.check(has(pushed[1], "Take your time"),
|
||||
"(10,4) with a ticket still gets take-your-time")
|
||||
T.check(pushed[1].opts == nil, "and no money window on that branch")
|
||||
|
||||
-- a caller with no overworld (the two older suites, and any script that
|
||||
-- talks to the clerk out of band) must keep the pre-#1690 ticket path
|
||||
local g5 = mkGame(3000)
|
||||
clerk(g5, nil, nil, function() end)
|
||||
T.check(has(pushed[1], "50"), "ow == nil still opens the ticket ask")
|
||||
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
|
||||
"ow == nil still raises the money window")
|
||||
|
||||
-- ------------------------------------------------ the rope onStep path
|
||||
-- Museum1FDefaultScript calls the clerk over when the player steps onto
|
||||
-- (9,4) or (10,4); OverworldController passes the player's own cell, so
|
||||
-- the brush-off must not swallow the ask there
|
||||
for _, x in ipairs({ 9, 10 }) do
|
||||
local where = ("(%d,4)"):format(x)
|
||||
local g6 = mkGame(3000)
|
||||
local moved = false
|
||||
local ow = mkOw(x, 4)
|
||||
ow.scriptMove = function() moved = true end
|
||||
local handled = M.MUSEUM_1F.onStep(g6, ow, x, 4)
|
||||
T.check(handled, where .. " on the rope is handled by onStep")
|
||||
T.check(has(pushed[1], "50"), where .. " on the rope opens the ticket ask")
|
||||
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
|
||||
where .. " on the rope keeps the money window")
|
||||
answer(pushed[1], false)
|
||||
T.check(has(pushed[2], "Come again"), where .. " declining says come again")
|
||||
pushed[2].onDone()
|
||||
T.check(moved, where .. " declining still shoves the player back south (#151)")
|
||||
end
|
||||
|
||||
-- a ticket holder walking the rope is not stopped at all
|
||||
local g7 = mkGame(3000)
|
||||
g7.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||
local ow7 = mkOw(9, 4)
|
||||
ow7.scriptMove = function() end
|
||||
T.check(not M.MUSEUM_1F.onStep(g7, ow7, 9, 4),
|
||||
"a ticket holder crosses the rope without being stopped")
|
||||
T.eq(#pushed, 0, "and no box is pushed")
|
||||
|
||||
-- ------------------------------------------------ translation reaches it
|
||||
-- the two new branches must read game.data.text, not bare literals
|
||||
local TRANSLATED = {
|
||||
_Museum1FScientist1DoYouKnowWhatAmberIsText = "Pas par derriere !\nL'AMBRE, tu connais ?",
|
||||
_Museum1FScientist1TheresALabSomewhereText = "Un labo essaie de\nles ressusciter.",
|
||||
_Museum1FScientist1AmberIsFossilizedTreeSapText = "C'est de la resine\nfossilisee.",
|
||||
_Museum1FScientist1GoToOtherSideText = "Passe de l'autre\ncote !",
|
||||
}
|
||||
local function mkTranslated()
|
||||
pushed = {}
|
||||
return {
|
||||
data = { text = TRANSLATED },
|
||||
save = { money = 3000, flags = {} },
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
}
|
||||
end
|
||||
|
||||
local g8 = mkTranslated()
|
||||
clerk(g8, mkOw(13, 4), nil, function() end)
|
||||
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1DoYouKnowWhatAmberIsText,
|
||||
"the back-way box uses the translated AMBER question")
|
||||
answer(pushed[1], true)
|
||||
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1TheresALabSomewhereText,
|
||||
"YES uses the translated lab line")
|
||||
|
||||
g8 = mkTranslated()
|
||||
clerk(g8, mkOw(12, 3), nil, function() end)
|
||||
answer(pushed[1], false)
|
||||
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1AmberIsFossilizedTreeSapText,
|
||||
"NO uses the translated tree sap line")
|
||||
|
||||
g8 = mkTranslated()
|
||||
clerk(g8, mkOw(12, 5), nil, function() end)
|
||||
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1GoToOtherSideText,
|
||||
"the brush-off uses the translated other-side line")
|
||||
|
||||
T.finish("museum_clerk_back_entrance_bug1690")
|
||||
@@ -84,12 +84,14 @@ T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
|
||||
|
||||
-- -------------------------------------------------------------------- caps
|
||||
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
|
||||
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
|
||||
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
|
||||
T.check(hi.tilt and hi.survey and hi.shaderfx and not hi.fpsMax,
|
||||
"high caps: all on, no fps ceiling")
|
||||
T.check((not lo.tilt) and (not lo.survey) and (not lo.shaderfx),
|
||||
"low caps: heavy extras off")
|
||||
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
|
||||
local bal = Performance.CAPS.balanced
|
||||
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
|
||||
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
|
||||
T.check((not bal.tilt) and bal.survey and (not bal.shaderfx) and not bal.fpsMax,
|
||||
"balanced caps: no tilt, no shaderfx, survey kept, no fps ceiling")
|
||||
|
||||
device("Linux", "arm64", 4)
|
||||
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")
|
||||
|
||||
@@ -74,6 +74,12 @@ if ok then
|
||||
peak = math.max(peak, math.abs(l), math.abs(r))
|
||||
end
|
||||
check(peak > 0.01, "TitleScreen renders audible samples (peak=" .. peak .. ")")
|
||||
-- audio/drumkits.asm kit 5: the NR42 envelope rings on past the noise_note
|
||||
-- script, so a snare must outlast the one frame its script occupies.
|
||||
local frame = ChipSynth.SAMPLE_RATE / 60
|
||||
local snare = engine:drumInstrumentGen2(5, 1)
|
||||
check(snare[#snare].endSample > frame * 2,
|
||||
"kit 5 snare rings past its script (" .. snare[#snare].endSample .. " samples)")
|
||||
end
|
||||
|
||||
local bark = audio.songs.Music_NewBarkTown
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
-- The battle main menu's 2x2 cursor clamps at every edge instead of wrapping
|
||||
-- to the opposite command (#1706). BattleMenuHeader's .MenuData flag byte is
|
||||
-- `db STATICMENU_CURSOR | STATICMENU_DISABLE_B` (engine/battle/menu.asm:31-33)
|
||||
-- with no STATICMENU_WRAP, so Init2DMenuCursorPosition leaves both wrap bits
|
||||
-- clear (engine/menus/menu.asm:156-166) and _2DMenuInterpretJoypad's
|
||||
-- .check_wrap_around_* arms answer `xor a / ret`: the cursor does not move.
|
||||
-- ContestBattleMenuHeader (menu.asm:70-77) is the same grid, same flag byte.
|
||||
--
|
||||
-- luajit tests/gen2_battle_cursor_test.lua
|
||||
--
|
||||
-- ROM-free: the fixtures below are the extractor's shapes, and every press
|
||||
-- goes through the real Input edge detector and the real screen.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 battle cursor")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local Input = require("src.core.Input")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" } }
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = GROWTH,
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL",
|
||||
baseStats = { hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
specialAttack = 60, specialDefense = 50 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
PIDGEY = {
|
||||
id = "PIDGEY", index = 16, name = "PIDGEY",
|
||||
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
}
|
||||
|
||||
local DATA = {
|
||||
pokemon = POKEMON,
|
||||
moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} },
|
||||
items = {},
|
||||
}
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- The smallest roll that neither crits nor misses.
|
||||
local function detRandom(n)
|
||||
if (n or 1) <= 1 then return 0 end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function newScreen(opts)
|
||||
opts = opts or {}
|
||||
Input:init()
|
||||
local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local save = { party = { player }, inventory = {}, player = { name = "GOLD" } }
|
||||
local pushed = {}
|
||||
local game = {
|
||||
data = DATA, save = save, input = Input, options = {},
|
||||
stack = {
|
||||
push = function(_, screen) pushed[#pushed + 1] = screen end,
|
||||
pop = function() table.remove(pushed) end,
|
||||
top = function() return pushed[#pushed] end,
|
||||
clear = function(self) while #pushed > 0 do self:pop() end end,
|
||||
},
|
||||
}
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
save = save, random = detRandom })
|
||||
local screen = BattleState.new(game, { battle = battle, save = save,
|
||||
contest = opts.contest })
|
||||
game.stack:push(screen)
|
||||
return screen, pushed
|
||||
end
|
||||
|
||||
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no frame
|
||||
-- countdown (home/joypad.asm:383-412), so the drain presses like a player.
|
||||
local function runToMenu(screen, cap)
|
||||
for _ = 1, (cap or 3000) do
|
||||
local waiting = (screen.messageTimer or 0) > 0
|
||||
if waiting then Input:overlayPressed("a") end
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
if waiting then Input:overlayReleased("a") end
|
||||
if screen.phase == "menu" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function press(screen, button)
|
||||
Input:overlayPressed(button)
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
Input:overlayReleased(button)
|
||||
Input:step()
|
||||
end
|
||||
|
||||
-- One press from a known cursor position, answering where it landed.
|
||||
local function pressAt(screen, index, button)
|
||||
screen.menuIndex = index
|
||||
press(screen, button)
|
||||
return screen.menuIndex
|
||||
end
|
||||
|
||||
-- ---- the whole 2x2 transition table ---------------------------------------
|
||||
--
|
||||
-- Row-major, FIGHT / PkMn over PACK / RUN (BattleMenuHeader's menu_coords).
|
||||
-- The eight edge rows are the bug; the eight interior rows are the guard
|
||||
-- against a fix that clamps the raw index instead of the column and the row,
|
||||
-- which would hold every edge and still break FIGHT-down and RUN-left.
|
||||
local GRID = {
|
||||
{ 1, "left", 1 }, { 2, "left", 1 }, { 3, "left", 3 }, { 4, "left", 3 },
|
||||
{ 1, "right", 2 }, { 2, "right", 2 }, { 3, "right", 4 }, { 4, "right", 4 },
|
||||
{ 1, "up", 1 }, { 2, "up", 2 }, { 3, "up", 1 }, { 4, "up", 2 },
|
||||
{ 1, "down", 3 }, { 2, "down", 4 }, { 3, "down", 3 }, { 4, "down", 4 },
|
||||
}
|
||||
|
||||
local LABEL = { "FIGHT", "PkMn", "PACK", "RUN" }
|
||||
|
||||
do
|
||||
local screen = newScreen()
|
||||
check(runToMenu(screen), "the intro drains to the battle menu")
|
||||
eq(screen.menuIndex, 1, "which opens on FIGHT")
|
||||
|
||||
for _, row in ipairs(GRID) do
|
||||
local from, button, want = row[1], row[2], row[3]
|
||||
local note = from == want
|
||||
and ("%s holds against %s"):format(LABEL[from], button)
|
||||
or ("%s goes %s to %s"):format(LABEL[from], button, LABEL[want])
|
||||
eq(pressAt(screen, from, button), want, note)
|
||||
end
|
||||
|
||||
-- Whatever the cursor lands on has to stay a command the menu can run;
|
||||
-- an off-by-one clamp would index MENU past its end and choose nothing.
|
||||
for _, row in ipairs(GRID) do
|
||||
local landed = pressAt(screen, row[1], row[2])
|
||||
check(BattleState.MENU[landed] ~= nil,
|
||||
("%s %s leaves the cursor on a real command"):format(
|
||||
LABEL[row[1]], row[2]))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- the same grid inside the bug-catching contest -------------------------
|
||||
--
|
||||
-- ContestBattleMenuHeader only moves the box out to menu_coords 2, 12
|
||||
-- (engine/battle/menu.asm:70-77); flag byte and cells are BattleMenuHeader's.
|
||||
do
|
||||
local screen = newScreen({ contest = true })
|
||||
check(runToMenu(screen), "a contest battle drains to its menu")
|
||||
eq(screen.contest, true, "with the contest menu's coordinates")
|
||||
eq(pressAt(screen, 2, "right"), 2, "PkMn holds against right in the contest")
|
||||
eq(pressAt(screen, 3, "down"), 3, "PARKBALL holds against down")
|
||||
eq(pressAt(screen, 1, "down"), 3, "and FIGHT still drops onto it")
|
||||
end
|
||||
|
||||
-- ---- the D-pad is silent ---------------------------------------------------
|
||||
--
|
||||
-- MenuClickSound / PlayClickSFX play SFX_READ_TEXT_2 for A and B only
|
||||
-- (home/menu.asm:746-762), so a rebuilt branch must not click on a move.
|
||||
do
|
||||
local screen = newScreen()
|
||||
check(runToMenu(screen), "reached the menu again")
|
||||
local played = {}
|
||||
screen.playSfx = function(_, name) played[#played + 1] = name end
|
||||
for _, button in ipairs({ "left", "right", "up", "down" }) do
|
||||
press(screen, button)
|
||||
end
|
||||
eq(#played, 0, "no click sound on any of the four directions")
|
||||
screen.menuIndex = 1
|
||||
press(screen, "a")
|
||||
eq(played[1], "Sfx_ReadText2", "A on FIGHT is what clicks")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,262 @@
|
||||
-- BATTLE BG on Gold (#1709): the one battle-surround setting Gold's renderer
|
||||
-- can honour. This covers the wiring a screenshot cannot -- the default key,
|
||||
-- the OPTION row and its ladder, the launcher gear row, the mod-facing member
|
||||
-- table -- plus the band geometry Game2 paints, asserted as rectangles rather
|
||||
-- than as pixels.
|
||||
--
|
||||
-- What colour those bands actually come out is not a claim this file makes;
|
||||
-- tests/drivers/gold_battle_bg_bug1709_test.lua is where a human judges that.
|
||||
|
||||
package.path = "./?.lua;" .. package.path
|
||||
|
||||
love = love or {}
|
||||
love.graphics = love.graphics or {
|
||||
getColor = function() return 1, 1, 1, 1 end,
|
||||
setColor = function() end,
|
||||
rectangle = function() end,
|
||||
print = function() end,
|
||||
printf = function() end,
|
||||
draw = function() end,
|
||||
newQuad = function() return {} end,
|
||||
newImage = function() return nil end,
|
||||
getShader = function() return nil end,
|
||||
setShader = function() end,
|
||||
newShader = function() error("no shaders in this harness") end,
|
||||
getDimensions = function() return 160, 144 end,
|
||||
push = function() end, pop = function() end,
|
||||
translate = function() end, scale = function() end,
|
||||
circle = function() end, clear = function() end,
|
||||
}
|
||||
love.math = love.math or { random = function(a, b) return b and a or 0.5 end }
|
||||
love.image = love.image or {}
|
||||
love.timer = love.timer or { getTime = function() return 0 end }
|
||||
love.filesystem = love.filesystem or {
|
||||
load = function() return nil end,
|
||||
getInfo = function() return nil end,
|
||||
read = function() return nil end,
|
||||
write = function() return true end,
|
||||
remove = function() return true end,
|
||||
}
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Game2 = require("src.core.Game2")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local LauncherSettings = require("src.import.LauncherSettings")
|
||||
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local ScreenPosition = require("src.core.ScreenPosition")
|
||||
|
||||
local checks, failures = 0, 0
|
||||
local function check(label, got, want)
|
||||
checks = checks + 1
|
||||
if got ~= want then
|
||||
failures = failures + 1
|
||||
print(("FAIL %s: got %s want %s"):format(label, tostring(got),
|
||||
tostring(want)))
|
||||
end
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- the key
|
||||
|
||||
check("the default surround is the cart's paper white",
|
||||
Save.DEFAULT_OPTIONS.battleBg, "white")
|
||||
check("and a fresh options table carries it",
|
||||
Save.defaultOptions().battleBg, "white")
|
||||
|
||||
-- --------------------------------------------------------------- the row
|
||||
|
||||
local function rowNamed(label)
|
||||
for i, row in ipairs(OptionsMenu.ROWS) do
|
||||
if row.label == label then return i, row end
|
||||
end
|
||||
end
|
||||
|
||||
local bgIndex, bgRow = rowNamed("BATTLE BG")
|
||||
check("OPTION has a BATTLE BG row", bgRow ~= nil, true)
|
||||
if bgRow then
|
||||
check("it edits battleBg", bgRow.key, "battleBg")
|
||||
check("it is a port row, not one of the cart's seven", bgRow.port, true)
|
||||
check("WHITE and BLACK are the whole ladder", #bgRow.values, 2)
|
||||
check("stored lowercase, shown WHITE", bgRow.display.white, "WHITE")
|
||||
check("and BLACK", bgRow.display.black, "BLACK")
|
||||
-- Appended at the tail, so nothing the other suites index by position moves.
|
||||
check("it follows MAX FPS", OptionsMenu.ROWS[bgIndex - 1].label, "MAX FPS")
|
||||
check("and CANCEL still ends the list",
|
||||
OptionsMenu.ROWS[bgIndex + 1].cancel, true)
|
||||
check("CANCEL is last", bgIndex + 1, #OptionsMenu.ROWS)
|
||||
end
|
||||
check("the cart's rows are unmoved", OptionsMenu.ROWS[7].key, "frame")
|
||||
check("the rebind screen is unmoved", OptionsMenu.ROWS[8].id, "controls")
|
||||
check("the port's audio group is unmoved", OptionsMenu.ROWS[9].key, "musicVol")
|
||||
|
||||
-- The screen is built from ROWS, so the row has to survive buildRows too.
|
||||
local menu = OptionsMenu.new({ options = Save.defaultOptions() },
|
||||
{ options = Save.defaultOptions() })
|
||||
local built
|
||||
for _, row in ipairs(menu.rows) do
|
||||
if row.key == "battleBg" then built = row end
|
||||
end
|
||||
check("buildRows keeps it", built ~= nil, true)
|
||||
if built then
|
||||
check("and gives it the mod-facing id battleBg", built.id, "battleBg")
|
||||
check("the menu starts on WHITE", menu.options.battleBg, "white")
|
||||
menu:cycle(built, 1)
|
||||
check("right stores black, not the display string",
|
||||
menu.options.battleBg, "black")
|
||||
menu:cycle(built, 1)
|
||||
check("right again wraps to white", menu.options.battleBg, "white")
|
||||
menu:cycle(built, -1)
|
||||
check("left walks the ladder the other way", menu.options.battleBg, "black")
|
||||
menu:cycle(built, -1)
|
||||
check("and back", menu.options.battleBg, "white")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------ the launcher gear
|
||||
|
||||
local model = LauncherSettings.open(nil, "gold")
|
||||
local gearRow
|
||||
for _, section in ipairs(model.sections) do
|
||||
for _, row in ipairs(section.rows) do
|
||||
if row.label == "BATTLE BG" then gearRow = row end
|
||||
end
|
||||
end
|
||||
check("the gold gear offers BATTLE BG", gearRow ~= nil, true)
|
||||
if gearRow then
|
||||
model.opts.gold.battleBg = nil
|
||||
check("an options.lua with no battleBg reads WHITE", gearRow.value(), "WHITE")
|
||||
gearRow.step(1)
|
||||
check("stepping writes the gold block, not the flat Gen 1 one",
|
||||
model.opts.gold.battleBg, "black")
|
||||
check("and the row reads BLACK", gearRow.value(), "BLACK")
|
||||
gearRow.step(1)
|
||||
check("stepping again returns to white", model.opts.gold.battleBg, "white")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- the battle end
|
||||
|
||||
local bgMode = BattleState.bgMode
|
||||
check("the Gold battle screen answers bgMode", type(bgMode), "function")
|
||||
if type(bgMode) == "function" then
|
||||
check("black when the option says so",
|
||||
bgMode({ game = { options = { battleBg = "black" } } }), "black")
|
||||
check("white when it says white",
|
||||
bgMode({ game = { options = { battleBg = "white" } } }), "white")
|
||||
check("white on an options table that predates the key",
|
||||
bgMode({ game = { options = {} } }), "white")
|
||||
check("white with no game at all", bgMode({}), "white")
|
||||
-- Gen 1's third mode leaves the battle non-opaque so the map shows through;
|
||||
-- Gold has no such path, so an options.lua carried over from Red must not
|
||||
-- switch the Gold battle to a mode nothing paints.
|
||||
check("Gen 1's WORLD degrades to white here",
|
||||
bgMode({ game = { options = { battleBg = "world" } } }), "white")
|
||||
end
|
||||
|
||||
check("and mods are told bgMode is backed on this side",
|
||||
Gen2Compat.memberStatus("src.battle.BattleState", "bgMode"), "backed")
|
||||
|
||||
-- ------------------------------------------------------------- the bands
|
||||
--
|
||||
-- Game2 paints the surround, not the battle screen: the widescreen layer
|
||||
-- resolves to the TOP state, so a party menu or text box opened over the
|
||||
-- battle would otherwise repaint the void white under itself.
|
||||
|
||||
local G = love.graphics
|
||||
local realRect, realSetColor = G.rectangle, G.setColor
|
||||
local painter = Game2.paintBattleSurround
|
||||
check("Game2 owns the surround repaint", type(painter), "function")
|
||||
|
||||
local function paint(states, w, h)
|
||||
if type(painter) ~= "function" then return {} end
|
||||
local rects, pen = {}, { 1, 1, 1, 1 }
|
||||
G.setColor = function(r, g, b, a)
|
||||
pen = { r or 0, g or 0, b or 0, a or 1 }
|
||||
end
|
||||
G.rectangle = function(_, x, y, rw, rh)
|
||||
rects[#rects + 1] = { x = x, y = y, w = rw, h = rh, pen = pen }
|
||||
end
|
||||
local ok, err = pcall(painter, { stack = { states = states } }, w, h)
|
||||
G.rectangle, G.setColor = realRect, realSetColor
|
||||
if not ok then
|
||||
check("paintBattleSurround ran: " .. tostring(err), false, true)
|
||||
end
|
||||
return rects
|
||||
end
|
||||
|
||||
local blackBattle = { bgMode = function() return "black" end }
|
||||
local whiteBattle = { bgMode = function() return "white" end }
|
||||
local plainMenu = {}
|
||||
local overworld = {}
|
||||
|
||||
check("BLACK paints the four bands", #paint({ blackBattle }, 1024, 768), 4)
|
||||
check("WHITE paints nothing at all", #paint({ whiteBattle }, 1024, 768), 0)
|
||||
check("the overworld alone paints nothing",
|
||||
#paint({ overworld, plainMenu }, 1024, 768), 0)
|
||||
|
||||
-- A menu over the battle keeps the battle's void: the walk goes down the
|
||||
-- stack past states that have no bgMode of their own.
|
||||
check("a party menu over the battle still finds the battle's mode",
|
||||
#paint({ overworld, blackBattle, plainMenu }, 1024, 768), 4)
|
||||
|
||||
local function panelSafe(states, w, h, label)
|
||||
local scale = Chrome.fitScale(w, h)
|
||||
local ox, oy = Chrome.fitOrigin(w, h, scale)
|
||||
local pw, ph = 160 * scale, 144 * scale
|
||||
local rects = paint(states, w, h)
|
||||
local covered, overlap, offColour = 0, 0, 0
|
||||
for _, r in ipairs(rects) do
|
||||
covered = covered + r.w * r.h
|
||||
if r.pen[1] ~= 0 or r.pen[2] ~= 0 or r.pen[3] ~= 0 then
|
||||
offColour = offColour + 1
|
||||
end
|
||||
local ix = math.max(0, math.min(r.x + r.w, ox + pw) - math.max(r.x, ox))
|
||||
local iy = math.max(0, math.min(r.y + r.h, oy + ph) - math.max(r.y, oy))
|
||||
overlap = overlap + ix * iy
|
||||
end
|
||||
-- The band maths has to tile the void exactly: any overlap with the panel is
|
||||
-- a black frame eating the HUD or the message box, and any shortfall is a
|
||||
-- strip of white left behind on one edge.
|
||||
check(label .. ": no band touches the battle panel", overlap, 0)
|
||||
check(label .. ": the void is covered edge to edge", covered, w * h - pw * ph)
|
||||
check(label .. ": every band is black", offColour, 0)
|
||||
end
|
||||
|
||||
for _, size in ipairs({ { 1024, 768 }, { 1280, 840 }, { 1920, 1080 },
|
||||
{ 800, 600 }, { 1366, 768 } }) do
|
||||
panelSafe({ blackBattle }, size[1], size[2],
|
||||
("%dx%d"):format(size[1], size[2]))
|
||||
end
|
||||
|
||||
-- SCREEN POS lifts the panel off centre, which is the other way the four
|
||||
-- bands can stop agreeing with where the panel actually landed.
|
||||
for _, mode in ipairs({ "center", "upper", "top" }) do
|
||||
ScreenPosition.setMode(mode)
|
||||
panelSafe({ blackBattle, plainMenu }, 1280, 840, "screen pos " .. mode)
|
||||
end
|
||||
ScreenPosition.setMode("center")
|
||||
|
||||
-- A window smaller than the GB screen has no void to paint; the bands must
|
||||
-- not wrap around to the far edge on the negative origin.
|
||||
check("nothing to paint under 160x144", #paint({ blackBattle }, 100, 90), 0)
|
||||
|
||||
-- ----------------------------------------------------- the call site
|
||||
--
|
||||
-- Constructing a Game2 needs love, so where the paint is called from is read
|
||||
-- out of the source, the way the other Gen 2 suites check this file. It has
|
||||
-- to run right after the widescreen layer has painted its white surround and
|
||||
-- before the letterbox, or there is nothing to repaint over.
|
||||
local handle = io.open("src/core/Game2.lua", "r")
|
||||
local source = handle and handle:read("*a")
|
||||
if handle then handle:close() end
|
||||
check("Game2's source is readable", source ~= nil, true)
|
||||
if source then
|
||||
check("drawScene repaints straight after the widescreen layer",
|
||||
source:find("wide:drawWidescreen%(w, h%)%s*self:paintBattleSurround%(w, h%)")
|
||||
~= nil, true)
|
||||
end
|
||||
|
||||
print(("gen2 battle options: %d checks, %d failures"):format(checks, failures))
|
||||
if failures > 0 then
|
||||
error(("%d assertion(s) failed"):format(failures), 0)
|
||||
end
|
||||
+25
-11
@@ -689,15 +689,16 @@ statusBattle.player.moves[1].pp = 20
|
||||
statusBattle:takeTurn({ kind = "move", move = "THUNDER_WAVE" })
|
||||
check("a second status fails", statusBattle.enemy.status, "paralyze")
|
||||
|
||||
-- Sleep lands with a turn counter, and canAct spends it. Asserted on the move
|
||||
-- rather than a whole turn: with this deterministic random the roll is the
|
||||
-- minimum 1 turn, and the slower foe's own turn later in the same round then
|
||||
-- wakes it -- which is what the cart does as well.
|
||||
-- Sleep lands with a turn counter, and canAct spends it. The counter opens at
|
||||
-- 2, so the target cannot wake in the round it was slept
|
||||
-- (engine/battle/effect_commands.asm:3591-3598, #1707).
|
||||
local sleepBattle = newBattle()
|
||||
sleepBattle.player.moves = { { id = "SPORE", pp = 15, maxPp = 15 } }
|
||||
sleepBattle:useMove(sleepBattle.player, sleepBattle.enemy, "SPORE")
|
||||
check("spore slept the target", sleepBattle.enemy.status, "sleep")
|
||||
check("sleep has turns", (sleepBattle.enemy.statusTurns or 0) >= 1, true)
|
||||
check("sleep never opens shorter than two turns",
|
||||
(sleepBattle.enemy.statusTurns or 0) >= 2, true)
|
||||
check("the lowest roll is exactly two", sleepBattle.enemy.statusTurns, 2)
|
||||
-- A sleeping mon cannot act, and the counter runs down to a wake-up.
|
||||
sleepBattle.enemy.statusTurns = 2
|
||||
check("asleep cannot act", sleepBattle:canAct(sleepBattle.enemy), false)
|
||||
@@ -1386,12 +1387,25 @@ check("Ground is immune", Effects.sandstormHits({ "NORMAL", "GROUND" }), false)
|
||||
check("Steel is immune", Effects.sandstormHits({ "STEEL" }), false)
|
||||
check("Flying is not", Effects.sandstormHits({ "NORMAL", "FLYING" }), true)
|
||||
|
||||
-- BattleCommand_Heal's .Weather ladder: a half normally, two thirds in sun,
|
||||
-- a quarter in rain or sandstorm.
|
||||
check("Morning Sun heals half in clear weather",
|
||||
Effects.weatherHealFraction(nil), 1 / 2)
|
||||
checkNear("...two thirds in sun", Effects.weatherHealFraction("sun"), 2 / 3, 0.001)
|
||||
check("...a quarter in rain", Effects.weatherHealFraction("rain"), 1 / 4)
|
||||
-- BattleCommand_TimeBasedHealContinue's .Multipliers ladder, walked by the
|
||||
-- time of day and the weather (engine/battle/effect_commands.asm:6388-6454).
|
||||
local DAY_F = Effects.SUN_HEAL.EFFECT_SYNTHESIS
|
||||
check("Synthesis heals half in the day in clear weather",
|
||||
Effects.timeBasedHealFraction(nil, DAY_F, 1), 1 / 2)
|
||||
check("...the lot in sun", Effects.timeBasedHealFraction("sun", DAY_F, 1), 1)
|
||||
check("...a quarter in rain",
|
||||
Effects.timeBasedHealFraction("rain", DAY_F, 1), 1 / 4)
|
||||
check("...a quarter at night in clear weather",
|
||||
Effects.timeBasedHealFraction(nil, DAY_F, 2), 1 / 4)
|
||||
check("...a half at night in sun",
|
||||
Effects.timeBasedHealFraction("sun", DAY_F, 2), 1 / 2)
|
||||
check("...an eighth at night in a sandstorm",
|
||||
Effects.timeBasedHealFraction("sandstorm", DAY_F, 2), 1 / 8)
|
||||
check("Moonlight wants NITE instead",
|
||||
Effects.timeBasedHealFraction(nil, Effects.SUN_HEAL.EFFECT_MOONLIGHT, 2),
|
||||
1 / 2)
|
||||
check("a battle with no clock still heals half",
|
||||
Effects.timeBasedHealFraction(nil, DAY_F, nil), 1 / 2)
|
||||
|
||||
-- ProtectChance halves for every consecutive use and gives up after eight.
|
||||
check("first Protect always works", Effects.protectChance(0), 0xff)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Bill's PC d-pad: only MOVE <PK><MN> W/O MAIL walks the boxes (#1710).
|
||||
--
|
||||
-- luajit tests/gen2_billspc_dpad_test.lua
|
||||
--
|
||||
-- ROM-free. Withdraw_UpDown is the entire joypad handler for the WITHDRAW
|
||||
-- list and the DEPOSIT list (engine/pokemon/bills_pc.asm:806-820) and reads
|
||||
-- PAD_UP and PAD_DOWN and nothing else. BillsPC_PressLeft / PressRight
|
||||
-- (:909-931) are reachable only through MoveMonWithoutMail_DPad and
|
||||
-- MoveMonWithoutMail_DPad_2 (:822-869), whose one caller is
|
||||
-- _MovePKMNWithoutMail (:480) -- so left and right belong to the MOVE screen
|
||||
-- alone, both while it is choosing a mon and while the insert cursor is up.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 bills pc dpad")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local BoxMenu = require("src.ui.gen2.BoxMenu")
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
|
||||
local function mon(species)
|
||||
return { species = species, nickname = species, name = species,
|
||||
level = 5, hp = 20, maxHp = 20 }
|
||||
end
|
||||
|
||||
local function newInput()
|
||||
local input = { pressed = {} }
|
||||
function input:press(...)
|
||||
for _, button in ipairs({ ... }) do self.pressed[button] = true end
|
||||
end
|
||||
function input:wasPressed(button)
|
||||
if self.pressed[button] then
|
||||
self.pressed[button] = nil
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
function input:isDown() return false end
|
||||
return input
|
||||
end
|
||||
|
||||
local function newGame(save)
|
||||
local input = newInput()
|
||||
return {
|
||||
input = input,
|
||||
save = save,
|
||||
data = { audio = {}, pokemon = {} },
|
||||
stack = { _items = {},
|
||||
push = function(self, s) self._items[#self._items + 1] = s end,
|
||||
pop = function(self) return table.remove(self._items) end,
|
||||
top = function(self) return self._items[#self._items] end,
|
||||
},
|
||||
}, input
|
||||
end
|
||||
|
||||
-- Three party mons and two boxes with something in them, so every list under
|
||||
-- test has rows and the last-healthy rule never gets in the way.
|
||||
local function newSave()
|
||||
local save = { party = { mon("CYNDAQUIL"), mon("TOTODILE"), mon("PIDGEY") },
|
||||
boxes = {}, boxNames = {}, currentBox = 3 }
|
||||
Boxes.box(save, 3)[1] = mon("GEODUDE")
|
||||
Boxes.box(save, 3)[2] = mon("ZUBAT")
|
||||
Boxes.box(save, 4)[1] = mon("ONIX")
|
||||
return save
|
||||
end
|
||||
|
||||
local function press(screen, input, ...)
|
||||
for _, button in ipairs({ ... }) do
|
||||
input:press(button)
|
||||
screen:update(0)
|
||||
end
|
||||
end
|
||||
|
||||
local function open(mode)
|
||||
local save = newSave()
|
||||
local game, input = newGame(save)
|
||||
local menu = BoxMenu.new(game, { save = save, mode = mode,
|
||||
onClose = function() end })
|
||||
return menu, input, save
|
||||
end
|
||||
|
||||
-- ---- the withdraw list ----------------------------------------------------
|
||||
do
|
||||
local menu, input = open("withdraw")
|
||||
eq(menu.boxIndex, 3, "the withdraw list opens on the current box")
|
||||
eq(menu:title(), "BOX3", "and the header names it")
|
||||
|
||||
press(menu, input, "left")
|
||||
eq(menu.boxIndex, 3, "LEFT on the withdraw list does not change the box")
|
||||
eq(menu:title(), "BOX3", "the header still reads BOX3")
|
||||
eq(menu.index, 1, "and the cursor has not been reset by a box step")
|
||||
|
||||
-- Two more LEFTs, so a screen that steps and then steps back cannot pass by
|
||||
-- landing on 3 again.
|
||||
press(menu, input, "left", "left")
|
||||
eq(menu.boxIndex, 3, "and neither do two more")
|
||||
eq(#menu:list(), 2, "the list is still BOX3's two mons")
|
||||
|
||||
-- RIGHT from a fresh screen, for the same reason.
|
||||
menu, input = open("withdraw")
|
||||
press(menu, input, "right", "right")
|
||||
eq(menu.boxIndex, 3, "RIGHT does not change the box either")
|
||||
eq(menu:title(), "BOX3", "the header is unmoved")
|
||||
|
||||
-- Withdraw_UpDown DOES read up and down, so the fix must not have taken the
|
||||
-- whole d-pad away.
|
||||
press(menu, input, "down")
|
||||
eq(menu.index, 2, "DOWN still walks the list")
|
||||
press(menu, input, "up")
|
||||
eq(menu.index, 1, "and UP walks back")
|
||||
end
|
||||
|
||||
-- ---- the deposit list -----------------------------------------------------
|
||||
--
|
||||
-- _DepositPKMN's .HandleJoypad calls the same Withdraw_UpDown, and its list is
|
||||
-- the party (wBillsPC_LoadedBox is zeroed at bills_pc.asm:17-18), so there is
|
||||
-- no box for left and right to walk to in the first place.
|
||||
do
|
||||
local menu, input = open("deposit")
|
||||
eq(menu:title(), "PARTY <PK><MN>", "the deposit list browses the party")
|
||||
local before = menu.boxIndex
|
||||
press(menu, input, "left", "right", "left")
|
||||
eq(menu.boxIndex, before, "the deposit list ignores left and right")
|
||||
eq(menu:title(), "PARTY <PK><MN>", "and stays on the party")
|
||||
eq(#menu:list(), 3, "with all three party mons")
|
||||
press(menu, input, "down")
|
||||
eq(menu.index, 2, "up and down still work here too")
|
||||
end
|
||||
|
||||
-- ---- the move screen ------------------------------------------------------
|
||||
--
|
||||
-- The half that is supposed to have box stepping. Gating this on the wrong
|
||||
-- mode string ("withdraw") would leave the reported bug in place and break
|
||||
-- this instead, which is why it is asserted next to the other two.
|
||||
do
|
||||
local menu, input, save = open("move")
|
||||
eq(menu.boxIndex, 3, "the move screen opens on the current box")
|
||||
|
||||
press(menu, input, "right")
|
||||
eq(menu.boxIndex, 4, "RIGHT walks to the next box")
|
||||
eq(menu:title(), "BOX4", "and the header follows")
|
||||
eq(#menu:list(), 1, "showing BOX4's one mon")
|
||||
|
||||
press(menu, input, "left", "left", "left", "left")
|
||||
eq(menu.boxIndex, 0, "four LEFTs from BOX4 reach the PARTY, box 0")
|
||||
eq(menu:title(), "PARTY <PK><MN>", "BillsPC_BoxName's .party arm")
|
||||
eq(#menu:list(), 3, "and the list is the party")
|
||||
|
||||
press(menu, input, "left")
|
||||
eq(menu.boxIndex, Boxes.NUM_BOXES,
|
||||
"LEFT off box 0 wraps to the last box (BillsPC_PressLeft)")
|
||||
press(menu, input, "right")
|
||||
eq(menu.boxIndex, 0, "and RIGHT wraps back through it")
|
||||
|
||||
-- The insert cursor is the separate .Joypad2 arm, and it walks boxes too:
|
||||
-- choose the first party mon, MOVE, then drive the cursor to BOX1.
|
||||
press(menu, input, "a", "a")
|
||||
eq(menu.phase, "insert", "MOVE opens the insert cursor")
|
||||
press(menu, input, "right")
|
||||
eq(menu.boxIndex, 1, "RIGHT walks the insert cursor to BOX1")
|
||||
eq(menu:title(), "BOX1", "and the header names the destination")
|
||||
press(menu, input, "left")
|
||||
eq(menu.boxIndex, 0, "LEFT walks it back to the party")
|
||||
press(menu, input, "right", "a")
|
||||
eq(#save.party, 2, "A there really does move the mon")
|
||||
eq(Boxes.box(save, 1)[1].nickname, "CYNDAQUIL", "into the box it named")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,280 @@
|
||||
-- Everything that lands a mon in a box lands it healed (#1696).
|
||||
--
|
||||
-- luajit tests/gen2_box_intake_test.lua
|
||||
--
|
||||
-- ROM-free. box_struct ends at Level and has no Status, no HP and no MaxHP
|
||||
-- at all -- those three belong to party_struct alone (macros/ram.asm:7-40) --
|
||||
-- so a boxed mon has nowhere to keep damage, and every screen that reads one
|
||||
-- goes through CalcTempmonStats, whose .not_egg arm copies MON_MAXHP over
|
||||
-- MON_HP and whose .zero_status arm blanks MON_STATUS before anything is
|
||||
-- drawn (engine/pokemon/tempmon.asm:39-83). The four intakes are the catch
|
||||
-- that overflows to the PC (`.SendToPC` / SendMonIntoBox,
|
||||
-- engine/items/item_effects.asm:604, engine/pokemon/move_mon.asm:942-1065),
|
||||
-- the PC's own deposit, MOVE <PK><MN> W/O MAIL, and the bug contest's
|
||||
-- full-party arm (engine/pokemon/caught_nickname.asm:72-90).
|
||||
--
|
||||
-- The asymmetry at the bottom is real cart behaviour and is the near-miss to
|
||||
-- watch: a catch that fits in the PARTY goes through TryAddMonToParty
|
||||
-- (item_effects.asm:551-556) and KEEPS its damage, so a fix that heals every
|
||||
-- capture is as wrong as one that heals none.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 box intake")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local BoxMenu = require("src.ui.gen2.BoxMenu")
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local BugContest = require("src.core.gen2.BugContest")
|
||||
local Input = require("src.core.Input")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- A mon hurt and statused exactly the way a ball lands on one.
|
||||
local function hurt(species)
|
||||
return { species = species, nickname = species, name = species, level = 5,
|
||||
hp = 1, maxHp = 20, status = "sleep", statusTurns = 3,
|
||||
moves = { { id = "TACKLE", pp = 2, maxPp = 35 } } }
|
||||
end
|
||||
|
||||
local function healthy(species)
|
||||
return { species = species, nickname = species, name = species, level = 5,
|
||||
hp = 20, maxHp = 20,
|
||||
moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
|
||||
end
|
||||
|
||||
local function newSave(party)
|
||||
return { party = party or {}, boxes = {}, boxNames = {}, currentBox = 1 }
|
||||
end
|
||||
|
||||
-- ---- Boxes.enterBox, the shared seam --------------------------------------
|
||||
do
|
||||
local mon = hurt("PIDGEY")
|
||||
Boxes.enterBox(mon)
|
||||
eq(mon.hp, 20, "a mon entering a box is at full HP")
|
||||
eq(mon.status, nil, "with no status byte to carry it")
|
||||
eq(mon.statusTurns, nil, "and no sleep counter either")
|
||||
eq(mon.moves[1].pp, 35, "RestorePPOfDepositedPokemon refilled its PP")
|
||||
|
||||
-- .not_egg is skipped for an EGG: CalcTempmonStats writes 0 over MON_HP
|
||||
-- instead, which is what keeps a stored egg from reading as hatchable.
|
||||
local egg = hurt("TOGEPI")
|
||||
egg.isEgg = true
|
||||
Boxes.enterBox(egg)
|
||||
eq(egg.hp, 0, "an EGG stays at 0 HP")
|
||||
eq(egg.status, nil, "and still loses the status byte")
|
||||
|
||||
-- A mon with no maxHp recorded must not come out with hp nil, which would
|
||||
-- read as fainted everywhere downstream.
|
||||
local odd = { species = "MISSINGNO", hp = 4 }
|
||||
Boxes.enterBox(odd)
|
||||
eq(odd.hp, 4, "a mon with no maxHp keeps the HP it had")
|
||||
eq(Boxes.enterBox(nil), nil, "and nil is handed straight back")
|
||||
end
|
||||
|
||||
-- ---- the PC's own deposit -------------------------------------------------
|
||||
--
|
||||
-- Read while the mon is STILL IN THE BOX: this is what the PC's STATS screen
|
||||
-- shows, and healing only on the way back out looks right in the party while
|
||||
-- the storage screen still reports 1 HP and SLP.
|
||||
do
|
||||
local save = newSave({ healthy("CYNDAQUIL"), hurt("PIDGEY"),
|
||||
healthy("GEODUDE") })
|
||||
local ok = Boxes.deposit(save, 2, 1)
|
||||
check(ok, "the hurt mon deposits")
|
||||
local boxed = Boxes.box(save, 1)[1]
|
||||
eq(boxed.species, "PIDGEY", "and it is the one in the box")
|
||||
eq(boxed.hp, 20, "sitting there at full HP")
|
||||
eq(boxed.status, nil, "with the status gone")
|
||||
eq(boxed.moves[1].pp, 35, "and its PP back")
|
||||
|
||||
-- ...and the withdraw arm still hands back the same whole mon.
|
||||
local took
|
||||
ok, took = Boxes.withdraw(save, 1, 1)
|
||||
check(ok, "withdrawing it works")
|
||||
eq(took.hp, 20, "and it is still whole on the way out")
|
||||
end
|
||||
|
||||
-- ---- MOVE <PK><MN> W/O MAIL ----------------------------------------------
|
||||
do
|
||||
local function newInput()
|
||||
local input = { pressed = {} }
|
||||
function input:press(...)
|
||||
for _, button in ipairs({ ... }) do self.pressed[button] = true end
|
||||
end
|
||||
function input:wasPressed(button)
|
||||
if self.pressed[button] then
|
||||
self.pressed[button] = nil
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
function input:isDown() return false end
|
||||
return input
|
||||
end
|
||||
|
||||
local function press(screen, input, ...)
|
||||
for _, button in ipairs({ ... }) do
|
||||
input:press(button)
|
||||
screen:update(0)
|
||||
end
|
||||
end
|
||||
|
||||
local function openMove(save)
|
||||
local input = newInput()
|
||||
local game = {
|
||||
input = input, save = save, data = { audio = {}, pokemon = {} },
|
||||
stack = { _items = {},
|
||||
push = function(self, s) self._items[#self._items + 1] = s end,
|
||||
pop = function(self) return table.remove(self._items) end,
|
||||
top = function(self) return self._items[#self._items] end,
|
||||
},
|
||||
}
|
||||
return BoxMenu.new(game, { save = save, mode = "move",
|
||||
onClose = function() end }), input
|
||||
end
|
||||
|
||||
-- party -> box: .CopyToBox writes a box_struct, so the damage cannot follow.
|
||||
local save = newSave({ hurt("PIDGEY"), healthy("CYNDAQUIL"),
|
||||
healthy("GEODUDE") })
|
||||
local menu, input = openMove(save)
|
||||
press(menu, input, "left")
|
||||
eq(menu.boxIndex, 0, "the move screen walks left to the PARTY")
|
||||
press(menu, input, "a", "a")
|
||||
eq(menu.phase, "insert", "MOVE opens the insert cursor")
|
||||
press(menu, input, "right", "a")
|
||||
eq(#save.party, 2, "the hurt mon left the party")
|
||||
local boxed = Boxes.box(save, 1)[1]
|
||||
eq(boxed and boxed.nickname, "PIDGEY", "and landed in BOX1")
|
||||
eq(boxed and boxed.hp, 20, "at full HP")
|
||||
eq(boxed and boxed.status, nil, "with no status")
|
||||
eq(boxed and boxed.moves[1].pp, 35, "and full PP")
|
||||
|
||||
-- party -> party is the same screen's reorder, and party_struct HAS the
|
||||
-- three fields, so this arm must leave the damage exactly where it was.
|
||||
save = newSave({ hurt("PIDGEY"), healthy("CYNDAQUIL"), healthy("GEODUDE") })
|
||||
menu, input = openMove(save)
|
||||
press(menu, input, "left")
|
||||
eq(menu.boxIndex, 0, "back on the party")
|
||||
press(menu, input, "a", "a", "down", "down", "a")
|
||||
eq(#save.party, 3, "the party still has three")
|
||||
local moved
|
||||
for _, mon in ipairs(save.party) do
|
||||
if mon.nickname == "PIDGEY" then moved = mon end
|
||||
end
|
||||
eq(moved and moved.hp, 1, "a party-to-party move keeps the damage")
|
||||
eq(moved and moved.status, "sleep", "and keeps the status")
|
||||
end
|
||||
|
||||
-- ---- the bug contest's full-party arm -------------------------------------
|
||||
do
|
||||
local save = newSave({ healthy("A"), healthy("B"), healthy("C"),
|
||||
healthy("D"), healthy("E"), healthy("F") })
|
||||
save.currentBox = 2
|
||||
save.playerName = "GOLD"
|
||||
BugContest.start(save)
|
||||
BugContest.switchCaught(save, hurt("SCYTHER"))
|
||||
local result = BugContest.collectCaughtMon(save, 6)
|
||||
eq(result, BugContest.BOXED_MON, "a full party sends the contest catch to a box")
|
||||
local boxed = Boxes.box(save, 2)[1]
|
||||
eq(boxed and boxed.nickname, "SCYTHER", "it is in the current box")
|
||||
eq(boxed and boxed.hp, 20, "at full HP")
|
||||
eq(boxed and boxed.status, nil, "with no status")
|
||||
eq(boxed and boxed.moves[1].pp, 35, "and its PP restored")
|
||||
end
|
||||
|
||||
-- ---- the catch that overflows to the PC -----------------------------------
|
||||
--
|
||||
-- The whole chain, through the real BattleState: a ball that lands while the
|
||||
-- party is full runs `.SendToPC`, and what the PC's STATS screen then reads
|
||||
-- is the box_struct SendMonIntoBox wrote.
|
||||
do
|
||||
local TYPES = { NORMAL = { id = "NORMAL", index = 0,
|
||||
category = "physical" } }
|
||||
local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35,
|
||||
type = "NORMAL", accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" } }
|
||||
local POKEMON = {
|
||||
growthRates = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1,
|
||||
squared = 0, linear = 0, constant = 0 } },
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL",
|
||||
baseStats = { hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
specialAttack = 60, specialDefense = 50 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
PIDGEY = {
|
||||
id = "PIDGEY", index = 16, name = "PIDGEY",
|
||||
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
}
|
||||
local DATA = {
|
||||
pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} },
|
||||
items = { POKE_BALL = { id = "POKE_BALL", name = "POKe BALL",
|
||||
pocket = "BALL" } },
|
||||
}
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- partySize 6 fills the party so the catch overflows; 5 leaves it room.
|
||||
local function catch(partySize)
|
||||
Input:init()
|
||||
local party = {}
|
||||
for i = 1, partySize do
|
||||
local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||
mon.nickname = "MON" .. i
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
party[i] = mon
|
||||
end
|
||||
local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 4, maxPp = 35 } }
|
||||
-- one HP and asleep: what a ball usually lands on
|
||||
wild.hp = 1
|
||||
wild.status = "sleep"
|
||||
wild.statusTurns = 3
|
||||
local save = { party = party, inventory = { POKE_BALL = 1 },
|
||||
currentBox = 3, boxes = {}, boxNames = {} }
|
||||
local game = {
|
||||
data = DATA, save = save, input = Input, options = {},
|
||||
stack = { push = function() end, pop = function() end,
|
||||
top = function() return nil end },
|
||||
}
|
||||
-- random 0: the catch roll always lands.
|
||||
local battle = Battle.new({ data = DATA, party = party, wild = wild,
|
||||
save = save, random = function() return 0 end })
|
||||
local screen = BattleState.new(game, { battle = battle, save = save })
|
||||
screen:useItem("POKE_BALL")
|
||||
return battle, save, wild
|
||||
end
|
||||
|
||||
local battle, save, wild = catch(6)
|
||||
eq(battle.outcome, "caught", "the ball lands on a full party")
|
||||
eq(#save.party, 6, "the party did not grow")
|
||||
local boxed = Boxes.box(save, 3)[1]
|
||||
eq(boxed, wild, "SendMonIntoBox put the catch in slot 1 of the current box")
|
||||
eq(boxed and boxed.hp, boxed and boxed.maxHp,
|
||||
"and the PC holds it at full HP")
|
||||
eq(boxed and boxed.status, nil, "with no status on the STATS screen")
|
||||
eq(boxed and boxed.statusTurns, nil, "and no sleep counter behind it")
|
||||
eq(boxed and boxed.moves[1].pp, 35, "PP refilled with the rest")
|
||||
|
||||
-- The other half of the cart's asymmetry.
|
||||
battle, save, wild = catch(5)
|
||||
eq(battle.outcome, "caught", "the ball lands with room in the party")
|
||||
eq(save.party[6], wild, "and TryAddMonToParty keeps it in the party")
|
||||
eq(wild.hp, 1, "at the 1 HP it was caught on")
|
||||
eq(wild.status, "sleep", "still asleep")
|
||||
eq(wild.moves[1].pp, 4, "and with the PP it had left")
|
||||
eq(Boxes.count(save, 3), 0, "nothing went to the PC")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -1108,7 +1108,7 @@ do
|
||||
"function World:bugContestBattleOver",
|
||||
"self:bugContestBattleOver()",
|
||||
"BugContestResultsWarpScript",
|
||||
"BugContest.ENGINE_BUG_CONTEST_TIMER",
|
||||
"FieldMoves.BUG_CONTEST_FLAG",
|
||||
-- Kurt's menu and the item pair the ladder needs.
|
||||
"scriptMenu = function(header, onChoose)",
|
||||
"itemIndex = function(id)",
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
-- The Cycling Road: the START poll the forced roll used to swallow (#1718) and
|
||||
-- the ALWAYS_ON_BIKE flag SURF never saw (#1749).
|
||||
--
|
||||
-- luajit tests/gen2_cycling_road_test.lua
|
||||
--
|
||||
-- Route17AlwaysOnBikeCallback sets ENGINE_ALWAYS_ON_BIKE and ENGINE_DOWNHILL on
|
||||
-- MAPCALLBACK_NEWMAP (maps/Route17.asm:13-16), and DOWNHILL is what makes
|
||||
-- .GetDPad read an empty pad as DOWN: the player is stepping on every single
|
||||
-- frame of that road.
|
||||
--
|
||||
-- #1718: the port gated START on "not moving", which on Route 17 is never --
|
||||
-- stepBody lands the step and queues the next one inside the SAME tick. The
|
||||
-- cart refuses only on PLAYERMOVEMENT_CONTINUE; a QUEUED step answers
|
||||
-- PLAYERMOVEMENT_FINISH (player_movement.asm:459-461), whose arm is `xor a /
|
||||
-- ld c, a / ret` (events.asm:761-779), so CheckMenuOW still runs there
|
||||
-- (events.asm:485-498). That landing frame comes round once in eight, so the
|
||||
-- other half of the fix is Game2's joypad latch (events.asm:193-199, :215-231);
|
||||
-- it needs a real Input and is driven by
|
||||
-- tests/drivers/gold_cycling_road_bug1718_1749_test.lua.
|
||||
--
|
||||
-- #1749: both surf entry points already refused on ctx.alwaysOnBike
|
||||
-- (engine/events/overworld.asm:350-352, :513-515), but World:fieldContext never
|
||||
-- set the field, so neither refusal was reachable.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 cycling road")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Bike = require("src.world.gen2.Bike")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
-- constants/collision_constants.asm
|
||||
local COLL_LAND, COLL_WATER = 0x00, 0x29
|
||||
|
||||
check(Permissions.isLand(COLL_LAND), "COLL_FLOOR $00 is a LAND_TILE")
|
||||
check(Permissions.isWater(COLL_WATER), "COLL_WATER $29 is a WATER_TILE")
|
||||
|
||||
-- ---- the gate: World:acceptsMenuInput, OWPlayerInput's preamble (#1718) ----
|
||||
--
|
||||
-- The shipped methods, over a world carrying only the fields they read.
|
||||
local function gate(w)
|
||||
w.busy = World.busy
|
||||
w.playerCollision = World.playerCollision
|
||||
return World.acceptsMenuInput(w)
|
||||
end
|
||||
|
||||
eq(gate({ player = { moving = false } }), true,
|
||||
"standing still: PLAYERMOVEMENT_FINISH, so CheckMenuOW runs")
|
||||
eq(gate({ player = { moving = true }, stepFinished = false }),
|
||||
false, "mid-step: PLAYERMOVEMENT_CONTINUE, and OWPlayerInput returns")
|
||||
eq(gate({ player = { moving = true }, stepFinished = true }),
|
||||
true, "the frame the step lands reads it again, whatever was queued on top")
|
||||
|
||||
-- stepFinished opens ONE arm and must not reach past it.
|
||||
eq(gate({ player = { moving = true }, stepFinished = true, battleActive = true }),
|
||||
false, "a battle still refuses on the landing frame")
|
||||
eq(gate({ player = { moving = true }, stepFinished = true, textbox = {} }),
|
||||
false, "an open text box still refuses on the landing frame")
|
||||
eq(gate({ player = { moving = true }, stepFinished = true, mapSetup = {} }),
|
||||
false, "and so does a map setup script")
|
||||
eq(gate({ player = { moving = true }, stepFinished = true, fieldMove = {} }),
|
||||
false, "and a field move's tail")
|
||||
eq(gate({ player = { moving = false },
|
||||
vm = { running = function() return true end } }),
|
||||
false, "wScriptRunning refuses even standing still (events.asm:238-243)")
|
||||
|
||||
-- CheckStandingOnIce's carry, the arm that sits below the movement one.
|
||||
do
|
||||
local COLL_ICE = 0x23
|
||||
check(Permissions.isIce(COLL_ICE), "COLL_ICE $23")
|
||||
local iced = {
|
||||
player = { moving = false, cellX = 1, cellY = 1 },
|
||||
stepFinished = true,
|
||||
turningDirection = "down",
|
||||
map = { cellCollision = function() return COLL_ICE end },
|
||||
}
|
||||
eq(gate(iced), false, "a latched slide on ice refuses")
|
||||
iced.turningDirection = nil
|
||||
eq(gate(iced), true, "and lets go once the latch clears")
|
||||
end
|
||||
|
||||
-- ---- .GetDPad and .DoStep on a DOWNHILL map --------------------------------
|
||||
|
||||
eq(Bike.forcedDirection(nil, true), "down",
|
||||
"no direction held on the Cycling Road is a step DOWN")
|
||||
eq(Bike.forcedDirection("up", true), "up", "a held direction still wins")
|
||||
eq(Bike.forcedDirection(nil, false), nil, "and off the road, nothing")
|
||||
eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", true, Player.STEP_FRAMES), 8,
|
||||
"the roll is a STEP_BIKE cell: half a walk")
|
||||
eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "left", true, Player.STEP_FRAMES), 16,
|
||||
"and every other direction gets the walking duration back")
|
||||
|
||||
-- ---- the roll: 24 ticks of Route 17, #1718's premise -----------------------
|
||||
--
|
||||
-- One iteration of Game2's fixed step over a real Player.
|
||||
|
||||
local openRoad = {
|
||||
inBounds = function() return true end,
|
||||
isWalkable = function() return true end,
|
||||
}
|
||||
|
||||
local function tick(w)
|
||||
-- Game2's fixed step asks first, with last tick's stepFinished still up.
|
||||
local accepts = gate(w)
|
||||
-- What the port asked before the fix, counted off the same run.
|
||||
local standing = not w.player.moving
|
||||
local p = w.player
|
||||
w.stepFinished = false
|
||||
w.stepFinished = p:update()
|
||||
if not p.moving then
|
||||
local dir = Bike.forcedDirection(nil, true)
|
||||
p.stepFrames = Bike.stepFrames(
|
||||
FieldMoves.PLAYER_BIKE, dir, true, Player.STEP_FRAMES)
|
||||
p:tryMove(dir, openRoad, nil)
|
||||
end
|
||||
return accepts, standing
|
||||
end
|
||||
|
||||
do
|
||||
local w = { player = Player.new(9, 36, "down") }
|
||||
local polled, idle, gaps, run = {}, 0, 0, 0
|
||||
for n = 1, 24 do
|
||||
local accepts, standing = tick(w)
|
||||
if accepts then
|
||||
polled[#polled + 1] = n
|
||||
if run > gaps then gaps = run end
|
||||
run = 0
|
||||
else
|
||||
run = run + 1
|
||||
end
|
||||
if standing then idle = idle + 1 end
|
||||
end
|
||||
|
||||
-- The direct proof of the premise.
|
||||
eq(idle, 1, "the forced roll leaves the player standing on tick 1 and never again")
|
||||
eq(w.player.moving, true, "and it is still rolling 24 ticks later")
|
||||
eq(w.player.cellY > 36, true, "having actually travelled down the road")
|
||||
|
||||
eq(#polled, 3, "the landing frame comes round three times in 24 ticks")
|
||||
eq(table.concat(polled, ","), "1,10,18",
|
||||
"tick 1, then one poll per 8-frame bike step")
|
||||
eq(gaps, 8, "eight refused ticks between polls -- one press in eight lands")
|
||||
end
|
||||
|
||||
-- ---- ALWAYS_ON_BIKE reaches the field-move context (#1749) -----------------
|
||||
--
|
||||
-- The bug is the wiring, so the flag comes through the shipped
|
||||
-- World:alwaysOnBike -> World:engineFlag pair off a real save table.
|
||||
|
||||
local function surfMon()
|
||||
return { species = "LAPRAS", moves = { "SURF" } }
|
||||
end
|
||||
|
||||
-- A road cell facing water: with the flag clear this ctx surfs.
|
||||
local function fieldSelf(onBike, badge)
|
||||
local coll = {}
|
||||
local map = {
|
||||
width = 8, height = 8,
|
||||
def = { blocks = {}, environment = "ROUTE", tileset = "TILESET_KANTO" },
|
||||
cellCollision = function(_, cx, cy) return coll[cy * 16 + cx] or COLL_LAND end,
|
||||
}
|
||||
for i = 1, 64 do map.def.blocks[i] = 1 end
|
||||
-- water in the cell the player faces, land under their feet
|
||||
coll[36 * 16 + 10] = COLL_WATER
|
||||
local save = {
|
||||
party = { surfMon() },
|
||||
player = { badges = { FOG = badge ~= false or nil } },
|
||||
engineFlags = { [Bike.ENGINE_ALWAYS_ON_BIKE] = onBike or nil },
|
||||
}
|
||||
return {
|
||||
player = Player.new(9, 36, "right"),
|
||||
map = map,
|
||||
playerState = FieldMoves.PLAYER_BIKE,
|
||||
game = { save = save },
|
||||
engineFlags = World.engineFlags,
|
||||
engineFlag = World.engineFlag,
|
||||
alwaysOnBike = World.alwaysOnBike,
|
||||
blockIndexAt = World.blockIndexAt,
|
||||
escapeRopeTarget = function() return nil end,
|
||||
facingObject = function() return nil end,
|
||||
hour = function() return 12 end,
|
||||
}
|
||||
end
|
||||
|
||||
eq(World.fieldContext(fieldSelf(true)).alwaysOnBike, true,
|
||||
"fieldContext carries ALWAYS_ON_BIKE onto the road")
|
||||
eq(World.fieldContext(fieldSelf(false)).alwaysOnBike, false,
|
||||
"and reports it clear everywhere else")
|
||||
|
||||
do
|
||||
-- The ctx really is surfable, or the refusals below pass for the wrong reason.
|
||||
local ctx = World.fieldContext(fieldSelf(false), surfMon())
|
||||
eq(ctx.facing, "right", "facing the water")
|
||||
check(Permissions.isWater(ctx.facingColl), "and the faced cell is water")
|
||||
local menu = FieldMoves.surfFromMenu(ctx)
|
||||
eq(menu.ok, true, "flag clear: the PACK's SURF goes through")
|
||||
local ow = FieldMoves.trySurfOW(ctx)
|
||||
eq(ow.ok, true, "flag clear: the A press offers to SURF")
|
||||
eq(ow.ask, FieldMoves.TEXT.ASK_SURF, "with the usual prompt")
|
||||
end
|
||||
|
||||
do
|
||||
local ctx = World.fieldContext(fieldSelf(true), surfMon())
|
||||
-- .FailSurf: MenuTextboxBackup on CantSurfText, so the menu refusal TALKS.
|
||||
local menu = FieldMoves.surfFromMenu(ctx)
|
||||
eq(menu.ok, false, "on the road the menu refuses")
|
||||
eq(menu.text, FieldMoves.TEXT.CANT_SURF, "with CantSurfText")
|
||||
-- TrySurfOW's arm is `.quit`: xor a, no script and no text at all.
|
||||
local ow = FieldMoves.trySurfOW(ctx)
|
||||
eq(ow.ok, false, "and the A press refuses")
|
||||
eq(ow.took, nil, "silently -- .quit queues no script")
|
||||
eq(ow.text, nil, "and prints nothing")
|
||||
end
|
||||
|
||||
do
|
||||
-- CheckBadge runs ABOVE the ALWAYS_ON_BIKE test.
|
||||
local ctx = World.fieldContext(fieldSelf(true, false), surfMon())
|
||||
local menu = FieldMoves.surfFromMenu(ctx)
|
||||
eq(menu.text, FieldMoves.TEXT.BADGE_REQUIRED,
|
||||
"no FOGBADGE still outranks the road (engine/events/overworld.asm:347-352)")
|
||||
end
|
||||
|
||||
-- ---- the real Route 17, when a cache is around -----------------------------
|
||||
--
|
||||
-- Pins the coordinates the driver walks to, so a map edit fails here instead
|
||||
-- of parking a human at a wall.
|
||||
|
||||
local cache = os.getenv("GOLD_CACHE")
|
||||
if not cache then
|
||||
cache = (os.getenv("HOME") or "")
|
||||
.. "/Library/Application Support/LOVE/gold-dev/gold"
|
||||
end
|
||||
local mapsPath = cache .. "/data/generated/maps.lua"
|
||||
local mf = io.open(mapsPath, "r")
|
||||
if not mf then
|
||||
check(true, "gold cache absent : fixture checks only (SKIP cache facts)")
|
||||
S.finish()
|
||||
return
|
||||
end
|
||||
mf:close()
|
||||
|
||||
local Map = require("src.world.gen2.Map")
|
||||
local maps = assert(loadfile(mapsPath))()
|
||||
local tilesets = assert(loadfile(cache .. "/data/generated/tilesets.lua"))()
|
||||
local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))()
|
||||
|
||||
do
|
||||
local def = maps.ROUTE_17
|
||||
check(def ~= nil, "the cache carries ROUTE_17")
|
||||
local ops = {}
|
||||
for _, cb in ipairs(def.callbacks or {}) do
|
||||
if cb.callback == "MAPCALLBACK_NEWMAP" then
|
||||
for _, cmd in ipairs(scripts[cb.scriptKey] or {}) do
|
||||
ops[#ops + 1] = cmd.op
|
||||
end
|
||||
end
|
||||
end
|
||||
eq(table.concat(ops, ","), "setflag,setflag,endcallback",
|
||||
"Route17AlwaysOnBikeCallback is the two unconditional setflags")
|
||||
|
||||
local map = Map.new(def, tilesets[def.tileset])
|
||||
-- The lane the driver rides: x=9 is road, x=10 is the water beside it, and
|
||||
-- the four bikers stand at (4,17) (16,32) (3,53) (6,80), well clear of it.
|
||||
for cy = 36, 48 do
|
||||
check(Permissions.isLand(map:cellCollision(9, cy)),
|
||||
"ROUTE_17 (9," .. cy .. ") is road")
|
||||
check(Permissions.isWater(map:cellCollision(10, cy)),
|
||||
"ROUTE_17 (10," .. cy .. ") is water")
|
||||
end
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
check(not (obj.x == 9 and obj.y >= 34 and obj.y <= 50),
|
||||
"no object parked in the driver's lane")
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,152 @@
|
||||
-- A script-gifted mon registers in the #DEX. GiveShuckle
|
||||
-- (engine/events/shuckle.asm:14) hands its mon to TryAddMonToParty, whose
|
||||
-- PARTYMON arm falls into .registerpokedex / SetSeenAndCaughtMon
|
||||
-- (engine/pokemon/move_mon.asm:179-197), so Mania's SHUCKIE arrives already
|
||||
-- ticked off. #1719. ROM-free:
|
||||
-- luajit tests/gen2_dex_gift_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 dex gift")
|
||||
local check, eq, same = S.check, S.eq, S.same
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Vm = require("src.script.gen2.Vm")
|
||||
local Events = require("src.world.gen2.Events")
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
-- constants/pokemon_constants.asm: SHUCKLE is 213.
|
||||
local SHUCKLE_INDEX = 213
|
||||
|
||||
-- Just enough of data.pokemon / data.moves for Mon.new to build a SHUCKLE,
|
||||
-- plus one other species so the "index, not name" mistake has something to
|
||||
-- collide with.
|
||||
local function fixture()
|
||||
return {
|
||||
pokemon = {
|
||||
SHUCKLE = { name = "SHUCKLE", index = SHUCKLE_INDEX,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
types = { "BUG", "ROCK" },
|
||||
baseStats = { hp = 20, attack = 10, defense = 230, speed = 5,
|
||||
specialAttack = 10, specialDefense = 230 },
|
||||
levelMoves = { { level = 1, move = "CONSTRICT" } } },
|
||||
TOTODILE = { name = "TOTODILE", index = 158,
|
||||
growthRate = "MEDIUM_SLOW",
|
||||
types = { "WATER", "WATER" },
|
||||
baseStats = { hp = 50, attack = 65, defense = 64, speed = 43,
|
||||
specialAttack = 44, specialDefense = 48 },
|
||||
levelMoves = { { level = 1, move = "SCRATCH" } } },
|
||||
},
|
||||
moves = {
|
||||
CONSTRICT = { name = "CONSTRICT", pp = 35 },
|
||||
SCRATCH = { name = "SCRATCH", pp = 35 },
|
||||
},
|
||||
items = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- The three hooks GiveShuckle reads out of World:specialHooks: the party it
|
||||
-- appends to, the save it flags the dex on, and the data Mon.new builds from.
|
||||
local function giftVm(record)
|
||||
record.party = record.party or {}
|
||||
return Vm.new({}, {}, Events.new(), { specials = {
|
||||
party = function() return record.party end,
|
||||
save = function() return record end,
|
||||
data = fixture,
|
||||
} })
|
||||
end
|
||||
|
||||
-- ---- the gift itself ------------------------------------------------------
|
||||
local giftedDex
|
||||
do
|
||||
local record = { party = {} }
|
||||
Specials.HANDLERS.GiveShuckle(giftVm(record))
|
||||
|
||||
eq(#record.party, 1, "Mania's SHUCKIE joins the party")
|
||||
local mon = record.party[1]
|
||||
eq(mon.nickname, "SHUCKIE", "under its own nickname")
|
||||
|
||||
local dex = record.pokedex or {}
|
||||
eq((dex.seen or {})[mon.species], true, "and it is SEEN in the #DEX")
|
||||
eq((dex.caught or {})[mon.species], true,
|
||||
"and CAUGHT: SetSeenAndCaughtMon sets both flags, not just the one")
|
||||
eq((dex.caught or {})[SHUCKLE_INDEX], nil,
|
||||
"keyed by the species name PokedexMenu:rebuild looks up, not by dex number")
|
||||
local summary = Save.summary(record)
|
||||
eq(summary and summary.caught, 1,
|
||||
"so the CONTINUE panel's own count agrees (Save.lua:910-913)")
|
||||
giftedDex = record.pokedex
|
||||
end
|
||||
|
||||
-- The handler must leave wScriptVar TRUE, because ManiasHouse.asm branches on
|
||||
-- it to pick the "took it" text over the "your party is full" one.
|
||||
do
|
||||
local record = { party = {} }
|
||||
local vm = giftVm(record)
|
||||
Specials.HANDLERS.GiveShuckle(vm)
|
||||
eq(vm.scriptVar, 1, "GiveShuckle answers TRUE when it hands one over")
|
||||
end
|
||||
|
||||
-- ---- a dex that already has entries in it ---------------------------------
|
||||
do
|
||||
local record = { party = {},
|
||||
pokedex = { seen = { TOTODILE = true }, caught = { TOTODILE = true } } }
|
||||
Specials.HANDLERS.GiveShuckle(giftVm(record))
|
||||
eq(record.pokedex.caught.TOTODILE, true, "the starter's entry survives")
|
||||
eq(record.pokedex.caught.SHUCKLE, true, "and SHUCKIE joins it")
|
||||
eq(Save.summary(record).caught, 2, "two owned species, counted once each")
|
||||
end
|
||||
|
||||
-- The shape a loaded save actually has, rather than a hand-built one:
|
||||
-- Save.normalize seeds pokedex.seen/caught, so the handler is writing into
|
||||
-- tables that already exist.
|
||||
do
|
||||
local record = Save.normalize({ party = {} }) or { party = {} }
|
||||
record.party = record.party or {}
|
||||
Specials.HANDLERS.GiveShuckle(giftVm(record))
|
||||
eq(record.pokedex.caught.SHUCKLE, true, "a normalized save takes the flag too")
|
||||
end
|
||||
|
||||
-- ---- the arms that must NOT flag the dex ----------------------------------
|
||||
-- .full returns before _AddPartyMon runs, so nothing reaches .registerpokedex
|
||||
-- and the player who never received the mon has no entry for it.
|
||||
do
|
||||
local full = {}
|
||||
for i = 1, Breeding.PARTY_SIZE do full[i] = { species = "TOTODILE", level = i } end
|
||||
local record = { party = full }
|
||||
local vm = giftVm(record)
|
||||
Specials.HANDLERS.GiveShuckle(vm)
|
||||
eq(vm.scriptVar, 0, "a full party refuses the gift")
|
||||
eq(#record.party, Breeding.PARTY_SIZE, "and nothing is appended")
|
||||
eq(record.pokedex, nil, "a refused gift leaves no #DEX entry behind")
|
||||
end
|
||||
|
||||
-- Mon.new failing (no such species in the cache) is the other early out.
|
||||
do
|
||||
local record = { party = {} }
|
||||
local vm = Vm.new({}, {}, Events.new(), { specials = {
|
||||
party = function() return record.party end,
|
||||
save = function() return record end,
|
||||
data = function() return { pokemon = {}, moves = {} } end,
|
||||
} })
|
||||
Specials.HANDLERS.GiveShuckle(vm)
|
||||
eq(vm.scriptVar, 0, "a mon that cannot be built is refused")
|
||||
eq(record.pokedex, nil, "and still flags nothing")
|
||||
end
|
||||
|
||||
-- ---- parity with the engine's other SetSeenAndCaughtMon ports -------------
|
||||
-- Breeding.markPokedex is the shared port of the same routine the hatch, the
|
||||
-- NPC trade and the givepoke opcode all run. A gift that flags the dex its
|
||||
-- own way instead of matching that state is drift, and drift is what leaves
|
||||
-- one screen showing the entry and another not.
|
||||
do
|
||||
local sibling = { party = {} }
|
||||
check(Breeding.markPokedex(sibling, "SHUCKLE"),
|
||||
"the shared SetSeenAndCaughtMon port still takes a species")
|
||||
same(giftedDex, sibling.pokedex,
|
||||
"GiveShuckle leaves the identical #DEX state the shared port does")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -71,9 +71,14 @@ check("$30 jump_step DOWN is a jump", Movement.decodeByte(0x30).kind, "jump")
|
||||
check("and it keeps its facing", Movement.decodeByte(0x33).dir, "right")
|
||||
check("$2c slow_jump_step is one too", Movement.decodeByte(0x2c).kind, "jump")
|
||||
check("$34 fast_jump_step as well", Movement.decodeByte(0x34).kind, "jump")
|
||||
-- The neighbouring families are still plain steps / turns.
|
||||
check("$28 turn_waterfall is untouched",
|
||||
Movement.decodeByte(0x28).kind, "turn")
|
||||
-- $20 turn_away / $24 turn_in / $28 turn_waterfall all `jp TurningStep`
|
||||
-- (engine/overworld/movement.asm:483-513), which is InitStep with
|
||||
-- OBJECT_ACTION_SPIN (:693-715): one cell crossed, spinning, not a facing
|
||||
-- change. Script_ForcedMovement's bounce out of a whirlpool is that step.
|
||||
check("$28 turn_waterfall is a spinning step",
|
||||
Movement.decodeByte(0x28).kind, "step")
|
||||
check("$24 turn_in is one too", Movement.decodeByte(0x24).kind, "step")
|
||||
check("and it carries the spin", Movement.decodeByte(0x24).spin, true)
|
||||
check("$10 big_step is untouched", Movement.decodeByte(0x10).kind, "step")
|
||||
|
||||
-- ------------------------------------------ sliding, fixed facing, tree shake
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
-- A ledge hop advances every logic frame, the way every other step does.
|
||||
--
|
||||
-- luajit tests/gen2_ledge_hop_test.lua
|
||||
--
|
||||
-- A hop is the engine's only two-cell move (World:tryLedgeJump, STEP_LEDGE).
|
||||
-- On the cart it is jump_step, i.e. STEP_WALK (pokegold/engine/overworld/
|
||||
-- movement.asm:595-597), so GetStepVector hands StepFunction_PlayerJump the
|
||||
-- `db 0, 2, 8, 2` row (map_objects.asm:365-381) and .stepjump / .stepland run
|
||||
-- it as two 8-frame beats: 16 frames, 2px EVERY frame, and UpdateJumpPosition
|
||||
-- (:1796-1817) adds the speed to OBJECT_JUMP_HEIGHT and indexes its 16-entry
|
||||
-- arc at height/2, one entry per frame.
|
||||
--
|
||||
-- We render Gen 2 at twice that temporal resolution (Player.STEP_FRAMES = 16
|
||||
-- for one cell), so the hop is 32 frames and has to move 1px and half an arc
|
||||
-- entry on every one of them. It used to compute the sub-cell offset per CELL
|
||||
-- and then scale it by the cell delta, so half the hop's frames moved nothing
|
||||
-- and the other half jumped 2px -- and since the camera follows px/py, the
|
||||
-- whole map scrolled at 30Hz. The arc had the matching quantum with the
|
||||
-- opposite sign, which dragged the sprite back UP the screen five times. #1713
|
||||
--
|
||||
-- Self-contained: no cache, no love. tests/drivers/gold_ledge_hop_bug1713_
|
||||
-- test.lua is the half a person watches.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 ledge hop")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
-- ---- one move, frame by frame ----------------------------------------------
|
||||
--
|
||||
-- What the renderer reads each frame: the position the camera follows, and the
|
||||
-- OBJECT_SPRITE_Y_OFFSET added on top of it. Row 1 is the standing frame
|
||||
-- before the move starts, so row i+1 is frame i.
|
||||
local function playMove(dir, opts)
|
||||
opts = opts or {}
|
||||
local p = Player.new(5, 5, dir)
|
||||
local d = DELTA[dir]
|
||||
local cells = opts.jump and 2 or 1
|
||||
p.targetX, p.targetY = p.cellX + d[1] * cells, p.cellY + d[2] * cells
|
||||
p.moving = true
|
||||
p.progress = 0
|
||||
p.jumping = opts.jump or nil
|
||||
p.stepFrames = opts.stepFrames
|
||||
or (opts.jump and Player.STEP_FRAMES * 2 or Player.STEP_FRAMES)
|
||||
local rows = { { px = p.px, py = p.py, off = 0 } }
|
||||
local frames = 0
|
||||
while p.moving and frames < 200 do
|
||||
p:update()
|
||||
frames = frames + 1
|
||||
rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 }
|
||||
end
|
||||
return p, rows, frames
|
||||
end
|
||||
|
||||
-- Per-frame change in one recorded column.
|
||||
local function steps(rows, key)
|
||||
local out = {}
|
||||
for i = 2, #rows do out[i - 1] = rows[i][key] - rows[i - 1][key] end
|
||||
return out
|
||||
end
|
||||
|
||||
local function countIf(list, fn)
|
||||
local n = 0
|
||||
for _, v in ipairs(list) do if fn(v) then n = n + 1 end end
|
||||
return n
|
||||
end
|
||||
|
||||
-- ---- the control: an ordinary one-cell walk --------------------------------
|
||||
--
|
||||
-- The hop's fix must not have been bought by moving this.
|
||||
do
|
||||
local p, rows, frames = playMove("down")
|
||||
eq(frames, Player.STEP_FRAMES, "a walk is 16 logic frames")
|
||||
local dy = steps(rows, "py")
|
||||
eq(countIf(dy, function(v) return v ~= 1 end), 0,
|
||||
"and every one of them moves the player exactly one pixel down")
|
||||
eq(countIf(steps(rows, "px"), function(v) return v ~= 0 end), 0,
|
||||
"with no sideways drift")
|
||||
eq(p.cellY, 6, "it lands one cell on")
|
||||
eq(p.py, 6 * 16, "on the pixel the cell says")
|
||||
|
||||
-- .DoStep's STEP_BIKE arm: the same one cell in half the frames, so the
|
||||
-- per-frame advance doubles. This is what a span applied to the wrong
|
||||
-- denominator breaks first.
|
||||
local bike, bikeRows, bikeFrames =
|
||||
playMove("right", { stepFrames = Player.STEP_FRAMES / 2 })
|
||||
eq(bikeFrames, 8, "a bike step is 8 logic frames")
|
||||
eq(countIf(steps(bikeRows, "px"), function(v) return v ~= 2 end), 0,
|
||||
"each moving two pixels, so a bike still covers one cell")
|
||||
eq(bike.cellX, 6, "and lands one cell on, not two")
|
||||
end
|
||||
|
||||
-- ---- the hop ---------------------------------------------------------------
|
||||
--
|
||||
-- The bug, stated as numbers: half these frames used to move nothing and the
|
||||
-- other half used to move two pixels.
|
||||
local hopP, hopRows, hopFrames = playMove("down", { jump = true })
|
||||
eq(hopFrames, Player.STEP_FRAMES * 2, "a ledge hop is 32 logic frames")
|
||||
local hopDy = steps(hopRows, "py")
|
||||
eq(countIf(hopDy, function(v) return v ~= 1 end), 0,
|
||||
"every frame of a ledge hop moves the player a pixel")
|
||||
eq(countIf(hopDy, function(v) return math.abs(v) >= 2 end), 0,
|
||||
"and no frame of it moves two")
|
||||
eq(countIf(steps(hopRows, "px"), function(v) return v ~= 0 end), 0,
|
||||
"a hop straight down never moves sideways")
|
||||
-- The near miss: span math applied without the matching frames scaling covers
|
||||
-- one cell in 32 frames instead of two.
|
||||
eq(hopRows[#hopRows].py - hopRows[1].py, 32,
|
||||
"the 32 frames add up to 32 pixels, which is two cells")
|
||||
eq(hopP.cellY, 7, "and the grid agrees the player crossed two cells")
|
||||
eq(hopP.py, 7 * 16, "landing on the landing cell's own pixel")
|
||||
eq(countIf(hopRows, function(r)
|
||||
return r.px % 1 ~= 0 or r.py % 1 ~= 0
|
||||
end), 0, "and no frame leaves the player on a half pixel")
|
||||
|
||||
-- ---- the arc ---------------------------------------------------------------
|
||||
--
|
||||
-- UpdateJumpPosition's .y_offsets: -4 up to -12 and back to 0, one entry per
|
||||
-- cart frame, so across our doubled step it is half an entry per frame.
|
||||
do
|
||||
local off = {}
|
||||
for i = 2, #hopRows do off[#off + 1] = hopRows[i].off end
|
||||
local peak = 0
|
||||
for i = 1, #off do if off[i] < peak then peak = off[i] end end
|
||||
-- The arc rises, then falls, and changes its mind exactly once: any extra
|
||||
-- sign change in the offsets is the sprite wobbling.
|
||||
local moves = {}
|
||||
for i = 2, #off do
|
||||
local v = off[i] - off[i - 1]
|
||||
if v ~= 0 then moves[#moves + 1] = v end
|
||||
end
|
||||
local turns = 0
|
||||
for i = 2, #moves do
|
||||
if (moves[i] > 0) ~= (moves[i - 1] > 0) then turns = turns + 1 end
|
||||
end
|
||||
eq(off[1], -4, "the arc opens on the table's first entry")
|
||||
eq(peak, -12, "peaks 12 pixels up, the table's own peak")
|
||||
eq(off[#off], 0, "and is back on the ground for the landing frame")
|
||||
eq(turns, 1, "rising then falling, once, with no wobble in between")
|
||||
eq(countIf(off, function(v) return v > 0 or v < -12 end), 0,
|
||||
"and never leaves the range the table covers")
|
||||
|
||||
-- The composed screen position, which is what an eye actually tracks: the
|
||||
-- camera-following py plus the sprite offset. The first frame lifts the
|
||||
-- sprite -- that is the take-off -- and after it a hop DOWN may never go
|
||||
-- backwards. Before the fix it did so on five frames.
|
||||
local screen, back = {}, 0
|
||||
for i = 2, #hopRows do
|
||||
screen[#screen + 1] = hopRows[i].py + hopRows[i].off
|
||||
end
|
||||
for i = 2, #screen do
|
||||
if screen[i] < screen[i - 1] then back = back + 1 end
|
||||
end
|
||||
eq(back, 0, "the arc never drags the sprite back up the screen")
|
||||
check(screen[1] < hopRows[1].py + hopRows[1].off,
|
||||
"the one time it does rise is the take-off, on the first frame")
|
||||
eq(countIf(steps(hopRows, "py"), function(v) return v ~= 1 end), 0,
|
||||
"while the map underneath scrolls one pixel a frame throughout")
|
||||
end
|
||||
|
||||
-- A sideways hop is the same move on the other axis: Gold's $a0 is HOP_RIGHT.
|
||||
for _, dir in ipairs({ "left", "right" }) do
|
||||
local p, rows, frames = playMove(dir, { jump = true })
|
||||
local want = DELTA[dir][1]
|
||||
eq(frames, 32, ("a %s hop is 32 logic frames too"):format(dir))
|
||||
eq(countIf(steps(rows, "px"), function(v) return v ~= want end), 0,
|
||||
("and moves one pixel %s on every one of them"):format(dir))
|
||||
eq(countIf(steps(rows, "py"), function(v) return v ~= 0 end), 0,
|
||||
("a %s hop never slides the player up or down a row"):format(dir))
|
||||
eq(p.cellX, 5 + want * 2, ("it lands two cells %s"):format(dir))
|
||||
end
|
||||
|
||||
-- The step ends clean: nothing about the jump is left set for the next walk.
|
||||
eq(hopP.moving, false, "the hop ends the step")
|
||||
eq(hopP.jumping, nil, "and clears the jump")
|
||||
eq(hopP.spriteYOffset, 0, "and puts the sprite back on its feet")
|
||||
|
||||
-- ---- the same hop, through World -------------------------------------------
|
||||
--
|
||||
-- Player alone cannot prove the wiring: World:tryLedgeJump is what picks the
|
||||
-- two-cell target and the doubled duration (World.lua's STEP_LEDGE arm), and a
|
||||
-- fix that only touched one of the two would still stutter in the game.
|
||||
local COLL_FLOOR, COLL_WALL, COLL_HOP_DOWN = 0x00, 0x07, 0xa3
|
||||
|
||||
local function ledgeWorld(px, py, dir, coll)
|
||||
local game = {
|
||||
data = { items = {}, moves = {}, pokemon = {} },
|
||||
save = { player = { name = "GOLD", badges = {} }, party = {},
|
||||
inventory = {} },
|
||||
}
|
||||
local world = World.new(game)
|
||||
game.world = world
|
||||
local cells = {}
|
||||
cells[py * 100 + px] = coll or COLL_HOP_DOWN
|
||||
local d = DELTA[dir]
|
||||
-- The refused single step, which is what turns the walk into a jump.
|
||||
cells[(py + d[2]) * 100 + (px + d[1])] = COLL_WALL
|
||||
local map
|
||||
map = {
|
||||
id = "ROUTE_29",
|
||||
width = 10, height = 10,
|
||||
def = { objects = {}, bgEvents = {}, environment = "ROUTE",
|
||||
tileset = "TILESET_JOHTO", width = 10, height = 10 },
|
||||
cellCollision = function(_, x, y)
|
||||
return cells[y * 100 + x] or COLL_FLOOR
|
||||
end,
|
||||
inBounds = function(_, x, y)
|
||||
return x >= 0 and y >= 0 and x < 20 and y < 20
|
||||
end,
|
||||
isWalkable = function(_, x, y)
|
||||
return map:inBounds(x, y)
|
||||
and Permissions.isWalkable(map:cellCollision(x, y))
|
||||
end,
|
||||
warpAt = function() return nil end,
|
||||
connection = function() return nil end,
|
||||
}
|
||||
world.map = map
|
||||
world.maps = { ROUTE_29 = map.def }
|
||||
world.player = Player.new(px, py, dir)
|
||||
world.player.turnArmed = false
|
||||
world.entities = { world.player }
|
||||
world.npcs = {}
|
||||
world.encounters = {}
|
||||
world.noWildEncounters = true
|
||||
world.pollTimeOfDay = function() end
|
||||
world.updatePeople = function() end
|
||||
return world
|
||||
end
|
||||
|
||||
check(Permissions.isLedge(COLL_HOP_DOWN), "$a3 is a ledge")
|
||||
check((Permissions.ledgeFacings(COLL_HOP_DOWN) or {}).down,
|
||||
"and its row hops DOWN")
|
||||
|
||||
do
|
||||
local world = ledgeWorld(4, 5, "down")
|
||||
local p = world.player
|
||||
local rows, airborne = {}, false
|
||||
for _ = 1, 120 do
|
||||
world.heldDir = (not airborne) and "down" or nil
|
||||
local wasAirborne = airborne
|
||||
world:step()
|
||||
if p.jumping then airborne = true end
|
||||
if airborne then
|
||||
rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 }
|
||||
end
|
||||
if wasAirborne and not p.moving then break end
|
||||
end
|
||||
check(#rows > 0, "holding down on a ledge starts a hop")
|
||||
eq(#rows - 1, 32, "which runs the doubled 32-frame duration")
|
||||
eq(p.cellY, 7, "and lands two cells down, on the far side of the wall")
|
||||
local dy = steps(rows, "py")
|
||||
eq(countIf(dy, function(v) return v ~= 1 end), 0,
|
||||
"every frame of the in-game hop scrolls the map exactly one pixel")
|
||||
eq(rows[#rows].py - rows[1].py, 32,
|
||||
"so the whole hop is 32 pixels of travel, not 16")
|
||||
local screen, back = {}, 0
|
||||
for i = 2, #rows do screen[#screen + 1] = rows[i].py + rows[i].off end
|
||||
for i = 2, #screen do
|
||||
if screen[i] < screen[i - 1] then back = back + 1 end
|
||||
end
|
||||
eq(back, 0, "and after the take-off the sprite never reverses")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -356,10 +356,11 @@ local optionsGame, optionsInput = newGame(Save.newGame())
|
||||
local options = OptionsMenu.new(optionsGame, {
|
||||
options = Save.defaultOptions(),
|
||||
})
|
||||
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
|
||||
-- video mode, screen position, the mobile-gated touch three (buildRows),
|
||||
-- MAX FPS and CANCEL.
|
||||
check("twenty-four rows", #OptionsMenu.ROWS, 24)
|
||||
-- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE,
|
||||
-- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside
|
||||
-- the dual-shader feature), video mode, screen position, the mobile-gated
|
||||
-- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL.
|
||||
check("twenty-seven rows", #OptionsMenu.ROWS, 27)
|
||||
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
|
||||
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
|
||||
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
|
||||
@@ -955,20 +956,24 @@ check("GBC leaves a palette alone",
|
||||
1)[1], 1)
|
||||
check("and has no present pass", GbcPalette.presentColors(), nil)
|
||||
|
||||
local zoomIndex, gbcfxIndex
|
||||
local zoomIndex, tiltIndex
|
||||
for i, row in ipairs(OptionsMenu.ROWS) do
|
||||
if row.label == "ZOOM" then zoomIndex = i end
|
||||
if row.label == "GBC FX" then gbcfxIndex = i end
|
||||
if row.label == "TILT" then tiltIndex = i end
|
||||
end
|
||||
check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label,
|
||||
"VOID FILL")
|
||||
check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
|
||||
"TILT")
|
||||
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
|
||||
check("SHADER FX follows COLOR follows TILT", OptionsMenu.ROWS[tiltIndex + 2].label,
|
||||
"SHADER FX")
|
||||
check("SHADER FX 2 follows SHADER FX", OptionsMenu.ROWS[tiltIndex + 3].label,
|
||||
"SHADER FX 2")
|
||||
check("VIDEO MODE follows SHADER FX 2", OptionsMenu.ROWS[tiltIndex + 4].label,
|
||||
"VIDEO MODE")
|
||||
check("and SCREEN POS follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
|
||||
check("and SCREEN POS follows it", OptionsMenu.ROWS[tiltIndex + 5].label,
|
||||
"SCREEN POS")
|
||||
check("and TOUCH PAD follows that", OptionsMenu.ROWS[gbcfxIndex + 3].label,
|
||||
check("and TOUCH PAD follows that", OptionsMenu.ROWS[tiltIndex + 6].label,
|
||||
"TOUCH PAD")
|
||||
|
||||
local videoRow = select(2, rowNamed("VIDEO MODE"))
|
||||
|
||||
@@ -180,7 +180,7 @@ do
|
||||
random = rolls({ 39, 39, 5, 2 }) })
|
||||
eq(battle:checkObedience("ROCK_THROW"), true, "napped instead")
|
||||
eq(mon.status, "sleep", "BeganToNap writes sleep straight in")
|
||||
eq(mon.statusTurns, 3, "1-7 turns, from the same roll shape as sleep")
|
||||
eq(mon.statusTurns, 3, "1-7 turns, written straight into the status byte")
|
||||
check(findText(battle.events, "ROCKY began to nap!"), "with its own line")
|
||||
|
||||
-- Self-hit: the margin roll in the second band.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
-- SPRITEMOVEDATA_POKEMON ($16), the row every overworld mon object stands on.
|
||||
--
|
||||
-- luajit tests/gen2_ow_bounce_test.lua (ROM-free; the cache section SKIPs
|
||||
-- without a gold cache)
|
||||
--
|
||||
-- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE and
|
||||
-- OBJECT_ACTION_BOUNCE. SetFacingBounce increments OBJECT_STEP_FRAME once a
|
||||
-- frame, masks it to four bits and reads bit 3: set means FACING_STEP_UP_0,
|
||||
-- clear falls into SetFacingFreezeBounce and FACING_STEP_DOWN_0
|
||||
-- (engine/overworld/map_object_action.asm:184-202). Those are tiles $00..$03
|
||||
-- and $04..$07 of the mon's menu icon (data/sprites/facings.asm:43-72), i.e.
|
||||
-- the two 16x16 halves of the 16x32 sheet extractIcons already writes.
|
||||
--
|
||||
-- Both halves of #1748 are pinned here: the animation itself, and the sheet
|
||||
-- being two frames deep so the animation has a second pose to reach.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 ow bounce")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local NPC = require("src.world.gen2.Npc")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
-- constants/map_object_constants.asm:135-151
|
||||
local SPRITEMOVEDATA_STILL = 0x01
|
||||
local SPRITEMOVEDATA_STANDING_DOWN = 0x06
|
||||
local SPRITEMOVEDATA_POKEMON = 0x16
|
||||
|
||||
-- trueColor short-circuits SpriteRenderer past the OBP bake, so :draw here
|
||||
-- needs no canvases (the same trick tests/gen2_big_object_test.lua uses).
|
||||
local MON_SHEET = {
|
||||
id = "SPRITE_JIGGLYPUFF", frames = 2, trueColor = true,
|
||||
spriteType = "POKEMON_SPRITE", walker = false,
|
||||
image = "assets/generated/icons/gen2/jigglypuff.png",
|
||||
}
|
||||
local DOLL_SHEET = {
|
||||
id = "SPRITE_FAIRY", frames = 6, trueColor = true,
|
||||
spriteType = "WALKING_SPRITE", walker = true,
|
||||
image = "assets/generated/sprites/fairy.png",
|
||||
}
|
||||
|
||||
local function build(movement, sheet)
|
||||
return NPC.new("TEST_MAP",
|
||||
{ index = 1, movement = movement, x = 4, y = 4 }, sheet or MON_SHEET)
|
||||
end
|
||||
|
||||
-- ---- the movement byte -----------------------------------------------------
|
||||
eq(NPC.MOVE.POKEMON, SPRITEMOVEDATA_POKEMON,
|
||||
"SPRITEMOVEDATA_POKEMON is $16 in map_object_constants.asm")
|
||||
|
||||
-- Without the seam every block below it raises rather than reporting, which
|
||||
-- turns a whole suite of answers into one traceback.
|
||||
local HAS_BOUNCE = check(type(NPC.bounceFrame) == "function",
|
||||
"NPC:bounceFrame is the seam OBJECT_ACTION_BOUNCE draws through")
|
||||
if not HAS_BOUNCE then
|
||||
NPC.bounceFrame = function() return nil end
|
||||
end
|
||||
|
||||
do
|
||||
local mon = build(SPRITEMOVEDATA_POKEMON)
|
||||
check(mon.bouncing == true, "a $16 object is built bouncing")
|
||||
check(mon.fixedFacing == true,
|
||||
"and keeps FIXED_FACING, which the $16 row's flags1 byte carries")
|
||||
eq(mon.facing, "down", "its `db DOWN ; facing` column is DOWN")
|
||||
eq(select(1, NPC.patternFor(SPRITEMOVEDATA_POKEMON)), "stand",
|
||||
"MovementFunction_Bouncing parks it on STEP_TYPE_STANDING: no walking")
|
||||
end
|
||||
|
||||
-- ---- eight frames down, eight frames up ------------------------------------
|
||||
-- `inc a / and %00001111 / ld [hl], a / and %00001000` is a sixteen-frame
|
||||
-- cycle with an eight-frame dwell on each pose, and the counter is stepped
|
||||
-- BEFORE the bit is read -- so the spawn frame is pose 0 and so are the seven
|
||||
-- after it.
|
||||
do
|
||||
local mon = build(SPRITEMOVEDATA_POKEMON)
|
||||
eq(mon.bounceStep, 0, "OBJECT_STEP_FRAME starts at zero")
|
||||
local seen, wrong = { mon:bounceFrame() }, nil
|
||||
for i = 1, 31 do
|
||||
mon:update()
|
||||
seen[i + 1] = mon:bounceFrame()
|
||||
end
|
||||
for i = 0, 31 do
|
||||
local want = math.floor(i / 8) % 2
|
||||
if seen[i + 1] ~= want and not wrong then
|
||||
wrong = ("frame %d drew pose %s, wanted %d")
|
||||
:format(i, tostring(seen[i + 1]), want)
|
||||
end
|
||||
end
|
||||
check(wrong == nil, "two full cycles run 0,0..0,1,1..1 eight frames apiece"
|
||||
.. (wrong and (" -- " .. wrong) or ""))
|
||||
eq(seen[1], 0, "the spawn frame is FacingStepDown0, the icon's first half")
|
||||
eq(seen[9], 1, "the ninth is FacingStepUp0, its second half")
|
||||
eq(seen[17], 0, "and the seventeenth is back to the first")
|
||||
eq(mon.bounceStep, 31 % 16, "the counter wrapped at sixteen, not at eight")
|
||||
end
|
||||
|
||||
-- ---- the frozen column -----------------------------------------------------
|
||||
-- OBJECT_ACTION_BOUNCE's second entry is SetFacingFreezeBounce, which writes
|
||||
-- FACING_STEP_DOWN_0 and never touches OBJECT_STEP_FRAME. So a mon held for a
|
||||
-- conversation pins its FIRST pose and resumes on the phase it left.
|
||||
do
|
||||
local mon = build(SPRITEMOVEDATA_POKEMON)
|
||||
for _ = 1, 10 do mon:update() end
|
||||
eq(mon:bounceFrame(), 1, "ten frames in, the mon is on the up pose")
|
||||
eq(mon.bounceStep, 10, "with OBJECT_STEP_FRAME at 10")
|
||||
|
||||
mon.frozen = true
|
||||
local pinned = true
|
||||
for _ = 1, 40 do
|
||||
mon:update()
|
||||
if mon:bounceFrame() ~= 0 then pinned = false end
|
||||
end
|
||||
check(pinned, "frozen, it holds FacingStepDown0 for the whole conversation")
|
||||
eq(mon.bounceStep, 10,
|
||||
"and SetFacingFreezeBounce leaves the step counter where it was")
|
||||
|
||||
mon.frozen = false
|
||||
mon:update()
|
||||
eq(mon.bounceStep, 11, "released, the counter carries on from 10")
|
||||
eq(mon:bounceFrame(), 1, "so the bounce resumes on the phase it froze at")
|
||||
end
|
||||
|
||||
-- ---- every other movement byte is still ------------------------------------
|
||||
-- Only the $16 row carries OBJECT_ACTION_BOUNCE; the Clefairy, Charizard and
|
||||
-- Pidgeot dolls are SPRITEMOVEDATA_STANDING_DOWN and take
|
||||
-- OBJECT_ACTION_STAND (data/sprites/map_objects.asm:53-59).
|
||||
do
|
||||
for _, movement in ipairs({ SPRITEMOVEDATA_STILL, SPRITEMOVEDATA_STANDING_DOWN,
|
||||
NPC.MOVE.WANDER, NPC.MOVE.SPINRANDOM_SLOW, NPC.MOVE.BIGDOLL }) do
|
||||
local npc = build(movement, DOLL_SHEET)
|
||||
check(not npc.bouncing,
|
||||
("movement $%02x does not bounce"):format(movement))
|
||||
check(npc:bounceFrame() == nil,
|
||||
("and $%02x asks for no frame override"):format(movement))
|
||||
end
|
||||
|
||||
-- The other half of it: a mon SHEET on a still row stays still. Gold has no
|
||||
-- such object, but a mod or a variablesprite slot can make one.
|
||||
local still = build(SPRITEMOVEDATA_STILL, MON_SHEET)
|
||||
for _ = 1, 40 do still:update() end
|
||||
check(still:bounceFrame() == nil,
|
||||
"a POKEMON_SPRITE sheet on a STILL row never bounces either")
|
||||
end
|
||||
|
||||
-- ---- the frame reaches the renderer ----------------------------------------
|
||||
-- NPC:draw is where the pose is spent: SpriteRenderer:draw's trailing
|
||||
-- frameOverride argument. A bounce the draw call drops looks exactly like no
|
||||
-- bounce at all.
|
||||
do
|
||||
local mon = build(SPRITEMOVEDATA_POKEMON)
|
||||
local doll = build(SPRITEMOVEDATA_STANDING_DOWN, DOLL_SHEET)
|
||||
local seen = {}
|
||||
local function recorder(who)
|
||||
return { draw = function(_, _, _, _, _, _, _, _, _, _, frameOverride)
|
||||
seen[who] = { override = frameOverride, count = (seen[who]
|
||||
and seen[who].count or 0) + 1 }
|
||||
end }
|
||||
end
|
||||
mon.sprite, doll.sprite = recorder("mon"), recorder("doll")
|
||||
|
||||
mon:draw(0, 0, 1)
|
||||
doll:draw(0, 0, 1)
|
||||
eq(seen.mon.override, 0, "the mon's first draw overrides to frame 0")
|
||||
check(seen.doll.override == nil, "and the doll's overrides to nothing")
|
||||
|
||||
for _ = 1, 8 do mon:update() end
|
||||
mon:draw(0, 0, 1)
|
||||
eq(seen.mon.override, 1, "eight frames later it overrides to frame 1")
|
||||
end
|
||||
|
||||
-- ---- a one-frame sheet has nowhere to bounce to ----------------------------
|
||||
-- SpriteRenderer builds one quad per frame and :draw only honours an override
|
||||
-- it has a quad for, so the extractor half of #1748 is load bearing: with
|
||||
-- `frames = 1` the up pose is simply not on the sheet.
|
||||
do
|
||||
local one = SpriteRenderer.new({
|
||||
id = "ONE", frames = 1, trueColor = true, image = MON_SHEET.image }, "one")
|
||||
eq(one.frameCount, 1, "a frames = 1 def builds one quad")
|
||||
check(one.frames[1] == nil, "so there is no second frame to override to")
|
||||
|
||||
local two = SpriteRenderer.new(MON_SHEET, "two")
|
||||
eq(two.frameCount, 2, "a frames = 2 def builds both")
|
||||
check(two.frames[1] ~= nil, "and frame 1 is a real quad")
|
||||
eq(two:getFrameGeometry(1).y, two.frameHeight,
|
||||
"which is the lower 16x16 of the 16x32 icon sheet")
|
||||
end
|
||||
|
||||
-- ---- World:load repairs a stale cache --------------------------------------
|
||||
-- The extractor fix only reaches a player who re-imports, and there is no
|
||||
-- non-file lever that would force one. World:load back-fills the SpriteMons
|
||||
-- rows on the way past, keyed on `source` so a mod's own POKEMON_SPRITE row is
|
||||
-- left exactly as the mod wrote it.
|
||||
do
|
||||
local sprites = {
|
||||
SPRITE_JIGGLYPUFF = { id = "SPRITE_JIGGLYPUFF", frames = 1,
|
||||
source = "ROM:SpriteMons[20]", spriteType = "POKEMON_SPRITE",
|
||||
image = "assets/generated/icons/gen2/jigglypuff.png" },
|
||||
SPRITE_CHRIS = { id = "SPRITE_CHRIS", frames = 12, walker = true,
|
||||
source = "ROM:OverworldSprites[0]",
|
||||
image = "assets/generated/sprites/chris.png" },
|
||||
SPRITE_MOD_MON = { id = "SPRITE_MOD_MON", frames = 1,
|
||||
source = "MOD:pocket-monsters", spriteType = "POKEMON_SPRITE",
|
||||
image = "mods/pocket-monsters/mon.png" },
|
||||
}
|
||||
local world = World.new({ data = {
|
||||
gen2Maps = { TEST_MAP = { width = 1, height = 1 } },
|
||||
gen2Tilesets = {},
|
||||
gen2Sprites = sprites,
|
||||
} })
|
||||
-- load goes on to tilesets, palettes and map art this stub has none of; the
|
||||
-- back-fill sits in its first dozen lines, so let the rest fall over.
|
||||
pcall(world.load, world)
|
||||
eq(sprites.SPRITE_JIGGLYPUFF.frames, 2,
|
||||
"a pre-#1748 SpriteMons row is back-filled to two frames on load")
|
||||
eq(sprites.SPRITE_CHRIS.frames, 12,
|
||||
"an OverworldSprites row is not touched")
|
||||
eq(sprites.SPRITE_MOD_MON.frames, 1,
|
||||
"and a mod's own POKEMON_SPRITE row keeps the frame count it declared")
|
||||
end
|
||||
|
||||
-- ---- the day-care pair -----------------------------------------------------
|
||||
-- GetMonSprite's .BreedMon1 / .BreedMon2 arms have no sprites.lua row to
|
||||
-- back-fill (engine/overworld/overworld.asm:279-305), so the def World builds
|
||||
-- by hand has to carry the second frame itself.
|
||||
do
|
||||
local world = World.new({ data = { gen2Icons = {
|
||||
species = { PIKACHU = "ICON_PIKACHU" },
|
||||
icons = { ICON_PIKACHU = {
|
||||
image = "assets/generated/icons/gen2/pikachu.png" } },
|
||||
} } })
|
||||
local def = world:breedmonSpriteDef("PIKACHU")
|
||||
check(def ~= nil, "a deposited PIKACHU builds a day-care sprite def")
|
||||
eq(def and def.frames, 2, "two frames, like every other SpriteMons row")
|
||||
eq(def and def.spriteType, "POKEMON_SPRITE", "and it is a mon sheet")
|
||||
end
|
||||
|
||||
-- ---- the cache -------------------------------------------------------------
|
||||
-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set
|
||||
-- still reads the cache instead of skipping silently.
|
||||
local cache = os.getenv("GOLD_CACHE")
|
||||
or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold")
|
||||
local function loadCache(name)
|
||||
local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua")
|
||||
return chunk and chunk() or nil
|
||||
end
|
||||
|
||||
local sprites = loadCache("sprites")
|
||||
if not sprites then
|
||||
check(true, "no GOLD_CACHE: the extracted rows are not checked (SKIP)")
|
||||
else
|
||||
local monRows, thin = 0, {}
|
||||
for id, def in pairs(sprites) do
|
||||
if type(def) == "table" and type(def.source) == "string"
|
||||
and def.source:find("^ROM:SpriteMons") then
|
||||
monRows = monRows + 1
|
||||
if (def.frames or 1) < 2 then thin[#thin + 1] = id end
|
||||
end
|
||||
end
|
||||
check(monRows > 0, "the cache carries SpriteMons rows at all")
|
||||
if #thin > 0 then
|
||||
check(true, ("cache predates #1748 (%d thin rows, first %s) : World:load"
|
||||
.. " back-fills them, a re-import writes them (SKIP)")
|
||||
:format(#thin, thin[1]))
|
||||
else
|
||||
check(true, ("all %d SpriteMons rows are two frames deep"):format(monRows))
|
||||
end
|
||||
|
||||
-- The objects the reporter was looking at: every $16 object on every map has
|
||||
-- to name a sheet that the bounce can actually flip.
|
||||
local maps = loadCache("maps")
|
||||
if not maps then
|
||||
check(true, "no maps.lua: the $16 objects are not checked (SKIP)")
|
||||
else
|
||||
local bouncers, slots, unresolved, oneFrame = 0, 0, {}, {}
|
||||
for mapId, def in pairs(maps) do
|
||||
if type(def) == "table" then
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
if obj.movement == SPRITEMOVEDATA_POKEMON then
|
||||
bouncers = bouncers + 1
|
||||
if type(obj.sprite) == "number" then
|
||||
-- The day-care pair: GetMonSprite's .BreedMon arms resolve those
|
||||
-- two bytes to the deposited species' icon at spawn time, so
|
||||
-- there is no sprites.lua row for them to name.
|
||||
slots = slots + 1
|
||||
elseif not sprites[obj.sprite] then
|
||||
unresolved[#unresolved + 1] = mapId
|
||||
elseif (sprites[obj.sprite].frames or 1) < 2 then
|
||||
oneFrame[#oneFrame + 1] = mapId
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check(bouncers > 0, ("the cache carries %d SPRITEMOVEDATA_POKEMON objects")
|
||||
:format(bouncers))
|
||||
check(#unresolved == 0, "every named one of them names a sheet the cache has"
|
||||
.. (unresolved[1] and (" (%s does not)"):format(unresolved[1]) or ""))
|
||||
check(slots == 2, ("and %d of them are the two day-care slots"):format(slots))
|
||||
if #oneFrame > 0 then
|
||||
check(true, ("%d of them sit on a pre-#1748 row : World:load back-fills"
|
||||
.. " them (SKIP)"):format(#oneFrame))
|
||||
else
|
||||
check(true, "and every one of those sheets is two frames deep")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,404 @@
|
||||
-- The PACK's list row and description box, as coordinates.
|
||||
--
|
||||
-- Most of what these three bugs broke is an integer the ASM names outright:
|
||||
-- TMHMPocket_GetCurrentLineCoord's column 5 and the `ld bc, 3` that puts the
|
||||
-- move name at column 8 (engine/items/tmhm.asm:355-403, #1695), the 1x9 cursor
|
||||
-- column at (7,2) whose palette carries the red (engine/gfx/cgb_layouts.asm:
|
||||
-- 723-726, #1694), and TEXTBOX_INNERY for anything printed in the description
|
||||
-- box (#1725). Chrome is swapped for a recorder here, so what is asserted is
|
||||
-- the write list rather than the pixels -- whether the cursor comes out RED on
|
||||
-- the real screen is tests/drivers/gold_pack_rows_bug1695_test.lua's job.
|
||||
|
||||
package.path = "./?.lua;" .. package.path
|
||||
|
||||
-- The UI modules require love-side helpers at load time. Stub the pieces they
|
||||
-- touch during construction and logic; nothing here draws.
|
||||
love = love or {}
|
||||
love.graphics = love.graphics or {
|
||||
getColor = function() return 1, 1, 1, 1 end,
|
||||
setColor = function() end,
|
||||
rectangle = function() end,
|
||||
print = function() end,
|
||||
printf = function() end,
|
||||
draw = function() end,
|
||||
newQuad = function() return {} end,
|
||||
newImage = function() return nil end,
|
||||
getShader = function() return nil end,
|
||||
setShader = function() end,
|
||||
newShader = function() error("no shaders in this harness") end,
|
||||
getDimensions = function() return 160, 144 end,
|
||||
push = function() end, pop = function() end,
|
||||
translate = function() end, scale = function() end,
|
||||
circle = function() end, clear = function() end,
|
||||
}
|
||||
love.math = love.math or {
|
||||
random = function(a, b)
|
||||
if b then return a end
|
||||
return a and 1 or 0.5
|
||||
end,
|
||||
}
|
||||
love.image = love.image or {}
|
||||
love.filesystem = love.filesystem or {
|
||||
load = function() return nil end,
|
||||
getInfo = function() return nil end,
|
||||
read = function() return nil end,
|
||||
write = function() return true end,
|
||||
remove = function() return true end,
|
||||
}
|
||||
love.timer = love.timer or { getTime = function() return 0 end }
|
||||
|
||||
-- No font is loaded here, so Font.encode would warn once per unknown glyph.
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local PackGfx = require("src.ui.gen2.PackGfx")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
local failures, checks = 0, 0
|
||||
local function check(name, got, want)
|
||||
checks = checks + 1
|
||||
if got ~= want then
|
||||
failures = failures + 1
|
||||
print(("FAIL %s: got %s, want %s"):format(
|
||||
name, tostring(got), tostring(want)))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
local ITEMS = {
|
||||
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 17,
|
||||
canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY",
|
||||
description = "Restores HP<NEXT>by 20." },
|
||||
ITEMFINDER = { id = "ITEMFINDER", name = "ITEMFINDER", pocket = "KEY_ITEM",
|
||||
index = 55, canToss = false, canSelect = true,
|
||||
fieldMenu = "ITEMMENU_CLOSE",
|
||||
description = "Checks for unseen<NEXT>items in the area." },
|
||||
TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01", pocket = "TM_HM",
|
||||
index = 191, tmNumber = 1, tmLabel = "TM01", teaches = "DYNAMICPUNCH" },
|
||||
TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM",
|
||||
index = 192, tmNumber = 2, tmLabel = "TM02", teaches = "HEADBUTT" },
|
||||
TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM",
|
||||
index = 240, tmNumber = 50, tmLabel = "TM50", teaches = "NIGHTMARE" },
|
||||
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 243,
|
||||
tmNumber = 51, tmLabel = "HM01", teaches = "CUT" },
|
||||
HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM",
|
||||
index = 249, tmNumber = 57, tmLabel = "HM07", teaches = "WATERFALL" },
|
||||
-- A cache old enough to carry neither tmLabel nor a numbered name: the row
|
||||
-- has no number to print and must fall back to the item's own name.
|
||||
TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM", index = 300,
|
||||
teaches = "SWIFT" },
|
||||
}
|
||||
|
||||
local MOVES = {}
|
||||
for _, name in ipairs({ "DYNAMICPUNCH", "HEADBUTT", "NIGHTMARE", "CUT",
|
||||
"WATERFALL", "SWIFT" }) do
|
||||
MOVES[name] = { id = name, name = name, description = "Move<NEXT>text." }
|
||||
end
|
||||
|
||||
local function newInput()
|
||||
local input = { pressed = {} }
|
||||
function input:press(...)
|
||||
for _, button in ipairs({ ... }) do self.pressed[button] = true end
|
||||
end
|
||||
function input:wasPressed(button)
|
||||
if self.pressed[button] then
|
||||
self.pressed[button] = nil
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
function input:isDown() return false end
|
||||
return input
|
||||
end
|
||||
|
||||
local function newGame(save)
|
||||
return {
|
||||
input = newInput(),
|
||||
save = save,
|
||||
options = save and save.options or Save.defaultOptions(),
|
||||
data = { audio = {}, pokemon = {}, items = ITEMS, moves = MOVES },
|
||||
stack = { _items = {},
|
||||
push = function(self, s) self._items[#self._items + 1] = s end,
|
||||
pop = function(self) return table.remove(self._items) end,
|
||||
top = function(self) return self._items[#self._items] end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- the recorder
|
||||
--
|
||||
-- PackMenu reaches Chrome through the module table on every call, so swapping
|
||||
-- the four entry points it draws through collects the screen as a write list.
|
||||
|
||||
local writes, cursors
|
||||
local real = {
|
||||
print = Chrome.print, cursor = Chrome.cursor,
|
||||
cursorThrough = Chrome.cursorThrough, box = Chrome.box, clear = Chrome.clear,
|
||||
}
|
||||
|
||||
local function install()
|
||||
writes, cursors = {}, {}
|
||||
Chrome.print = function(text, tx, ty)
|
||||
writes[#writes + 1] = { text = text, x = tx, y = ty }
|
||||
end
|
||||
Chrome.cursor = function(tx, ty, hollow)
|
||||
cursors[#cursors + 1] =
|
||||
{ x = tx, y = ty, hollow = hollow or false, palette = nil }
|
||||
end
|
||||
Chrome.cursorThrough = function(tx, ty, palette, _invert, hollow)
|
||||
cursors[#cursors + 1] =
|
||||
{ x = tx, y = ty, hollow = hollow or false, palette = palette }
|
||||
end
|
||||
Chrome.box = function() end
|
||||
Chrome.clear = function() end
|
||||
end
|
||||
|
||||
local function restore()
|
||||
Chrome.print, Chrome.cursor = real.print, real.cursor
|
||||
Chrome.cursorThrough, Chrome.box, Chrome.clear =
|
||||
real.cursorThrough, real.box, real.clear
|
||||
end
|
||||
|
||||
-- Where a string landed, as "x,y", so a miss reads as the coordinate it wanted.
|
||||
local function at(text)
|
||||
for _, write in ipairs(writes) do
|
||||
if write.text == text then return write.x .. "," .. write.y end
|
||||
end
|
||||
return "absent"
|
||||
end
|
||||
|
||||
local function drew(text)
|
||||
for _, write in ipairs(writes) do
|
||||
if write.text == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The cart's own PACK chrome, stubbed: PackGfx needs a real PNG to answer
|
||||
-- available(), and none of that matters to where the writes land.
|
||||
local RED = { { 255, 255, 255 }, { 123, 123, 255 }, { 0, 0, 255 }, { 255, 0, 0 } }
|
||||
local function withPackGfx(pack)
|
||||
pack.gfx = {
|
||||
available = function() return true end,
|
||||
draw = function() end,
|
||||
colorsAt = function(_self, tx, ty)
|
||||
if tx == 7 and ty >= 2 and ty <= 10 then return RED end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
return pack
|
||||
end
|
||||
|
||||
local function openPack(inventory, order, pocket)
|
||||
local save = Save.newGame()
|
||||
save.player.name = "GOLD"
|
||||
save.inventory = inventory
|
||||
save.bagOrder = order
|
||||
local game = newGame(save)
|
||||
local pack = PackMenu.new(game, { save = save, pocket = pocket })
|
||||
return withPackGfx(pack), save, game
|
||||
end
|
||||
|
||||
-- ------------------------------------------- the TM/HM row's number (#1695)
|
||||
--
|
||||
-- TMHM_DisplayPocketItems writes the number itself: PRINTNUM_LEADINGZEROS on a
|
||||
-- two-digit field for a TM, and the literal 'H' plus a PRINTNUM_LEFTALIGN
|
||||
-- ordinal for an HM. The item's own name is never printed in this pocket.
|
||||
|
||||
do
|
||||
local pack = openPack({
|
||||
TM_DYNAMICPUNCH = 1, TM_HEADBUTT = 3, TM_NIGHTMARE = 24,
|
||||
HM_CUT = 1, HM_WATERFALL = 1, TM_LEGACY = 1,
|
||||
}, {
|
||||
"HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_HEADBUTT",
|
||||
"TM_DYNAMICPUNCH", "TM_LEGACY",
|
||||
}, "TM_HM")
|
||||
|
||||
check("TM01 prints as 01", pack.rows[1].tmhmLabel, "01")
|
||||
check("TM02 prints as 02", pack.rows[2].tmhmLabel, "02")
|
||||
check("TM50 keeps both digits", pack.rows[3].tmhmLabel, "50")
|
||||
-- HM01 is TM/HM 51: PrintNum sees 51 - NUM_TMS = 1, left aligned.
|
||||
check("HM01 prints as H1", pack.rows[4].tmhmLabel, "H1")
|
||||
check("HM07 prints as H7", pack.rows[5].tmhmLabel, "H7")
|
||||
check("a numberless TM row has no number to print",
|
||||
pack.rows[6].tmhmLabel, nil)
|
||||
|
||||
install()
|
||||
pack.index = 1
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
|
||||
-- hlcoord 5 for the number, `ld bc, 3` on to column 8 for the move name, and
|
||||
-- the cursor gutter between them at LIST_X - 1.
|
||||
check("the number sits at column 5", at("01"), "5,2")
|
||||
check("the move name sits at column 8", at("DYNAMICPUNCH"), "8,2")
|
||||
check("the item's own name is never printed", drew("TM01"), false)
|
||||
check("nor is an invented HM prefix", drew("HM01"), false)
|
||||
check("the cursor is one tile left of the name", cursors[1].x, 7)
|
||||
check("and level with the row it marks", cursors[1].y, 2)
|
||||
-- engine/items/tmhm.asm:392-403 -- SCREEN_WIDTH + 9 from the number's coord.
|
||||
check("a TM's count is at column 17 on the row below", at("\xc3\x97 1"), "17,3")
|
||||
check("the fourth row is the first HM", at("H1"), "5,8")
|
||||
check("its move name shares that row", at("CUT"), "8,8")
|
||||
check("and an HM prints no count", drew("\xc3\x97 1"), true)
|
||||
|
||||
-- The fifth row is HM07, whose count would be the only other "× 1".
|
||||
local ones = 0
|
||||
for _, write in ipairs(writes) do
|
||||
if write.text == "\xc3\x97 1" then ones = ones + 1 end
|
||||
end
|
||||
check("exactly one count on screen, the TM's", ones, 1)
|
||||
end
|
||||
|
||||
-- A row with no number to print still has to draw something a person can read,
|
||||
-- so the item's own name stands in rather than the row going blank.
|
||||
do
|
||||
local pack = openPack({ TM_LEGACY = 1 }, { "TM_LEGACY" }, "TM_HM")
|
||||
install()
|
||||
pack.index = 1
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("a numberless row falls back to the item name", at("TM??"), "8,2")
|
||||
check("and prints nothing in the number column", drew("SWIFT"), false)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- an ordinary pocket's row
|
||||
|
||||
do
|
||||
local pack = openPack({ POTION = 5 }, { "POTION" }, "ITEM")
|
||||
install()
|
||||
pack.index = 1
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("an ITEM row starts at column 8", at("POTION"), "8,2")
|
||||
check("with no number beside it", drew("01"), false)
|
||||
check("and its count in the same column a TM's uses",
|
||||
at("\xc3\x97 5"), "17,3")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ the cursor's palette (#1694)
|
||||
--
|
||||
-- _CGB_PackPals fills (7,2) 1x9 with palette $3, whose colour 3 is red, and
|
||||
-- that rectangle is exactly the five list rows' cursor gutter.
|
||||
|
||||
do
|
||||
local pack = openPack({ POTION = 5, ESCAPE_ROPE = 1 }, { "POTION" }, "ITEM")
|
||||
install()
|
||||
pack.index = 1
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("the cursor asks the pack for its cell's palette",
|
||||
cursors[1] and cursors[1].palette, RED)
|
||||
local ink = cursors[1] and cursors[1].palette and cursors[1].palette[4]
|
||||
check("which is the one whose colour 3 is red",
|
||||
ink and table.concat(ink, ","), "255,0,0")
|
||||
|
||||
-- SELECT arms a row: ScrollingMenu_PlaceCursor's hollow arrow takes the same
|
||||
-- palette, so `hollow` has to survive the call.
|
||||
pack.rows[2] = { id = "ESCAPE_ROPE", count = 1, name = "ESCAPE ROPE",
|
||||
showCount = true }
|
||||
pack.switching = 1
|
||||
pack.index = 2
|
||||
install()
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
local armed
|
||||
for _, cursor in ipairs(cursors) do
|
||||
if cursor.hollow then armed = cursor end
|
||||
end
|
||||
check("the armed row's hollow arrow is drawn", armed ~= nil, true)
|
||||
check("in the cursor column", armed and armed.x, 7)
|
||||
check("and through the same red palette", armed and armed.palette, RED)
|
||||
|
||||
-- No pack tiles in the cache: the fallback must still draw a cursor rather
|
||||
-- than nothing at all.
|
||||
pack.gfx = { available = function() return false end, draw = function() end }
|
||||
pack.switching = nil
|
||||
pack.index = 1
|
||||
install()
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("a cache with no pack tiles falls back to the plain arrow",
|
||||
cursors[1] and cursors[1].palette, nil)
|
||||
end
|
||||
|
||||
-- PackGfx flattens paletteZones to a per-cell lookup; the cursor zone is the
|
||||
-- one PackMenu reads, so check the rectangle resolves over its whole height.
|
||||
do
|
||||
local pals = { { { 1, 1, 1 } }, { { 2, 2, 2 } }, { { 3, 3, 3 } }, RED }
|
||||
local gfx = PackGfx.new({ pack = {
|
||||
palettes = pals,
|
||||
paletteZones = { { 0, 0, 10, 1, 2 }, { 7, 2, 1, 9, 4 } },
|
||||
} })
|
||||
check("the cursor column starts at row 2", gfx:colorsAt(7, 2), RED)
|
||||
check("and runs nine rows down to row 10", gfx:colorsAt(7, 10), RED)
|
||||
check("row 11 is outside it", gfx:colorsAt(7, 11), pals[1])
|
||||
check("so is the name column", gfx:colorsAt(8, 2), pals[1])
|
||||
check("the header keeps its own zone", gfx:colorsAt(0, 0), pals[2])
|
||||
end
|
||||
|
||||
-- ------------------------------------------ the description box's rows (#1725)
|
||||
--
|
||||
-- Every text box in the game starts at TEXTBOX_INNERY and steps two rows a
|
||||
-- line. The item description already did; a message printed over the same box
|
||||
-- did not.
|
||||
|
||||
do
|
||||
local pack = openPack({ ITEMFINDER = 1 }, { "ITEMFINDER" }, "KEY_ITEM")
|
||||
|
||||
install()
|
||||
pack.index = 1
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("an item description starts on row 14",
|
||||
at("Checks for unseen"), "1,14")
|
||||
check("and its second line is row 16", at("items in the area."), "1,16")
|
||||
|
||||
-- RegisteredItemText, the case the report filed.
|
||||
pack.message = { "Registered the", "ITEMFINDER." }
|
||||
install()
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("a message's first line shares the description's row",
|
||||
at("Registered the"), "1,14")
|
||||
check("and its second line lands on row 16", at("ITEMFINDER."), "1,16")
|
||||
|
||||
-- _AskThrowAwayText, the other two-line message in this box.
|
||||
pack.message = { "Throw away how", "many?" }
|
||||
install()
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("the toss question starts on row 14", at("Throw away how"), "1,14")
|
||||
check("with a blank row under it", at("many?"), "1,16")
|
||||
|
||||
-- OakThisIsntTheTimeText is three lines, which two double-spaced rows cannot
|
||||
-- hold: those pack one row apart so the last is inside the box.
|
||||
pack.message = { "OAK: {PLAYER}!", "This isn't the", "time to use that!" }
|
||||
install()
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("a three-line message starts a row higher", at("OAK: GOLD!"), "1,13")
|
||||
check("its second line is row 14", at("This isn't the"), "1,14")
|
||||
check("and its third clears the bottom border",
|
||||
at("time to use that!"), "1,15")
|
||||
|
||||
-- The yes/no prompt is printed in the same box by the same path.
|
||||
pack.message = nil
|
||||
pack.confirm = { prompt = { "Throw away 1", "POTION(S)?" }, choice = 1 }
|
||||
install()
|
||||
pack:drawPanel()
|
||||
restore()
|
||||
check("the toss confirmation uses the same two rows",
|
||||
at("Throw away 1"), "1,14")
|
||||
check("and the same gap", at("POTION(S)?"), "1,16")
|
||||
end
|
||||
|
||||
print(("gen2 pack rows: %d checks, %d failures"):format(checks, failures))
|
||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
|
||||
-- exit here takes the whole tier down with it and silently skips every
|
||||
-- suite listed after this one (see tests/harness.lua's T.suite note).
|
||||
if failures > 0 then
|
||||
error(("%d assertion(s) failed"):format(failures), 0)
|
||||
end
|
||||
@@ -0,0 +1,213 @@
|
||||
-- The counter a sleep MOVE writes: BattleCommand_SleepTarget's .random_loop
|
||||
-- (engine/battle/effect_commands.asm:3591-3598), which masks with SLP_MASK,
|
||||
-- rerolls 0 and SLP_MASK and only then does `inc a` -- so 2-7, never 1 (#1707).
|
||||
-- Rest and the disobedience nap keep their own counters and are the controls.
|
||||
--
|
||||
-- luajit tests/gen2_sleep_counter_test.lua -- ROM-free
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 sleep counter")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
FLYING = { id = "FLYING", index = 2, category = "physical" },
|
||||
FIRE = { id = "FIRE", index = 20, category = "special" },
|
||||
GRASS = { id = "GRASS", index = 22, category = "special" },
|
||||
PSYCHIC = { id = "PSYCHIC", index = 24, category = "special" },
|
||||
}
|
||||
|
||||
local MATCHUPS = {}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
-- data/moves/moves.asm: SPORE and HYPNOSIS are both EFFECT_SLEEP.
|
||||
SPORE = { id = "SPORE", name = "SPORE", power = 0, type = "GRASS",
|
||||
accuracy = 100, pp = 15, effect = "EFFECT_SLEEP" },
|
||||
HYPNOSIS = { id = "HYPNOSIS", name = "HYPNOSIS", power = 0,
|
||||
type = "PSYCHIC", accuracy = 100, pp = 20, effect = "EFFECT_SLEEP" },
|
||||
REST = { id = "REST", name = "REST", power = 0, type = "PSYCHIC",
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_HEAL" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
|
||||
linear = 100, constant = 140 },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = GROWTH,
|
||||
-- Fast enough to move first against the PIDGEY below at these levels.
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL",
|
||||
baseStats = { hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
specialAttack = 60, specialDefense = 50 },
|
||||
types = { "FIRE", "FIRE" }, catchRate = 45, baseExp = 65,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
PIDGEY = {
|
||||
id = "PIDGEY", index = 16, name = "PIDGEY",
|
||||
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
}
|
||||
|
||||
local DATA = {
|
||||
pokemon = POKEMON,
|
||||
moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = MATCHUPS },
|
||||
items = {},
|
||||
}
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- BattleRandom(n) -> 0..n-1, pinned to one value.
|
||||
local function zeroRandom() return 0 end
|
||||
local function fixedRandom(value)
|
||||
return function(n) return math.min(value, (n or 1) - 1) end
|
||||
end
|
||||
|
||||
local function newBattle(opts)
|
||||
opts = opts or {}
|
||||
local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||
player.moves = {
|
||||
{ id = "SPORE", pp = 15, maxPp = 15 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 },
|
||||
}
|
||||
local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local battle = Battle.new({
|
||||
data = DATA,
|
||||
party = { player },
|
||||
wild = wild,
|
||||
random = opts.random or zeroRandom,
|
||||
})
|
||||
battle:takeEvents()
|
||||
return battle, player, wild
|
||||
end
|
||||
|
||||
local function texts(events)
|
||||
local out = {}
|
||||
for _, event in ipairs(events or {}) do
|
||||
if event.text then out[#out + 1] = event.text end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function found(events, needle)
|
||||
for _, text in ipairs(texts(events)) do
|
||||
if text:find(needle, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- the roll's range
|
||||
|
||||
-- The whole roll space against .random_loop's window: 2..7 inclusive, six
|
||||
-- outcomes, no 1 and no 8 (effect_commands.asm:3591-3598).
|
||||
do
|
||||
local seen, low, high = {}, math.huge, -math.huge
|
||||
for roll = 0, 7 do
|
||||
local battle, _, wild = newBattle({ random = fixedRandom(roll) })
|
||||
battle:useMove(battle.player, wild, "SPORE")
|
||||
eq(wild.status, "sleep", "roll " .. roll .. " slept the target")
|
||||
local turns = wild.statusTurns or 0
|
||||
seen[turns] = true
|
||||
low, high = math.min(low, turns), math.max(high, turns)
|
||||
end
|
||||
eq(low, 2, "the shortest sleep a sleep move can roll is two turns")
|
||||
eq(high, 7, "the longest is seven, SLP_MASK itself never being written")
|
||||
check(not seen[1], "one turn is unreachable, so no free-action sleep")
|
||||
check(not seen[0], "and zero is unreachable")
|
||||
local count = 0
|
||||
for _ in pairs(seen) do count = count + 1 end
|
||||
eq(count, 6, "six distinct counters, the width of the cart's window")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, _, wild = newBattle()
|
||||
battle:useMove(battle.player, wild, "SPORE")
|
||||
eq(wild.statusTurns, 2, "the lowest roll writes two, not one")
|
||||
end
|
||||
|
||||
-- ------------------------------------------- slept by a faster foe (#1707)
|
||||
|
||||
-- CheckPlayerTurn decrements before the sleeper acts, so the round the sleep
|
||||
-- landed in is the one a 1 would have cost nothing.
|
||||
do
|
||||
local battle, player, wild = newBattle()
|
||||
local hpBefore = player.hp
|
||||
local events = battle:takeTurn({ kind = "move", move = "SPORE" })
|
||||
eq(wild.status, "sleep", "the slower foe is asleep")
|
||||
check(found(events, "is fast asleep!"),
|
||||
"and spends the same round's action asleep")
|
||||
check(not found(events, "woke up!"),
|
||||
"it cannot wake in the round it was slept")
|
||||
eq(wild.statusTurns, 1, "one turn of the counter was spent")
|
||||
eq(player.hp, hpBefore, "so the sleeper landed no attack of its own")
|
||||
end
|
||||
|
||||
-- The counter still runs out on the next round.
|
||||
do
|
||||
local battle, _, wild = newBattle()
|
||||
battle:takeTurn({ kind = "move", move = "SPORE" })
|
||||
local events = battle:takeTurn({ kind = "move", move = "TACKLE" })
|
||||
check(found(events, "woke up!"), "the next round wakes it")
|
||||
eq(wild.status, nil, "and clears the status byte")
|
||||
eq(wild.statusTurns, nil, "and the counter with it")
|
||||
end
|
||||
|
||||
-- Hypnosis shares EFFECT_SLEEP, so it shares the window.
|
||||
do
|
||||
local battle, _, wild = newBattle()
|
||||
battle.player.moves[1] = { id = "HYPNOSIS", pp = 20, maxPp = 20 }
|
||||
battle:useMove(battle.player, wild, "HYPNOSIS")
|
||||
eq(wild.status, "sleep", "hypnosis slept the target")
|
||||
eq(wild.statusTurns, 2, "off the same .random_loop")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- the controls
|
||||
|
||||
-- Rest writes REST_SLEEP_TURNS + 1 straight into the status byte, no roll
|
||||
-- (effect_commands.asm:6015-6027).
|
||||
do
|
||||
local battle, player, wild = newBattle()
|
||||
player.moves = { { id = "REST", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
player.hp = 1
|
||||
battle:useMove(player, wild, "REST")
|
||||
eq(player.status, "sleep", "rest slept the user")
|
||||
eq(player.statusTurns, 3, "rest is still exactly three")
|
||||
eq(player.hp, player.maxHp, "and still a full heal")
|
||||
|
||||
local events = battle:takeTurn({ kind = "move", move = "TACKLE" })
|
||||
check(found(events, "is fast asleep!"), "the first turn after rest is lost")
|
||||
events = battle:takeTurn({ kind = "move", move = "TACKLE" })
|
||||
check(found(events, "is fast asleep!"), "so is the second")
|
||||
events = battle:takeTurn({ kind = "move", move = "TACKLE" })
|
||||
check(found(events, "woke up!"), "and it acts on the third")
|
||||
end
|
||||
|
||||
-- The disobedience nap rerolls only 0 and has no `inc a`, so 1 is legal
|
||||
-- there (effect_commands.asm:778-785) and the two rolls are not shared.
|
||||
do
|
||||
local record = Battle.STATUSES.sleep
|
||||
check(type(record.onInflict) == "function", "sleep still has an onInflict")
|
||||
local mon = {}
|
||||
record.onInflict({ random = zeroRandom }, mon)
|
||||
eq(mon.statusTurns, 2, "the status record's own floor is two")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -172,10 +172,13 @@ else
|
||||
"which ReadMonMenuIcon turns into PIKACHU's menu icon")
|
||||
eq(doll.image, "assets/generated/icons/gen2/pikachu.png",
|
||||
"so it draws from the icon sheet extractIcons already writes")
|
||||
-- FacingStepDown0 is the only OAM set a still mon object ever uses, and it
|
||||
-- is tiles $00..$03: the icon's first frame. The second is the party
|
||||
-- menu's bob and never reaches the map.
|
||||
eq(doll.frames, 1, "one frame: _DoesSpriteHaveFacings sends a mon down-only")
|
||||
-- OBJECT_ACTION_BOUNCE swaps FacingStepDown0's tiles $00..$03 for
|
||||
-- FacingStepUp0's $04..$07, so both icon frames reach the map (#1748).
|
||||
if doll.frames == 1 then
|
||||
check(true, "cache predates #1748 : re-import for the mon bounce (SKIP)")
|
||||
else
|
||||
eq(doll.frames, 2, "two frames: SetFacingBounce swaps the icon's pair")
|
||||
end
|
||||
check(not doll.walker, "and a doll does not walk")
|
||||
eq(doll.paletteId, 0, "_GetSpritePalette answers 0 for every mon sprite")
|
||||
eq(doll.palette, "PAL_OW_RED", "which is PAL_OW_RED in the MapObjectPals set")
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
-- Morning Sun / Synthesis / Moonlight: BattleCommand_TimeBasedHealContinue
|
||||
-- (engine/battle/effect_commands.asm:6374) walks a four-rung .Multipliers
|
||||
-- table from the half rung -- the wrong wTimeOfDay steps down, sun up, rain
|
||||
-- and sandstorm down (#1751). World:startBattle is where the hour comes from.
|
||||
--
|
||||
-- luajit tests/gen2_timed_heal_test.lua -- ROM-free
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 timed heal")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Effects = require("src.battle.gen2.Effects")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- constants/ram_constants.asm:137-140, wTimeOfDay: MORN_F 0, DAY_F 1, NITE_F 2,
|
||||
-- DARKNESS_F 3.
|
||||
local MORN, DAY, NITE, DARK = 0, 1, 2, 3
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
FLYING = { id = "FLYING", index = 2, category = "physical" },
|
||||
GRASS = { id = "GRASS", index = 22, category = "special" },
|
||||
WATER = { id = "WATER", index = 21, category = "special" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
-- data/moves/moves.asm: three 5 PP status moves that differ only in effect.
|
||||
SYNTHESIS = { id = "SYNTHESIS", name = "SYNTHESIS", power = 0,
|
||||
type = "GRASS", accuracy = 100, pp = 5, effect = "EFFECT_SYNTHESIS" },
|
||||
MORNING_SUN = { id = "MORNING_SUN", name = "MORNING SUN", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MORNING_SUN" },
|
||||
MOONLIGHT = { id = "MOONLIGHT", name = "MOONLIGHT", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MOONLIGHT" },
|
||||
SUNNY_DAY = { id = "SUNNY_DAY", name = "SUNNY DAY", power = 0,
|
||||
type = "FIRE", accuracy = 100, pp = 5, effect = "EFFECT_SUNNY_DAY" },
|
||||
RAIN_DANCE = { id = "RAIN_DANCE", name = "RAIN DANCE", power = 0,
|
||||
type = "WATER", accuracy = 100, pp = 5, effect = "EFFECT_RAIN_DANCE" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
|
||||
linear = 100, constant = 140 },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = GROWTH,
|
||||
CHIKORITA = {
|
||||
id = "CHIKORITA", index = 152, name = "CHIKORITA",
|
||||
baseStats = { hp = 45, attack = 49, defense = 65, speed = 45,
|
||||
specialAttack = 49, specialDefense = 65 },
|
||||
types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 64,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
PIDGEY = {
|
||||
id = "PIDGEY", index = 16, name = "PIDGEY",
|
||||
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
}
|
||||
|
||||
local DATA = {
|
||||
pokemon = POKEMON,
|
||||
moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} },
|
||||
items = {},
|
||||
}
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
local function zeroRandom() return 0 end
|
||||
|
||||
local function newBattle(opts)
|
||||
opts = opts or {}
|
||||
local player = Mon.new(DATA, "CHIKORITA", 20, { dvs = perfect })
|
||||
player.moves = {
|
||||
{ id = "SYNTHESIS", pp = 5, maxPp = 5 },
|
||||
{ id = "MORNING_SUN", pp = 5, maxPp = 5 },
|
||||
{ id = "MOONLIGHT", pp = 5, maxPp = 5 },
|
||||
{ id = "SUNNY_DAY", pp = 5, maxPp = 5 },
|
||||
}
|
||||
local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local battle = Battle.new({
|
||||
data = DATA,
|
||||
party = { player },
|
||||
wild = wild,
|
||||
random = zeroRandom,
|
||||
timeOfDay = opts.timeOfDay,
|
||||
})
|
||||
battle:takeEvents()
|
||||
return battle, player, wild
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- the ladder
|
||||
|
||||
local frac = Effects.timeBasedHealFraction
|
||||
check(type(frac) == "function",
|
||||
"Effects models the whole command, not just the weather half")
|
||||
|
||||
-- .Multipliers itself (effect_commands.asm:6450-6454).
|
||||
local RUNGS = { 1 / 8, 1 / 4, 1 / 2, 1 }
|
||||
eq(Effects.HEAL_MULTIPLIERS[1], RUNGS[1], "rung 0 is an eighth")
|
||||
eq(Effects.HEAL_MULTIPLIERS[2], RUNGS[2], "rung 1 is a quarter")
|
||||
eq(Effects.HEAL_MULTIPLIERS[3], RUNGS[3], "rung 2 is a half, the default")
|
||||
eq(Effects.HEAL_MULTIPLIERS[4], RUNGS[4], "rung 3 is the whole bar")
|
||||
|
||||
-- The `ld b, MORN_F / DAY_F / NITE_F` each of the three entry points loads
|
||||
-- (effect_commands.asm:6362-6371).
|
||||
eq(Effects.SUN_HEAL.EFFECT_MORNING_SUN, MORN, "Morning Sun wants MORN")
|
||||
eq(Effects.SUN_HEAL.EFFECT_SYNTHESIS, DAY, "Synthesis wants DAY")
|
||||
eq(Effects.SUN_HEAL.EFFECT_MOONLIGHT, NITE, "Moonlight wants NITE")
|
||||
|
||||
-- The six cells that matter, read as Synthesis: its own hour on the top row,
|
||||
-- any other hour on the bottom.
|
||||
eq(frac(nil, DAY, DAY), 1 / 2, "right hour, clear sky: a half")
|
||||
eq(frac("sun", DAY, DAY), 1, "right hour in sun: the whole bar")
|
||||
eq(frac("rain", DAY, DAY), 1 / 4, "right hour in rain: a quarter")
|
||||
eq(frac(nil, DAY, NITE), 1 / 4, "wrong hour, clear sky: a quarter")
|
||||
eq(frac("sun", DAY, NITE), 1 / 2, "wrong hour in sun: a half")
|
||||
eq(frac("rain", DAY, NITE), 1 / 8, "wrong hour in rain: an eighth")
|
||||
|
||||
-- Sandstorm takes the same `dec c / dec c` arm rain does.
|
||||
eq(frac("sandstorm", DAY, DAY), 1 / 4, "sandstorm is rain's rung")
|
||||
eq(frac("sandstorm", DAY, NITE), 1 / 8, "at either hour")
|
||||
|
||||
-- Every hour that is not the move's own is the wrong one, DARKNESS included.
|
||||
eq(frac(nil, DAY, MORN), 1 / 4, "morning is the wrong hour for Synthesis")
|
||||
eq(frac(nil, DAY, DARK), 1 / 4, "and so is DARKNESS")
|
||||
eq(frac(nil, MORN, MORN), 1 / 2, "Morning Sun at dawn is the top row")
|
||||
eq(frac(nil, MORN, DAY), 1 / 4, "Morning Sun in the afternoon is not")
|
||||
eq(frac(nil, NITE, NITE), 1 / 2, "Moonlight at night is the top row")
|
||||
eq(frac(nil, NITE, DAY), 1 / 4, "Moonlight at noon is not")
|
||||
|
||||
-- wTimeOfDay by name, the spelling World keeps.
|
||||
eq(frac(nil, DAY, "DAY"), 1 / 2, "the DAY spelling reads as DAY_F")
|
||||
eq(frac(nil, DAY, "NITE"), 1 / 4, "and NITE as NITE_F")
|
||||
eq(frac(nil, NITE, "NITE"), 1 / 2, "Moonlight likewise")
|
||||
|
||||
-- `ld a, [wLinkMode] / and a / jr nz, .Weather` (effect_commands.asm:6396-6399):
|
||||
-- a battle with no clock skips the time term rather than failing the hour.
|
||||
eq(frac(nil, DAY, nil), 1 / 2, "no clock, no time term")
|
||||
eq(frac("sun", DAY, nil), 1, "the weather half still walks")
|
||||
|
||||
-- Gen 3's 2/3 is not on this ladder anywhere.
|
||||
do
|
||||
local offLadder = {}
|
||||
for _, weather in ipairs({ "none", "sun", "rain", "sandstorm" }) do
|
||||
for _, wants in ipairs({ MORN, DAY, NITE }) do
|
||||
for hour = MORN, DARK do
|
||||
local got = frac(weather ~= "none" and weather or nil, wants, hour)
|
||||
local onLadder = false
|
||||
for _, rung in ipairs(RUNGS) do
|
||||
if got == rung then onLadder = true end
|
||||
end
|
||||
if not onLadder then
|
||||
offLadder[#offLadder + 1] = ("%s/%d/%d=%s")
|
||||
:format(weather, wants, hour, tostring(got))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
eq(#offLadder, 0,
|
||||
"every cell lands on a .Multipliers rung: " .. table.concat(offLadder, " "))
|
||||
end
|
||||
check(Effects.weatherHealFraction == nil
|
||||
or math.abs(Effects.weatherHealFraction("sun") - 2 / 3) > 0.0001,
|
||||
"and nothing still answers Gen 3's two thirds in sun")
|
||||
|
||||
-- ------------------------------------------------------- through a battle
|
||||
|
||||
-- wTimeOfDay reaches the handler (effect_commands.asm:6401-6404).
|
||||
do
|
||||
local battle = newBattle({ timeOfDay = NITE })
|
||||
eq(battle.timeOfDay, NITE, "Battle.new carries wTimeOfDay")
|
||||
end
|
||||
|
||||
local function healedBy(move, timeOfDay, weather)
|
||||
local battle, player = newBattle({ timeOfDay = timeOfDay })
|
||||
battle.weather = weather
|
||||
battle.weatherTurns = weather and 5 or nil
|
||||
player.hp = math.floor(player.maxHp / 2)
|
||||
local before = player.hp
|
||||
battle:useMove(player, battle.enemy, move)
|
||||
return player.hp - before, player.maxHp, battle:takeEvents()
|
||||
end
|
||||
|
||||
do
|
||||
local healed, maxHp = healedBy("SYNTHESIS", NITE)
|
||||
eq(healed, math.floor(maxHp / 4), "Synthesis at night restores a quarter")
|
||||
check(healed ~= math.floor(maxHp / 2),
|
||||
"which is not the flat half the unfixed move gave")
|
||||
end
|
||||
|
||||
do
|
||||
local healed, maxHp, events = healedBy("SYNTHESIS", DAY)
|
||||
eq(healed, math.floor(maxHp / 2), "Synthesis in the day restores a half")
|
||||
local said = false
|
||||
for _, event in ipairs(events) do
|
||||
if event.text and event.text:find("regained health!", 1, true) then
|
||||
said = true
|
||||
end
|
||||
end
|
||||
check(said, "and still says so")
|
||||
end
|
||||
|
||||
-- Sun on the top rung is GetMaxHP, not a bigger fraction, so the bar fills
|
||||
-- from wherever it started rather than from half.
|
||||
do
|
||||
local battle, player = newBattle({ timeOfDay = DAY })
|
||||
battle.weather, battle.weatherTurns = "sun", 5
|
||||
player.hp = 1
|
||||
battle:useMove(player, battle.enemy, "SYNTHESIS")
|
||||
eq(player.hp, player.maxHp,
|
||||
"Synthesis in the day under Sunny Day fills the bar from 1 HP")
|
||||
|
||||
battle, player = newBattle({ timeOfDay = DAY })
|
||||
battle.weather, battle.weatherTurns = "sun", 5
|
||||
player.hp = math.floor(player.maxHp / 2)
|
||||
battle:useMove(player, battle.enemy, "SYNTHESIS")
|
||||
eq(player.hp, player.maxHp, "and from half")
|
||||
end
|
||||
|
||||
do
|
||||
local healed, maxHp = healedBy("SYNTHESIS", NITE, "rain")
|
||||
eq(healed, math.floor(maxHp / 8), "Synthesis at night in rain is an eighth")
|
||||
end
|
||||
|
||||
do
|
||||
local healed, maxHp = healedBy("MOONLIGHT", NITE)
|
||||
eq(healed, math.floor(maxHp / 2), "Moonlight at night restores a half")
|
||||
healed = healedBy("MOONLIGHT", DAY)
|
||||
eq(healed, math.floor(maxHp / 4), "and a quarter at noon")
|
||||
healed = healedBy("MORNING_SUN", MORN)
|
||||
eq(healed, math.floor(maxHp / 2), "Morning Sun at dawn restores a half")
|
||||
healed = healedBy("MORNING_SUN", NITE)
|
||||
eq(healed, math.floor(maxHp / 4), "and a quarter at night")
|
||||
end
|
||||
|
||||
-- At NITE only Moonlight is on its top rung.
|
||||
do
|
||||
local night = {}
|
||||
for _, move in ipairs({ "MORNING_SUN", "SYNTHESIS", "MOONLIGHT" }) do
|
||||
night[move] = healedBy(move, NITE)
|
||||
end
|
||||
check(night.MOONLIGHT > night.SYNTHESIS,
|
||||
"Moonlight beats Synthesis at night")
|
||||
eq(night.MORNING_SUN, night.SYNTHESIS,
|
||||
"and the two daytime moves tie below it")
|
||||
end
|
||||
|
||||
-- effect_commands.asm:6443-6448: a full-HP user gets HPIsFullText and no heal,
|
||||
-- whatever the clock says.
|
||||
do
|
||||
local battle, player = newBattle({ timeOfDay = NITE })
|
||||
battle:useMove(player, battle.enemy, "SYNTHESIS")
|
||||
eq(player.hp, player.maxHp, "a full-HP user is not healed")
|
||||
local said = false
|
||||
for _, event in ipairs(battle:takeEvents()) do
|
||||
if event.text and event.text:find("HP is full", 1, true) then said = true end
|
||||
end
|
||||
check(said, "and hears HPIsFullText instead")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- the overworld's clock
|
||||
|
||||
-- World:timeOfDayId is wTimeOfDay off the RTC hour
|
||||
-- (engine/tilesets/timeofday_pals.asm:5-11), and startBattle hands it over.
|
||||
do
|
||||
local World = require("src.world.gen2.World")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local pushed
|
||||
local realPush = Screens.push
|
||||
Screens.push = function(_, id, opts)
|
||||
if id == "Gen2BattleState" then pushed = opts end
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
for _, hour in ipairs({ { "MORN", MORN }, { "DAY", DAY },
|
||||
{ "NITE", NITE }, { "DARK", DARK } }) do
|
||||
local mon = Mon.new(DATA, "CHIKORITA", 20, { dvs = perfect })
|
||||
local game = {
|
||||
data = DATA,
|
||||
save = { version = "gold", player = { name = "GOLD", id = 1234 },
|
||||
party = { mon }, inventory = {}, boxes = {} },
|
||||
}
|
||||
game.stack = { pop = function() end }
|
||||
local world = World.new(game)
|
||||
world.tod = hour[1]
|
||||
-- No map, so DoBattleTransition has nothing to wipe and the battle
|
||||
-- screen comes straight in (World:pushBattleTransition).
|
||||
world.map = nil
|
||||
pushed = nil
|
||||
world:startBattle({ wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) })
|
||||
eq(pushed and pushed.battle and pushed.battle.timeOfDay, hour[2],
|
||||
"a battle started at " .. hour[1] .. " carries that wTimeOfDay")
|
||||
end
|
||||
end)
|
||||
|
||||
Screens.push = realPush
|
||||
check(ok, "the World seam ran: " .. tostring(err))
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -1768,8 +1768,10 @@ end
|
||||
-- Shuckle you caught yourself is the thing the routine refuses.
|
||||
do
|
||||
local list = {}
|
||||
local record = { party = list }
|
||||
local vm = specialVm(0, { order = { "x" }, hooks = {
|
||||
party = function() return list end,
|
||||
save = function() return record end,
|
||||
data = function()
|
||||
return { pokemon = { SHUCKLE = { name = "SHUCKLE", index = 213,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
@@ -1785,6 +1787,9 @@ do
|
||||
eq(list[1].otId, Specials.MANIA_OT_ID, "with MANIA's trainer ID")
|
||||
eq(list[1].item, "BERRY", "holding a BERRY")
|
||||
eq(list[1].level, 15, "at level 15")
|
||||
local dex = record.pokedex or {}
|
||||
eq((dex.seen or {}).SHUCKLE, true, "seen in the #DEX")
|
||||
eq((dex.caught or {}).SHUCKLE, true, "and caught in the #DEX")
|
||||
|
||||
local hooks2 = { party = function() return list end,
|
||||
selectPartyMon = function(_, done) done(1, list[1]) end }
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
-- Whirlpool tiles force-turn the player: DoPlayerMovement's .CheckTile, the
|
||||
-- CheckWhirlpoolTile arm that sits ABOVE the nybble ladder.
|
||||
--
|
||||
-- luajit tests/gen2_whirlpool_test.lua
|
||||
--
|
||||
-- COLL_WHIRLPOOL is tested first and takes PLAYERMOVEMENT_FORCE_TURN
|
||||
-- (engine/overworld/player_movement.asm:117-123), which runs Script_ForcedMovement
|
||||
-- (engine/events/forced_movement.asm:1-51): step_dig 16, turn_in <back>,
|
||||
-- step_dig 16, turn_head <back>, step_end. So a whirlpool is entered, whirled
|
||||
-- on, and left again the way the player came, with the d-pad ignored throughout.
|
||||
--
|
||||
-- The port had no arm at all -- only the HI_NYBBLE_CURRENT and HI_NYBBLE_WARPS
|
||||
-- ones -- so a whirlpool cell was plain water and a surfer swam straight over
|
||||
-- it, past the HM06 + GLACIERBADGE gate on Route 41 and Route 27. Two decoding
|
||||
-- gaps sat under that: turn_away / turn_in / turn_waterfall all `jp TurningStep`
|
||||
-- (engine/overworld/movement.asm:483-513), which is a real one-cell STEP under
|
||||
-- OBJECT_ACTION_SPIN, and $4f step_dig was not modelled at all. #1716
|
||||
--
|
||||
-- The whirl itself is drawing, so what is asserted here is the state the
|
||||
-- drawing reads (Player.spinFrames) and the grid the player ends on.
|
||||
-- tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua is the other half.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 whirlpool")
|
||||
local check, eq, same = S.check, S.eq, S.same
|
||||
|
||||
local Movement = require("src.script.gen2.Movement")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local World = require("src.world.gen2.World")
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
|
||||
-- ---- the tile --------------------------------------------------------------
|
||||
--
|
||||
-- CheckWhirlpoolTile is the two-entry compare COLL_WHIRLPOOL $24 / COLL_WHIRLPOOL_1 $2c.
|
||||
local COLL_WATER, COLL_WHIRLPOOL, COLL_WHIRLPOOL_1 = 0x29, 0x24, 0x2c
|
||||
|
||||
check(Permissions.isWhirlpool(COLL_WHIRLPOOL), "COLL_WHIRLPOOL $24")
|
||||
check(Permissions.isWhirlpool(COLL_WHIRLPOOL_1), "COLL_WHIRLPOOL_1 $2c")
|
||||
check(not Permissions.isWhirlpool(COLL_WATER), "plain water is not one")
|
||||
check(not Permissions.isWhirlpool(0x33), "and neither is a waterfall")
|
||||
-- The arm is above the nybble ladder, not part of it: the tile's PERMISSION is
|
||||
-- still WATER_TILE, which is what lets a surfing player step onto it at all.
|
||||
check(Permissions.isWater(COLL_WHIRLPOOL),
|
||||
"a whirlpool is still WATER_TILE, so .CheckSurfable lets the step happen")
|
||||
eq(Permissions.currentDirection(COLL_WHIRLPOOL), nil,
|
||||
"and it is not a HI_NYBBLE_CURRENT tile")
|
||||
|
||||
-- ---- Script_ForcedMovement's stream ----------------------------------------
|
||||
--
|
||||
-- .MovementData_up is `step_dig 16 / turn_in DOWN / step_dig 16 /
|
||||
-- turn_head DOWN / step_end`, i.e. every byte points BACK the way the player
|
||||
-- came. turn_head is the $00 family, turn_in the $24 one, step_dig $4f with
|
||||
-- its frame count in the byte after it (macros/scripts/movement.asm:163-167).
|
||||
eq(Movement.STEP_DIG, 0x4f, "$4f step_dig")
|
||||
eq(Movement.STEP_DIG_FRAMES, 16, "and the 16 frames the stream asks it for")
|
||||
same(Movement.forcedMovementBytes("down"),
|
||||
{ 0x4f, 16, 0x24, 0x4f, 16, 0x00, 0x47 },
|
||||
"thrown DOWN: step_dig 16, turn_in DOWN, step_dig 16, turn_head DOWN, end")
|
||||
same(Movement.forcedMovementBytes("up"),
|
||||
{ 0x4f, 16, 0x25, 0x4f, 16, 0x01, 0x47 }, "thrown UP")
|
||||
same(Movement.forcedMovementBytes("left"),
|
||||
{ 0x4f, 16, 0x26, 0x4f, 16, 0x02, 0x47 }, "thrown LEFT")
|
||||
same(Movement.forcedMovementBytes("right"),
|
||||
{ 0x4f, 16, 0x27, 0x4f, 16, 0x03, 0x47 }, "thrown RIGHT")
|
||||
|
||||
-- The middle byte is the one the port used to read as a facing change, which
|
||||
-- is why the bounce could not exist: turn_in never moved anybody.
|
||||
for byte, dir in pairs({ [0x24] = "down", [0x25] = "up",
|
||||
[0x26] = "left", [0x27] = "right" }) do
|
||||
local act = Movement.decodeByte(byte)
|
||||
eq(act.kind, "step", ("$%02x turn_in crosses a cell"):format(byte))
|
||||
eq(act.dir, dir, ("$%02x turn_in direction"):format(byte))
|
||||
eq(act.spin, true, ("$%02x turn_in spins on the way"):format(byte))
|
||||
end
|
||||
eq(Movement.decodeByte(0x20).kind, "step", "$20 turn_away is a step too")
|
||||
eq(Movement.decodeByte(0x28).kind, "step", "$28 turn_waterfall as well")
|
||||
-- The neighbours must not have picked the spin up.
|
||||
eq(Movement.decodeByte(0x0c).spin, nil, "$0c step is a plain step")
|
||||
eq(Movement.decodeByte(0x10).spin, nil, "$10 big_step too")
|
||||
eq(Movement.decodeByte(0x00).kind, "turn", "$00 turn_head is still a turn")
|
||||
|
||||
-- ---- the step --------------------------------------------------------------
|
||||
--
|
||||
-- One whirlpool at (5,5) in an open pond, the way Route 41's four sit: water on
|
||||
-- every side of them (data/generated/maps.lua, ROUTE_41 (22,12)).
|
||||
local WHIRL_X, WHIRL_Y = 5, 5
|
||||
|
||||
local function whirlWorld(px, py, facing, coll)
|
||||
local game = {
|
||||
data = { items = {}, moves = {}, pokemon = {} },
|
||||
save = { player = { name = "GOLD", badges = {} }, party = {},
|
||||
inventory = {} },
|
||||
}
|
||||
local world = World.new(game)
|
||||
game.world = world
|
||||
local cells = {}
|
||||
for y = 0, 9 do
|
||||
for x = 0, 9 do cells[y * 100 + x] = COLL_WATER end
|
||||
end
|
||||
cells[WHIRL_Y * 100 + WHIRL_X] = coll or COLL_WHIRLPOOL
|
||||
local map
|
||||
map = {
|
||||
id = "ROUTE_41",
|
||||
width = 5, height = 5,
|
||||
def = { objects = {}, bgEvents = {}, environment = "WATER",
|
||||
tileset = "TILESET_JOHTO", width = 5, height = 5 },
|
||||
cellCollision = function(_, x, y)
|
||||
return cells[y * 100 + x] or COLL_WATER
|
||||
end,
|
||||
inBounds = function(_, x, y)
|
||||
return x >= 0 and y >= 0 and x < 10 and y < 10
|
||||
end,
|
||||
isWalkable = function(_, x, y)
|
||||
return Permissions.isWalkable(map:cellCollision(x, y))
|
||||
end,
|
||||
warpAt = function() return nil end,
|
||||
connection = function() return nil end,
|
||||
}
|
||||
world.map = map
|
||||
world.maps = { ROUTE_41 = map.def }
|
||||
world.player = Player.new(px, py, facing)
|
||||
world.entities = { world.player }
|
||||
world.npcs = {}
|
||||
world.playerState = FieldMoves.PLAYER_SURF
|
||||
world.encounters = {}
|
||||
world.pollTimeOfDay = function() end
|
||||
-- Nothing here draws, and nothing here is about the wild roll.
|
||||
world.noWildEncounters = true
|
||||
world.updatePeople = function() end
|
||||
return world, game
|
||||
end
|
||||
|
||||
-- Swim at the whirlpool from `dir` for `frames` with the d-pad held the whole
|
||||
-- way, the way a player leaning on it does, and report what happened.
|
||||
local function swimInto(dir, frames)
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
local d = DELTA[dir]
|
||||
-- Two cells short of the whirlpool, so the approach is an ordinary swim.
|
||||
local world = whirlWorld(WHIRL_X - d[1] * 2, WHIRL_Y - d[2] * 2, dir)
|
||||
local p = world.player
|
||||
local r = { onWhirlpool = false, spun = false, busy = false, past = false }
|
||||
for _ = 1, frames or 220 do
|
||||
world.heldDir = dir
|
||||
world:step()
|
||||
if p.cellX == WHIRL_X and p.cellY == WHIRL_Y then r.onWhirlpool = true end
|
||||
if p.spinFrames then r.spun = true end
|
||||
if world:busy() then r.busy = true end
|
||||
-- One cell past the whirlpool, on the far side.
|
||||
if p.cellX == WHIRL_X + d[1] and p.cellY == WHIRL_Y + d[2] then
|
||||
r.past = true
|
||||
end
|
||||
end
|
||||
r.x, r.y, r.facing = p.cellX, p.cellY, p.facing
|
||||
return r, world
|
||||
end
|
||||
|
||||
-- The cart lets the player land on the whirlpool: FORCE_TURN is answered from
|
||||
-- the tile UNDERFOOT, so the step onto it happens and the bounce follows.
|
||||
local BACK = { up = "down", down = "up", left = "right", right = "left" }
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
local r = swimInto(dir)
|
||||
check(r.onWhirlpool,
|
||||
("swimming %s reaches the whirlpool cell"):format(dir))
|
||||
check(not r.past,
|
||||
("holding %s never carries the player through it"):format(dir))
|
||||
check(r.busy,
|
||||
("the forced movement owns the world while it runs (%s)"):format(dir))
|
||||
check(r.spun,
|
||||
("step_dig put the player under OBJECT_ACTION_SPIN (%s)"):format(dir))
|
||||
eq(r.facing, BACK[dir],
|
||||
("turn_head leaves the player facing away from it (%s)"):format(dir))
|
||||
end
|
||||
|
||||
-- COLL_WHIRLPOOL_1 is the same arm, not a second tile that fell through.
|
||||
do
|
||||
local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WHIRLPOOL_1)
|
||||
local p = world.player
|
||||
local past = false
|
||||
for _ = 1, 220 do
|
||||
world.heldDir = "up"
|
||||
world:step()
|
||||
if p.cellY < WHIRL_Y then past = true end
|
||||
end
|
||||
check(not past, "$2c force-turns exactly like $24")
|
||||
end
|
||||
|
||||
-- The negative control, and the reason the bug was invisible: the same cell as
|
||||
-- plain water is swum straight over.
|
||||
do
|
||||
local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WATER)
|
||||
local p = world.player
|
||||
for _ = 1, 220 do
|
||||
world.heldDir = "up"
|
||||
world:step()
|
||||
end
|
||||
check(p.cellY < WHIRL_Y, "plain water in the same cell still lets us through")
|
||||
eq(p.spinFrames, nil, "and nothing spins over it")
|
||||
end
|
||||
|
||||
-- The turn is answered from the tile the player is STANDING on, not from a
|
||||
-- neighbour: swimming past a whirlpool one cell to the side is untouched.
|
||||
do
|
||||
local world = whirlWorld(WHIRL_X - 1, WHIRL_Y + 2, "up")
|
||||
local p = world.player
|
||||
for _ = 1, 220 do
|
||||
world.heldDir = "up"
|
||||
world:step()
|
||||
end
|
||||
eq(p.cellX, WHIRL_X - 1, "the column beside the whirlpool is ordinary water")
|
||||
check(p.cellY < WHIRL_Y, "and it is swum up without a bounce")
|
||||
end
|
||||
|
||||
-- The stream is Script_ForcedMovement's own, byte for byte, and it is queued
|
||||
-- through the same World:beginMovement every applymovement uses -- so the
|
||||
-- freeze, the follower and the step-end bookkeeping all come along with it.
|
||||
do
|
||||
local world = whirlWorld(WHIRL_X, WHIRL_Y, "up")
|
||||
check(world:runForcedMovement(), "standing on one starts the stream")
|
||||
check(world.moveState ~= nil, "through an applymovement, not a bespoke path")
|
||||
same(world.moveState.bytes, Movement.forcedMovementBytes("down"),
|
||||
"and the bytes are the ones facing UP asks for")
|
||||
check(world:busy(), "which is what closes the overworld to input")
|
||||
-- It does not start a second copy over itself.
|
||||
check(not world:runForcedMovement(), "one stream at a time")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -134,9 +134,7 @@ love.graphics = {
|
||||
|
||||
-- Fresh copies of the modules that cache a compiled shader or a page set
|
||||
-- at first use, so they see the recorder above -- and so the originals
|
||||
-- every other suite holds never see it. GBCFX is in the list because
|
||||
-- Renderer:endFrame reaches it and parity_gbcfx asserts on its unset
|
||||
-- shader cache.
|
||||
-- every other suite holds never see it.
|
||||
-- The tile/sprite renderers join them because they report trueColor rects
|
||||
-- to PaletteFX and resolve through Assets, and the loader because it holds
|
||||
-- Assets as an upvalue: an earlier suite's copy of any of the three would
|
||||
@@ -144,7 +142,7 @@ love.graphics = {
|
||||
local savedLoaded = {}
|
||||
for _, name in ipairs({ "src.render.PaletteFX", "src.render.Renderer",
|
||||
"src.render.Font", "src.render.Assets",
|
||||
"src.render.GBCFX", "src.render.SpriteRenderer",
|
||||
"src.render.SpriteRenderer",
|
||||
"src.render.TileRenderer", "src.mods.Loader" }) do
|
||||
savedLoaded[name] = package.loaded[name]
|
||||
package.loaded[name] = nil
|
||||
|
||||
+38
-3
@@ -285,8 +285,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
||||
"battleFit", "battleHud", "battleBg", "uiLayout",
|
||||
"ruleset", "musicVol", "sfxVol", "musicFilter",
|
||||
"performance", "colors",
|
||||
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
|
||||
"faithfulRes", "screenPos", "fpsCap",
|
||||
"tilt", "shaderfx", "shaderfx2", "zoom", "voidFill",
|
||||
"videoMode", "faithfulRes", "screenPos", "fpsCap",
|
||||
"speedOverworld", "speedBattle", "speedMenu",
|
||||
"mods", "controls", "dateFormat", "timeFormat" }
|
||||
local function orow(menu, id)
|
||||
@@ -331,7 +331,9 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
|
||||
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order,
|
||||
-- with SHADER FX / SHADER FX 2 right after TILT now that GBCFX.lua and
|
||||
-- its row are gone)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
om.game.save.options.zoom = 0
|
||||
@@ -359,6 +361,39 @@ check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||
|
||||
-- the SHADER FX row now pushes a real ShaderFXScreen list instead of
|
||||
-- cycling in place. With no presets under ShaderFX.presetDir() (nothing
|
||||
-- is dropped in for this stub love.filesystem), the pushed screen must
|
||||
-- show OFF plus the permanent DOWNLOAD SHADERS row and stay a safe
|
||||
-- no-op rather than crash. SHADER FX 2 (the dual-shader secondary slot)
|
||||
-- mirrors it one row down, opening the same shared screen on
|
||||
-- "secondary" instead.
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
check(om.rows[16].id == "shaderfx", "row 16 is the SHADER FX row")
|
||||
check(om.rows[16].value(om.game) == "OFF", "SHADER FX shows OFF with no presets")
|
||||
check(om.rows[16].step == nil, "SHADER FX row has no step() any more")
|
||||
om.rows[16].activate(om.game)
|
||||
local sfxScreen = om.game.stack:top()
|
||||
check(sfxScreen and sfxScreen.title == "SHADER FX",
|
||||
"SHADER FX row.activate() pushes a ShaderFXScreen")
|
||||
check(#sfxScreen.items == 2 and sfxScreen.items[1].label == "OFF"
|
||||
and sfxScreen.items[2].download == true,
|
||||
"ShaderFXScreen shows OFF + DOWNLOAD SHADERS with zero presets found")
|
||||
sfxScreen.onChoose(sfxScreen.items[1])
|
||||
check(ShaderFX.active("main") == false, "choosing OFF on an empty list stays a safe no-op")
|
||||
check(om.game.stack:top() == nil, "ShaderFXScreen pops itself after onChoose")
|
||||
|
||||
check(om.rows[17].id == "shaderfx2", "row 17 is the SHADER FX 2 row")
|
||||
check(om.rows[17].value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets")
|
||||
om.rows[17].activate(om.game)
|
||||
local sfx2Screen = om.game.stack:top()
|
||||
check(sfx2Screen and sfx2Screen.title == "SHADER FX 2",
|
||||
"SHADER FX 2 row.activate() pushes the shared ShaderFXScreen on the secondary slot")
|
||||
sfx2Screen.onChoose(sfx2Screen.items[1])
|
||||
check(ShaderFX.active("secondary") == false, "choosing OFF on the secondary slot is a safe no-op")
|
||||
check(ShaderFX.active() == false, "neither slot active means ShaderFX.active() is false")
|
||||
check(om.game.stack:top() == nil, "the secondary ShaderFXScreen pops itself after onChoose")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(orow(om, "fpsCap").value(om.game) == "60",
|
||||
|
||||
@@ -976,8 +976,10 @@ do
|
||||
check(battle.leveledUp and battle.leveledUp[caterpie],
|
||||
"awardExp records the level-up for EvolveAfterBattle")
|
||||
|
||||
game.stack:pop()
|
||||
battle.onFinish("win")
|
||||
-- EndOfBattle evolves on the battle screen (end_of_battle.asm:42-45),
|
||||
-- so drive finish() rather than calling onFinish by hand
|
||||
battle.result = "win"
|
||||
battle:finish()
|
||||
-- the "is evolving!" box types out and holds DelayFrames 50 before the
|
||||
-- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it
|
||||
game.input.wasPressed = function() return false end
|
||||
|
||||
@@ -61,8 +61,8 @@ function Record.events(target, opts)
|
||||
return capture
|
||||
end
|
||||
|
||||
-- the sanctioned draw-capture pattern from tests/parity_gbcfx.lua, lifted
|
||||
-- so sprite/tileset mods can assert on what reached the screen
|
||||
-- the sanctioned love.graphics.draw-capture pattern, lifted so sprite/
|
||||
-- tileset mods can assert on what reached the screen
|
||||
function Record.draw()
|
||||
local original = love.graphics.draw
|
||||
local capture = { draws = {} }
|
||||
|
||||
@@ -132,6 +132,9 @@ do
|
||||
o:handleInput()
|
||||
check(not o.player.moving, "and holding A there changes nothing")
|
||||
Input:reset()
|
||||
-- a scripted step now reads the live bike state (#1754), so do not leak
|
||||
-- the mount into whatever suite runs next
|
||||
Game.save.onBike = false
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -50,7 +50,9 @@ local function runEscort(sync, steps)
|
||||
npc.cellX, npc.cellY, npc.px, npc.py = 10, 4, 160, 64
|
||||
npc.moving, npc.progress, npc.targetX, npc.targetY = false, 0, nil, nil
|
||||
ow.scriptMoves = {}
|
||||
npc.stepFrames = sync and (p.stepFramesCur or p.stepFrames) or nil
|
||||
-- a scripted step re-derives its length from the live walk/bike state
|
||||
-- (home/overworld.asm:276, #1754), so sync off that, not a stale latch
|
||||
npc.stepFrames = sync and p:stepLength() or nil
|
||||
local worstDrift, done, i = 0, false, 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
-- Parity test, GBC FX ladder (Pixel Transparency shader).
|
||||
-- Unit-tests the Lua-side API of src/render/GBCFX.lua headless under the
|
||||
-- love stub: level clamping, the OFF→1→2→3→4→OFF cycle, options plumbing,
|
||||
-- level labels, and that active()/present() degrade gracefully when the
|
||||
-- stub offers no love.graphics.newShader (shader() returns nil).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity gbcfx")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- === assertions ===
|
||||
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
|
||||
-- defaults
|
||||
eq(GBCFX.level, 0, "gbcfx starts at level 0 (OFF)")
|
||||
eq(#GBCFX.LABELS, 5, "five labels (OFF + 4 levels)")
|
||||
eq(GBCFX.LABELS[1], "OFF", "first label is OFF")
|
||||
|
||||
-- setLevel clamps and floors
|
||||
GBCFX.setLevel(2)
|
||||
eq(GBCFX.level, 2, "setLevel stores an in-range level")
|
||||
GBCFX.setLevel(-3)
|
||||
eq(GBCFX.level, 0, "setLevel clamps below to 0")
|
||||
GBCFX.setLevel(99)
|
||||
eq(GBCFX.level, 4, "setLevel clamps above to 4")
|
||||
GBCFX.setLevel(2.9)
|
||||
eq(GBCFX.level, 2, "setLevel floors fractional levels")
|
||||
GBCFX.setLevel("3")
|
||||
eq(GBCFX.level, 3, "setLevel accepts numeric strings")
|
||||
GBCFX.setLevel(nil)
|
||||
eq(GBCFX.level, 0, "setLevel(nil) resets to OFF")
|
||||
GBCFX.setLevel("junk")
|
||||
eq(GBCFX.level, 0, "setLevel(non-numeric) resets to OFF")
|
||||
|
||||
-- cycle wraps OFF→1→2→3→4→OFF and returns the new level
|
||||
GBCFX.setLevel(0)
|
||||
eq(GBCFX.cycle(), 1, "cycle OFF -> 1")
|
||||
eq(GBCFX.cycle(), 2, "cycle 1 -> 2")
|
||||
eq(GBCFX.cycle(), 3, "cycle 2 -> 3")
|
||||
eq(GBCFX.cycle(), 4, "cycle 3 -> 4")
|
||||
eq(GBCFX.cycle(), 0, "cycle 4 wraps to OFF")
|
||||
eq(GBCFX.level, 0, "cycle leaves the wrapped level stored")
|
||||
|
||||
-- applyOptions reads opts.gbcfx
|
||||
GBCFX.applyOptions({ gbcfx = 3 })
|
||||
eq(GBCFX.level, 3, "applyOptions reads opts.gbcfx")
|
||||
GBCFX.applyOptions({})
|
||||
eq(GBCFX.level, 0, "applyOptions without gbcfx resets to OFF")
|
||||
GBCFX.setLevel(2)
|
||||
GBCFX.applyOptions(nil)
|
||||
eq(GBCFX.level, 0, "applyOptions(nil) resets to OFF")
|
||||
|
||||
-- labels
|
||||
eq(GBCFX.levelLabel(0), "OFF", "label for level 0")
|
||||
eq(GBCFX.levelLabel(1), "1", "label for level 1")
|
||||
eq(GBCFX.levelLabel(4), "4", "label for level 4")
|
||||
GBCFX.setLevel(3)
|
||||
eq(GBCFX.levelLabel(), "3", "levelLabel() defaults to the current level")
|
||||
eq(GBCFX.levelLabel(42), "OFF", "out-of-range label falls back to OFF")
|
||||
|
||||
-- headless: the love stub has no newShader, so the shader never compiles
|
||||
eq(GBCFX.shader(), nil, "shader() is nil headless")
|
||||
GBCFX.setLevel(4)
|
||||
check(not GBCFX.active(), "active() is false headless even at level 4")
|
||||
|
||||
-- present() falls back to a plain draw when the shader is unavailable
|
||||
local drawn = nil
|
||||
local g = love.graphics
|
||||
local oldDraw, oldSetColor = g.draw, g.setColor
|
||||
g.draw = function(c, x, y) drawn = { c, x, y } end
|
||||
g.setColor = g.setColor or function() end
|
||||
local canvas = {}
|
||||
local ok, err = pcall(GBCFX.present, canvas, 5)
|
||||
g.draw = oldDraw
|
||||
g.setColor = oldSetColor
|
||||
check(ok, "present() does not error headless (" .. tostring(err) .. ")")
|
||||
check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0,
|
||||
"present() falls back to a plain draw at (0,0)")
|
||||
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK)
|
||||
check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX")
|
||||
local prevSystem = love.system
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
check(not GBCFX.isSupported(), "Android reports GBC FX unsupported")
|
||||
GBCFX.setLevel(3)
|
||||
eq(GBCFX.level, 0, "setLevel forces OFF on Android")
|
||||
eq(GBCFX.cycle(), 0, "cycle stays OFF on Android")
|
||||
local opts = { gbcfx = 4 }
|
||||
check(GBCFX.applyOptions(opts) == true,
|
||||
"applyOptions reports a cleared persisted level on Android")
|
||||
eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android")
|
||||
eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android")
|
||||
check(not GBCFX.active(), "active() is false on Android")
|
||||
eq(GBCFX.shader(), nil, "shader() is nil on Android")
|
||||
love.system = { getOS = function() return "iOS" end }
|
||||
check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported")
|
||||
love.system = prevSystem
|
||||
check(GBCFX.isSupported(), "support restores when OS stub is removed")
|
||||
-- desktop path still applies a level after leaving the mobile gate
|
||||
GBCFX.applyOptions({ gbcfx = 2 })
|
||||
eq(GBCFX.level, 2, "applyOptions still sets levels on desktop")
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- Options menu hides the GBC FX row on Android
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
local om = OptionsMenu.new({
|
||||
data = { rulesets = {}, constants = {} },
|
||||
save = { options = {} },
|
||||
stack = { pop = function() end },
|
||||
input = { wasPressed = function() return false end },
|
||||
modStatus = { available = {} },
|
||||
})
|
||||
local hasGbc = false
|
||||
for _, row in ipairs(om.rows) do
|
||||
if row.id == "gbcfx" then hasGbc = true end
|
||||
end
|
||||
check(not hasGbc, "Options menu omits GBC FX on Android")
|
||||
love.system = prevSystem
|
||||
|
||||
-- POKEPORT_GBCFX overrides the platform default both ways. Handheld packs
|
||||
-- (build-rg34xxsp.sh) export 0 because their getOS() says "Linux" while the
|
||||
-- GPU is phone class; 1 is the escape hatch if a device turns out to cope.
|
||||
local prevEnv = os.getenv("POKEPORT_GBCFX")
|
||||
local realGetenv = os.getenv
|
||||
local stubEnv
|
||||
os.getenv = function(name)
|
||||
if name == "POKEPORT_GBCFX" then return stubEnv end
|
||||
return realGetenv(name)
|
||||
end
|
||||
|
||||
stubEnv = "0"
|
||||
check(not GBCFX.isSupported(), "POKEPORT_GBCFX=0 refuses GBC FX on desktop")
|
||||
GBCFX.setLevel(3)
|
||||
eq(GBCFX.level, 0, "setLevel forces OFF under POKEPORT_GBCFX=0")
|
||||
local handheldOpts = { gbcfx = 4 }
|
||||
check(GBCFX.applyOptions(handheldOpts) == true,
|
||||
"applyOptions reports a cleared level under POKEPORT_GBCFX=0")
|
||||
eq(handheldOpts.gbcfx, 0, "applyOptions heals a persisted level on a handheld")
|
||||
check(not GBCFX.active(), "active() is false under POKEPORT_GBCFX=0")
|
||||
|
||||
local omHandheld = OptionsMenu.new({
|
||||
data = { rulesets = {}, constants = {} },
|
||||
save = { options = {} },
|
||||
stack = { pop = function() end },
|
||||
input = { wasPressed = function() return false end },
|
||||
modStatus = { available = {} },
|
||||
})
|
||||
hasGbc = false
|
||||
for _, row in ipairs(omHandheld.rows) do
|
||||
if row.id == "gbcfx" then hasGbc = true end
|
||||
end
|
||||
check(not hasGbc, "Options menu omits GBC FX under POKEPORT_GBCFX=0")
|
||||
|
||||
-- "1" wins over the Android gate, so the override is a real two-way door.
|
||||
stubEnv = "1"
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
check(GBCFX.isSupported(), "POKEPORT_GBCFX=1 forces GBC FX on despite Android")
|
||||
love.system = prevSystem
|
||||
|
||||
stubEnv = nil
|
||||
check(GBCFX.isSupported(), "unset POKEPORT_GBCFX falls back to the OS check")
|
||||
os.getenv = realGetenv
|
||||
eq(os.getenv("POKEPORT_GBCFX"), prevEnv, "os.getenv restored")
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- === summary ===
|
||||
S.finish()
|
||||
@@ -175,7 +175,7 @@ do
|
||||
check(isBox(box), "the last candy levels too")
|
||||
finishLevelUp(game, box)
|
||||
eq(game.stack:top(), list, "the bag stays open after the last candy")
|
||||
eq(#list.items, 0, "the emptied row left the list")
|
||||
eq(#list.items, 1, "the emptied row left the list, leaving only CANCEL")
|
||||
eq(game.save.inventory.RARE_CANDY, nil, "no candies left in the inventory")
|
||||
end
|
||||
|
||||
|
||||
@@ -83,9 +83,10 @@ do
|
||||
for _, r in ipairs(rowsOfKind(rows, "show_text")) do
|
||||
said[#said + 1] = r[2]
|
||||
end
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers, so the
|
||||
-- defeat line prints in battle, not on the map (scripts/SSAnne2F.asm:199)
|
||||
local order = {
|
||||
"_SSAnne2FRivalText",
|
||||
"_SSAnne2FRivalDefeatedText",
|
||||
"_SSAnne2FRivalCutMasterText",
|
||||
}
|
||||
local pi = 1
|
||||
@@ -93,7 +94,10 @@ do
|
||||
if pi <= #order and id == order[pi] then pi = pi + 1 end
|
||||
end
|
||||
eq(pi, #order + 1,
|
||||
("x=%d text order: greeting, defeated, CUT master"):format(x))
|
||||
("x=%d text order: greeting, then CUT master"):format(x))
|
||||
local armed = rowsOfKind(rows, "save_end_battle_text")[1]
|
||||
check(armed ~= nil and armed[2] == "_SSAnne2FRivalDefeatedText",
|
||||
("x=%d arms the defeat line for the battle screen (#1688)"):format(x))
|
||||
|
||||
local walk = rowsOfKind(rows, "walk_npc")[1]
|
||||
check(walk ~= nil, ("x=%d exit is a walk_npc list"):format(x))
|
||||
@@ -123,13 +127,9 @@ local function dockBlock(bx, by)
|
||||
end
|
||||
|
||||
local function sail(cellX, cellY)
|
||||
local rows, puffs = nil, 0
|
||||
local rows = nil
|
||||
local ow = {
|
||||
player = { cellX = cellX, cellY = cellY },
|
||||
startDustAnim = function(_, _, _, done)
|
||||
puffs = puffs + 1
|
||||
if done then done() end
|
||||
end,
|
||||
queueScript = function(_, r) rows = r end,
|
||||
}
|
||||
local game = {
|
||||
@@ -139,10 +139,15 @@ local function sail(cellX, cellY)
|
||||
story3.VERMILION_DOCK.onEnter(game, ow)
|
||||
check(rows ~= nil, "stepping off the gangway queues the departure")
|
||||
check(game.save.flags.EVENT_SS_ANNE_LEFT == true, "EVENT_SS_ANNE_LEFT set")
|
||||
eq(puffs, 3, "three funnel smoke puffs (LoadSmokeTileFourTimes)")
|
||||
return rows
|
||||
end
|
||||
|
||||
local function kindIndex(rows, kind, from)
|
||||
for i = from or 1, #rows do
|
||||
if rows[i][1] == kind then return i end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- the hull ids the slide reuses are the map's own blocks, so a data
|
||||
-- rebuild that renumbered the tileset would be caught here
|
||||
@@ -165,52 +170,25 @@ do
|
||||
local waits = rowsOfKind(rows, "wait")
|
||||
eq(waits[1][2], 120, "120 frames before the first horn")
|
||||
eq(waits[#waits][2], 120, "EraseSSAnne's 120 frames before the walk out")
|
||||
local slide = 0
|
||||
for _, w in ipairs(waits) do
|
||||
if w[2] == 20 then slide = slide + 1 end
|
||||
end
|
||||
eq(slide, 8, "eight column shifts, .shift_columns_up's ld e, $8")
|
||||
|
||||
-- the bug was the whole hull blinking to water in a single frame with no
|
||||
-- travel at all: her bow block has to be written one column further west
|
||||
-- each step, and the water has to close in astern behind her
|
||||
local bow, wake = {}, {}
|
||||
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
|
||||
if r[3] == 1 and r[4] == dockBlock(DOCK_HULL.x0, 1) then
|
||||
bow[#bow + 1] = r[2]
|
||||
elseif r[3] == 1 and r[4] == 1 then
|
||||
wake[#wake + 1] = r[2]
|
||||
end
|
||||
end
|
||||
check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step")
|
||||
check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }),
|
||||
"water closes in astern, stern column first")
|
||||
-- scripts/VermilionDock.asm:50 zeroes wSpritePlayerStateData1ImageIndex and
|
||||
-- :77 freezes sprite updates: he faces DOWN until he walks out (#1689)
|
||||
eq(rows[1][1], "face_player_dir", "he is turned before the first delay")
|
||||
eq(rows[1][2], "down", "and he is turned to face DOWN")
|
||||
|
||||
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
|
||||
check(r[2] >= 1 and r[2] <= DOCK_HULL.x1,
|
||||
"the slide stays inside the dock's water, off the pier column 0")
|
||||
end
|
||||
-- she used to be shuffled west one whole 32px block per beat, which read as
|
||||
-- teleporting; .shift_columns_up is a 1px-per-8-frames slide (#1689)
|
||||
eq(#rowsOfKind(rows, "replace_block"), 0,
|
||||
"no block shuffle: the hull slides, it does not jump")
|
||||
eq(#rowsOfKind(rows, "ss_anne_departs"), 1, "one blocking sail-away beat")
|
||||
|
||||
-- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship,
|
||||
-- the gangway block under the player included (#1211)
|
||||
local final = {}
|
||||
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
|
||||
final[r[2] .. "," .. r[3]] = r[4]
|
||||
end
|
||||
for bx = DOCK_HULL.x0, DOCK_HULL.x1 do
|
||||
for by = DOCK_HULL.y0, DOCK_HULL.y1 do
|
||||
check(WATER[final[bx .. "," .. by]],
|
||||
("hull block (%d,%d) ends as open water"):format(bx, by))
|
||||
end
|
||||
end
|
||||
|
||||
-- and she has to have travelled: the westmost water column of the map
|
||||
-- carried hull blocks partway through
|
||||
local sawWest = false
|
||||
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
|
||||
if r[2] == 1 and not WATER[r[4]] then sawWest = true end
|
||||
end
|
||||
check(sawWest, "the hull reaches the west edge of the water before it goes")
|
||||
local horn1 = kindIndex(rows, "play_sound")
|
||||
local sailIdx = kindIndex(rows, "ss_anne_departs")
|
||||
local horn2 = kindIndex(rows, "play_sound", (horn1 or 0) + 1)
|
||||
check(horn1 and sailIdx and horn2 and horn1 < sailIdx and sailIdx < horn2,
|
||||
"horn, then she sails, then the horn again")
|
||||
check(kindIndex(rows, "warp") > sailIdx,
|
||||
"the walk out only starts once she has gone")
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -66,6 +66,7 @@ local function gameWith(opts)
|
||||
_VermilionCitySailor1YouNeedATicketText =
|
||||
"You need a ticket\nto get aboard.",
|
||||
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
|
||||
_VermilionCitySailor1WelcomeToSSAnneText = "Welcome to S.S.\nANNE!",
|
||||
_SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...",
|
||||
_SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!",
|
||||
_SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!",
|
||||
@@ -143,25 +144,36 @@ do
|
||||
check(#pushed == 0, "no spurious dialog off the check")
|
||||
end
|
||||
|
||||
-- Sailor talk after ship left: ShipSetSail branch (row script).
|
||||
-- Sailor talk: once she has sailed he only reports it gone, and before
|
||||
-- that an A press is the plain greeting, never the ticket check (#1651).
|
||||
-- scripts/VermilionCity.asm:158-198
|
||||
do
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } })
|
||||
local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1
|
||||
local runner = ScriptRunner.new(game, nil)
|
||||
runner:run(rows, {})
|
||||
local guard = 0
|
||||
while runner:isRunning() and guard < 200 do
|
||||
guard = guard + 1
|
||||
if game.stack and game._pushed then
|
||||
local box = game._pushed[#game._pushed]
|
||||
if box and box.done then box.done() end
|
||||
end
|
||||
runner:update()
|
||||
end
|
||||
local last = game._pushed[#game._pushed]
|
||||
eq(last and last.text, "The ship set sail.",
|
||||
local talk = city.talk.TEXT_VERMILIONCITY_SAILOR1
|
||||
local game, pushed = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } })
|
||||
talk(game, owWith({}), nil, function() end)
|
||||
eq(pushed[#pushed] and pushed[#pushed].text, "The ship set sail.",
|
||||
"talk after ship left shows ShipSetSail")
|
||||
|
||||
game, pushed = gameWith({})
|
||||
local ow = owWith({})
|
||||
ow.player.cellX, ow.player.cellY, ow.player.facing = 18, 30, "right"
|
||||
talk(game, ow, nil, function() end)
|
||||
eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!",
|
||||
"facing him from the west is the plain greeting, no ticket check")
|
||||
|
||||
game, pushed = gameWith({})
|
||||
ow = owWith({})
|
||||
ow.player.cellX, ow.player.cellY, ow.player.facing = 19, 29, "down"
|
||||
talk(game, ow, nil, function() end)
|
||||
eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!",
|
||||
"talking from in front of him is the plain greeting too")
|
||||
|
||||
game, pushed = gameWith({})
|
||||
ow = owWith({})
|
||||
ow.player.cellX, ow.player.cellY, ow.player.facing = 19, 31, "up"
|
||||
talk(game, ow, nil, function() end)
|
||||
eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!",
|
||||
"and from behind him")
|
||||
end
|
||||
|
||||
-- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set.
|
||||
@@ -180,22 +192,22 @@ do
|
||||
check(ow._queued ~= nil, "departure queues the sail-away script")
|
||||
-- #360: the surf override must NOT ride into Vermilion City, and she
|
||||
-- sails west block by block
|
||||
local kept, horns, slid, underPlayer = false, 0, 0, false
|
||||
local kept, horns, sails, shuffled, faced = false, 0, 0, 0, false
|
||||
for _, row in ipairs(ow._queued or {}) do
|
||||
if row[1] == "play_music" and row[3] and row[3].keep then kept = true end
|
||||
if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then
|
||||
horns = horns + 1
|
||||
end
|
||||
if row[1] == "replace_block" then
|
||||
slid = slid + 1
|
||||
if row[2] == 7 and row[3] == 1 then underPlayer = true end
|
||||
end
|
||||
if row[1] == "ss_anne_departs" then sails = sails + 1 end
|
||||
if row[1] == "replace_block" then shuffled = shuffled + 1 end
|
||||
if row[1] == "face_player_dir" and row[2] == "down" then faced = true end
|
||||
end
|
||||
eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp")
|
||||
eq(horns, 2, "the horn blows before and after she sails")
|
||||
check(slid > 8, "she sails west block by block instead of vanishing")
|
||||
-- scripts/VermilionDock.asm:182-203 (#1211)
|
||||
eq(underPlayer, true, "the gangway block under the player is erased too")
|
||||
eq(sails, 1, "she slides west as one animation, not a block shuffle")
|
||||
eq(shuffled, 0, "and no hull block is stamped across the dock any more")
|
||||
-- scripts/VermilionDock.asm:50 (#1689)
|
||||
eq(faced, true, "he keeps facing down until he walks out")
|
||||
end
|
||||
|
||||
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
-- A trainer script's post-battle text must finish before a level evolution.
|
||||
-- Its start_battle row resumes the script after OverworldState:afterBattle,
|
||||
-- so this covers the same handoff used by trainer and Rocket encounters.
|
||||
-- EndOfBattle evolves on the battle screen, before the map comes back
|
||||
-- (engine/battle/end_of_battle.asm:42-45) (#1656)
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
@@ -77,13 +76,9 @@ check(#Game.stack.states == 1,
|
||||
Game.stack:pop()
|
||||
moreText.onDone()
|
||||
|
||||
-- The evolution runs as the EvolutionState cutscene screen now (the
|
||||
-- evolve_mon.asm sequence lives in src/ui/EvolutionState.lua), not a bare
|
||||
-- "evolving!" text box, so assert the screen itself took the stack.
|
||||
local evolution = Game.stack:top()
|
||||
check(evolution ~= nil
|
||||
and (evolution.screenId == "EvolutionState"
|
||||
or stateHas(evolution, "evolving")),
|
||||
"the level evolution starts after trainer after-text closes")
|
||||
-- The evolution belongs to the battle screen (BattleState:finish ->
|
||||
-- Evolution.checkParty), so afterBattle leaves nothing behind the text.
|
||||
check(#Game.stack.states == 0,
|
||||
"afterBattle pushes no evolution; the battle screen already ran it")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -59,6 +59,7 @@ local textBoxStub = {
|
||||
end,
|
||||
}
|
||||
local realTextBox = getUpvalue(OW.trashCanSwitch, "TextBox")
|
||||
textBoxStub.soundOpts = realTextBox.soundOpts
|
||||
local fakeGame = { data = Data, save = SaveData.newGame(), stack = { push = function() end } }
|
||||
check(setUpvalue(OW.trashCanSwitch, "TextBox", textBoxStub), "TextBox upvalue rewired")
|
||||
check(setUpvalue(OW.trashCanSwitch, "Game", fakeGame), "Game upvalue rewired")
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Pet-NPC cries: #1687 (S.S. Anne WIGGLYTUFF + MACHOKE) and #1649
|
||||
-- (Vermilion PIDGEY, Vermilion City MACHOP, Fan Club PIKACHU + SEEL).
|
||||
-- Commands.play_cry only arms the very next show_text, so what is asserted
|
||||
-- here is placement: one play_cry row, the right species, the waitForButton
|
||||
-- form, sitting immediately before the box it belongs to. The sound itself
|
||||
-- is a driver's job (tests/drivers/pet_cries_bug1687_1649_test.lua).
|
||||
-- Self-contained: `luajit tests/pet_cries_test.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local init = require("data.scripts.init")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local S = require("tests.harness").suite("pet cries")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local yellow = GameVersion.isYellow()
|
||||
|
||||
-- pokered/scripts/*.asm, PlayCry line in brackets:
|
||||
-- SSAnne1FRooms.asm:66 [:70], SSAnneB1FRooms.asm:82 [:86],
|
||||
-- VermilionPidgeyHouse.asm:15 [:19], VermilionCity.asm:224 [:228],
|
||||
-- PokemonFanClub.asm:71 [:76] and :84 [:89].
|
||||
local PETS = {
|
||||
{ map = "SS_ANNE_1F_ROOMS", const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF",
|
||||
object = "SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF",
|
||||
label = "_SSAnne1FRoomsWigglytuffText" },
|
||||
{ map = "SS_ANNE_B1F_ROOMS", const = "TEXT_SSANNEB1FROOMS_MACHOKE",
|
||||
object = "SSANNEB1FROOMS_MACHOKE", species = "MACHOKE",
|
||||
label = "_SSAnneB1FRoomsMachokeText" },
|
||||
{ map = "VERMILION_PIDGEY_HOUSE", const = "TEXT_VERMILIONPIDGEYHOUSE_PIDGEY",
|
||||
object = "VERMILIONPIDGEYHOUSE_PIDGEY", species = "PIDGEY",
|
||||
label = "_VermilionPidgeyHousePidgeyText" },
|
||||
-- the cry belongs to the first box; the stomping line is a second box
|
||||
-- with no cry of its own (VermilionCity.asm:233 .StompingTheLandFlatText)
|
||||
{ map = "VERMILION_CITY", const = "TEXT_VERMILIONCITY_MACHOP",
|
||||
object = "VERMILIONCITY_MACHOP", species = "MACHOP",
|
||||
label = "_VermilionCityMachopText",
|
||||
after = "_VermilionCityMachopStompingTheLandFlatText" },
|
||||
{ map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_SEEL",
|
||||
object = "POKEMONFANCLUB_SEEL", species = "SEEL",
|
||||
label = "_PokemonFanClubSeelText" },
|
||||
}
|
||||
|
||||
if yellow then
|
||||
PETS[#PETS + 1] = {
|
||||
map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_CLEFAIRY",
|
||||
object = "POKEMONFANCLUB_CLEFAIRY", species = "CLEFAIRY",
|
||||
label = "_PokemonFanClubClefairyText",
|
||||
}
|
||||
else
|
||||
PETS[#PETS + 1] = {
|
||||
map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_PIKACHU",
|
||||
object = "POKEMONFANCLUB_PIKACHU", species = "PIKACHU",
|
||||
label = "_PokemonFanClubPikachuText",
|
||||
}
|
||||
end
|
||||
|
||||
local function rowsOf(script)
|
||||
local plays, texts = {}, {}
|
||||
for i, row in ipairs(script) do
|
||||
if type(row) == "table" then
|
||||
if row[1] == "play_cry" then plays[#plays + 1] = i end
|
||||
if row[1] == "show_text" then texts[#texts + 1] = i end
|
||||
end
|
||||
end
|
||||
return plays, texts
|
||||
end
|
||||
|
||||
for _, pet in ipairs(PETS) do
|
||||
local tag = pet.map .. "/" .. pet.const
|
||||
local script = init.talkScript(pet.map, pet.const)
|
||||
if check(type(script) == "table", tag .. " has a ported talk script") then
|
||||
local plays = rowsOf(script)
|
||||
if eq(#plays, 1, tag .. " carries exactly one play_cry row") then
|
||||
local cry = script[plays[1]]
|
||||
eq(cry[2], pet.species, tag .. " cries " .. pet.species)
|
||||
-- the waitForButton form: without it the box pops itself when the
|
||||
-- cry ends instead of holding for A/B (Commands.play_cry, #247/#251)
|
||||
eq(cry[3], true, tag .. " uses the waitForButton play_cry form")
|
||||
-- play_cry arms ctx.pendingCry for the NEXT show_text only, so the
|
||||
-- box it belongs to has to be the very next row
|
||||
local next_ = script[plays[1] + 1]
|
||||
check(type(next_) == "table" and next_[1] == "show_text"
|
||||
and next_[2] == pet.label,
|
||||
tag .. " arms " .. pet.label .. " on the next row")
|
||||
end
|
||||
if pet.after then
|
||||
local _, texts = rowsOf(script)
|
||||
eq(#texts, 2, tag .. " still shows both of its boxes")
|
||||
local last = texts[#texts]
|
||||
eq(script[last][2], pet.after, tag .. " keeps " .. pet.after .. " last")
|
||||
end
|
||||
end
|
||||
|
||||
check(Data.pokemon[pet.species] ~= nil, pet.species .. " is a known species")
|
||||
check(Data.audio.cries and Data.audio.cries[pet.species] ~= nil,
|
||||
pet.species .. " has a cry program in the mounted cache")
|
||||
local text = Data.text[pet.label]
|
||||
check(type(text) == "string" and text ~= "",
|
||||
pet.label .. " resolves to text")
|
||||
|
||||
-- a script nobody can reach is the same silence as a missing cry
|
||||
local objects = (Data.maps[pet.map] or {}).objects or {}
|
||||
local found
|
||||
for _, o in ipairs(objects) do
|
||||
if o.name == pet.object then found = o end
|
||||
end
|
||||
if check(found ~= nil, pet.map .. " still lists " .. pet.object) then
|
||||
eq(found.text, pet.const, pet.object .. " talks through " .. pet.const)
|
||||
end
|
||||
end
|
||||
|
||||
-- Yellow swaps the Fan Club pet for a CLEFAIRY on its own text constant
|
||||
-- (pokeyellow/data/maps/objects/PokemonFanClub.asm), so the extra row has
|
||||
-- to be version-gated in both directions.
|
||||
if yellow then
|
||||
check(init.talkScript("POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CLEFAIRY") ~= nil,
|
||||
"Yellow registers the CLEFAIRY pet")
|
||||
else
|
||||
check(init.talkScript("POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CLEFAIRY") == nil,
|
||||
"Red/Blue do not register Yellow's CLEFAIRY pet")
|
||||
end
|
||||
|
||||
-- The Yellow half of that guard, checked from any version: re-read the
|
||||
-- module with isYellow forced on, then put package.loaded back exactly as
|
||||
-- it was so the registry keeps pointing at the table it merged.
|
||||
do
|
||||
local key = "data.scripts.flavor.pokemon_fan_club"
|
||||
local cached = package.loaded[key]
|
||||
local realIsYellow = GameVersion.isYellow
|
||||
GameVersion.isYellow = function() return true end
|
||||
package.loaded[key] = nil
|
||||
local ok, module = pcall(require, key)
|
||||
GameVersion.isYellow = realIsYellow
|
||||
package.loaded[key] = cached
|
||||
if check(ok, "the Fan Club script loads with isYellow forced on") then
|
||||
local row = module.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY
|
||||
if check(type(row) == "table", "the Yellow branch adds the CLEFAIRY pet") then
|
||||
eq(row[1][1], "play_cry", "Yellow CLEFAIRY leads with play_cry")
|
||||
eq(row[1][2], "CLEFAIRY", "Yellow CLEFAIRY cries CLEFAIRY, not PIKACHU")
|
||||
eq(row[1][3], true, "Yellow CLEFAIRY uses the waitForButton form")
|
||||
eq(row[2][2], "_PokemonFanClubClefairyText",
|
||||
"Yellow CLEFAIRY arms its own text")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Sound.playCry hands PIKACHU to the PCM clips whenever the cache has
|
||||
-- them, which is Yellow's voiced cry. Red/Blue must never reach that
|
||||
-- branch: audio.pikaCries is the only thing gating it.
|
||||
if not yellow then
|
||||
check(Data.audio.pikaCries == nil,
|
||||
"a non-Yellow cache has no Pikachu voice clips for playCry to find")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
+49
-23
@@ -1223,8 +1223,8 @@ do
|
||||
local mhc = BattleState.newWild(Game, "SNORLAX", 40)
|
||||
mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook
|
||||
mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 })
|
||||
eq(countText(mhc, "Critical hit!"), 2,
|
||||
"multi-hit prints Critical hit! once per strike")
|
||||
eq(countText(mhc, "Critical hit!"), 1,
|
||||
"PrintCriticalOHKOText clears wCriticalHitOrOHKO, so the crit line prints once")
|
||||
local cseq = {}
|
||||
for _, r in ipairs(mhc.queue) do
|
||||
if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim"
|
||||
@@ -1233,8 +1233,8 @@ do
|
||||
elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se"
|
||||
end
|
||||
end
|
||||
eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se",
|
||||
"crit then effectiveness follow each multi-hit drain")
|
||||
eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,se",
|
||||
"the crit line prints once, effectiveness after every drain")
|
||||
unsub()
|
||||
Runtime.install(savedE, savedH)
|
||||
end
|
||||
@@ -1658,7 +1658,7 @@ do
|
||||
rep.options = {
|
||||
textSpeed = 3, animations = false, battleStyle = "SET",
|
||||
ruleset = "gen1_faithful", musicVol = 4, sfxVol = 2, musicFilter = 2,
|
||||
colors = "og", tilt = 2, gbcfx = 3,
|
||||
colors = "og", tilt = 2,
|
||||
}
|
||||
rep.defeatedTrainers = { ["OPP_BROCK:1"] = true }
|
||||
rep.pokedex = { seen = { PIKACHU = true, CATERPIE = true },
|
||||
@@ -1687,7 +1687,6 @@ do
|
||||
eq(back.options.animations, false, "options.lua round-trips animations")
|
||||
eq(back.options.colors, "og", "options.lua round-trips colors")
|
||||
eq(back.options.tilt, 2, "options.lua round-trips tilt")
|
||||
eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx")
|
||||
-- zoom / voidFill ride the same options.lua path when present
|
||||
rep.options.zoom = -2
|
||||
rep.options.voidFill = "water"
|
||||
@@ -2315,6 +2314,14 @@ do
|
||||
press("down")
|
||||
press("down")
|
||||
press("a") -- QUIT
|
||||
check(not quitCalled, "QUIT says goodbye before it returns (pokemart.asm:220)")
|
||||
local goodbye = StateStack:top()
|
||||
check(goodbye ~= shop and goodbye.isTextBox,
|
||||
"QUIT prints _PokemartThankYouText")
|
||||
for _ = 1, 600 do
|
||||
if quitCalled then break end
|
||||
press("a")
|
||||
end
|
||||
check(quitCalled, "QUIT fires onQuit (script runner resume)")
|
||||
eq(#StateStack.states, depth0, "mart menu unwound cleanly")
|
||||
end
|
||||
@@ -2635,14 +2642,14 @@ do
|
||||
-- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box
|
||||
-- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like
|
||||
-- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed),
|
||||
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE
|
||||
-- cycle their display modes.
|
||||
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / VIDEO MODE
|
||||
-- cycle their display modes (SHADER FX activates a pushed screen instead).
|
||||
do
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local OInput = require("src.core.Input")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local SD = require("src.core.SaveData")
|
||||
@@ -2681,7 +2688,6 @@ do
|
||||
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
|
||||
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
||||
eq(og.save.options.tilt, 0, "new saves default TILT to OFF")
|
||||
eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF")
|
||||
eq(og.save.options.zoom, 0, "new saves default ZOOM to FIT")
|
||||
eq(og.save.options.voidFill, "trees", "new saves default VOID FILL to TREES")
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
@@ -2725,12 +2731,22 @@ do
|
||||
eq(Tilt.level, 1, "Tilt level tracks TILT option")
|
||||
press("a"); press("a"); press("a")
|
||||
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
|
||||
check(seek("gbcfx"), "cursor reaches GBC FX")
|
||||
press("a")
|
||||
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
|
||||
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
|
||||
for _ = 1, 4 do press("a") end
|
||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||
check(seek("shaderfx"), "cursor reaches SHADER FX")
|
||||
-- this row activates a pushed ShaderFXScreen rather than cycling in
|
||||
-- place like the rest of this suite's rows; `og.stack` above only
|
||||
-- stubs `pop`, not a real push/top stack, so activate() is not
|
||||
-- called here -- tests/mod_ui_tests.lua exercises it end to end against
|
||||
-- a real stack.
|
||||
check(om.rows[om.index].step == nil, "SHADER FX row has no step()")
|
||||
check(type(om.rows[om.index].activate) == "function",
|
||||
"SHADER FX row has an activate()")
|
||||
check(seek("shaderfx2"), "cursor reaches SHADER FX 2")
|
||||
-- the dual-shader secondary slot: same shared ShaderFXScreen, opened on
|
||||
-- "secondary" instead -- see the SHADER FX row above for why activate()
|
||||
-- isn't exercised against this stub stack either.
|
||||
check(om.rows[om.index].step == nil, "SHADER FX 2 row has no step() either")
|
||||
check(type(om.rows[om.index].activate) == "function",
|
||||
"SHADER FX 2 row has an activate()")
|
||||
check(seek("zoom"), "cursor reaches ZOOM")
|
||||
local ZoomOpt = require("src.render.Zoom")
|
||||
press("a")
|
||||
@@ -2806,7 +2822,7 @@ do
|
||||
require("src.core.Sound").applyOptions(og.save.options)
|
||||
PaletteFX.applyOptions(og.save.options)
|
||||
Tilt.applyOptions(og.save.options)
|
||||
GBCFX.applyOptions(og.save.options)
|
||||
ShaderFX.applyOptions(og.save.options)
|
||||
require("src.render.Zoom").applyOptions(og.save.options)
|
||||
require("src.render.TileRenderer").applyOptions(og.save.options)
|
||||
require("src.core.VideoMode").applyOptions(og.save.options)
|
||||
@@ -3354,7 +3370,7 @@ end
|
||||
-- ---------------------------------------------- suite discovery
|
||||
-- The chains below used to be hard-coded arrays, so adding a suite meant
|
||||
-- editing a list and forgetting to meant the suite silently never ran.
|
||||
-- They are globbed now (21-testing-and-ci §CI).
|
||||
-- They're globbed now instead.
|
||||
--
|
||||
-- Order still matters: these suites share one process and one Data, and
|
||||
-- the sequence they were chained in is the sequence they are known to
|
||||
@@ -3395,10 +3411,6 @@ end
|
||||
-- back. Nothing notices until a LATER file reads the leftover, and then the
|
||||
-- failure lands nowhere near its cause:
|
||||
--
|
||||
-- * the Android ROM-importer suites pin getOS to "Android", so
|
||||
-- GBCFX.isSupported() answers false for the rest of the run and
|
||||
-- parity_gbcfx fails "setLevel stores an in-range level (got 0, want 2)"
|
||||
-- -- while passing perfectly on its own;
|
||||
-- * suites that swap in a minimal love.filesystem drop
|
||||
-- getDirectoryItems, so three rom_importer suites then die on
|
||||
-- "attempt to call field 'getDirectoryItems' (a nil value)".
|
||||
@@ -3621,6 +3633,19 @@ runSuites(orderedGlob(
|
||||
"tests/gen2_crystal_anim_test.lua",
|
||||
"tests/gen2_crystal_caught_data_test.lua",
|
||||
"tests/gen2_crystal_gender_test.lua",
|
||||
-- Pinned in the order the glob already ran them in, alphabetically last.
|
||||
"tests/gen2_battle_cursor_test.lua",
|
||||
"tests/gen2_battle_options_test.lua",
|
||||
"tests/gen2_billspc_dpad_test.lua",
|
||||
"tests/gen2_box_intake_test.lua",
|
||||
"tests/gen2_cycling_road_test.lua",
|
||||
"tests/gen2_dex_gift_test.lua",
|
||||
"tests/gen2_ledge_hop_test.lua",
|
||||
"tests/gen2_ow_bounce_test.lua",
|
||||
"tests/gen2_pack_rows_test.lua",
|
||||
"tests/gen2_sleep_counter_test.lua",
|
||||
"tests/gen2_timed_heal_test.lua",
|
||||
"tests/gen2_whirlpool_test.lua",
|
||||
}, LEAKS_SAVE_SLOT_STATE))
|
||||
|
||||
-- ---------------------------------------------- Android second ROM pick (#167)
|
||||
@@ -3638,6 +3663,8 @@ runSuites({ "tests/rom_importer_choose_version_test.lua" })
|
||||
-- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so
|
||||
-- CI's headless lane runs them without data/generated/.
|
||||
runSuites({ "tests/launcher_mods_install_zip_test.lua" })
|
||||
-- ---------------------------------------------- pet Pokemon cries (#1687, #1649)
|
||||
runSuites({ "tests/pet_cries_test.lua" })
|
||||
-- ---------------------------------------------- parity workstream tests
|
||||
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
|
||||
-- error()s if any assertion fails). Globbed, so dropping a new parity
|
||||
@@ -3656,7 +3683,6 @@ runSuites(orderedGlob("tests/parity_*.lua", {
|
||||
"tests/parity_yellow_bills_pikachu.lua",
|
||||
"tests/parity_trainer_evolution_order.lua",
|
||||
"tests/parity_intro.lua", "tests/parity_tilt.lua",
|
||||
"tests/parity_gbcfx.lua",
|
||||
}))
|
||||
|
||||
-- ---------------------------------------------- the globbed tiers
|
||||
|
||||
@@ -66,7 +66,6 @@ mustContain(install, "Select + **A**", "install")
|
||||
mustContain(install, "Select + **L**", "install")
|
||||
mustContain(install, "COLORS", "install")
|
||||
mustContain(install, "TILT", "install")
|
||||
mustContain(install, "GBC FX", "install")
|
||||
mustContain(install, "PERFORMANCE", "install")
|
||||
mustContain(install, "Stock engine effect", "install")
|
||||
mustContain(install, "## Limitations", "install")
|
||||
|
||||
Reference in New Issue
Block a user