Spider Car Unleashed

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