fix merge conflicts with dev

This commit is contained in:
1jamie
2026-08-13 07:39:11 -05:00
60 changed files with 1900 additions and 251 deletions
+80
View File
@@ -0,0 +1,80 @@
-- #1091: does GAME SPEED change the wild encounter rate per STEP?
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love .
local Encounter = require("src.battle.gen2.Encounter")
local Player = require("src.world.gen2.Player")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local rolls, hits, steps = 0, 0, 0
local realTriggers = Encounter.triggers
Encounter.triggers = function(rate, random)
rolls = rolls + 1
if realTriggers(rate, random) then hits = hits + 1 end
return false
end
local World = require("src.world.gen2.World")
local ticks = 0
local realWorldStep = World.step
World.step = function(self)
ticks = ticks + 1
return realWorldStep(self)
end
local realUpdate = Player.update
Player.update = function(self)
local landed = realUpdate(self)
if landed then steps = steps + 1 end
return landed
end
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local function measure(speed, yields)
world:setMap("ROUTE_29", 44, 12, "right")
wait(20)
world.wildCooldown = 0
game.speedOverride = speed
rolls, hits, steps, ticks = 0, 0, 0, 0
local dir = "right"
for i = 1, yields do
if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
end
clearDirs()
game.speedOverride = 1
wait(5)
return rolls, hits, steps, ticks
end
local r1, h1, s1, t1 = measure(1, 900)
local r2, h2, s2, t2 = measure(200, 60)
print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0))
print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0))
Encounter.triggers = realTriggers
Player.update = realUpdate
World.step = realWorldStep
local rate1 = s1 > 0 and r1 / s1 or 0
local rate2 = s2 > 0 and r2 / s2 or 0
assert(s1 > 20 and s2 > 20, "not enough walked cells to judge")
assert(math.abs(rate1 - rate2) < 0.05,
("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2))
print("[driver] PASS encounter rolls are one per walked cell at every speed")
end
+79
View File
@@ -0,0 +1,79 @@
-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP
-- puts the player inside the mountain on Route 31.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love .
local Mon = require("src.battle.gen2.Mon")
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
local function hold(dir, frames, trace)
local last
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
local w = game.world
local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY)
if trace and at ~= last then print(" " .. at); last = at end
end
clearDirs()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
-- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP
-- arrival until the errand is over; set its event so the walk is not spent
-- behind a phone call. tests/drivers/gold/flag_names.lua:1132.
if world.events then world.events:set(64, true) end
-- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3.
world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down")
wait(20)
hold("down", 40)
-- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14)
for _ = 1, 900 do
if not world:busy() then break end
coroutine.yield()
end
wait(20)
print(("[driver] out of the cave at %s (%d,%d)")
:format(world.map.id, world.player.cellX, world.player.cellY))
assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31")
print("[driver] holding UP:")
hold("up", 120, true)
wait(30)
local m, x, y = world.map.id, world.player.cellX, world.player.cellY
print(("[driver] ended at %s (%d,%d)"):format(m, x, y))
-- Anything above y=5 on Route 31 in that column is the inside of the
-- mountain; the honest outcomes are re-entering the cave or bumping.
local inside = (m == "ROUTE_31" and y < 5)
assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y))
-- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is
-- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with
-- ordinary FLOOR above them in the collision data.
for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do
local mapId, wx, wy = c[1], c[2], c[3]
world:setMap(mapId, wx, wy, "up")
wait(20)
hold("up", 90)
wait(20)
if world.map.id == mapId then
assert(world.player.cellY >= wy,
("%s: walked above the cave mouth to (%d,%d)")
:format(mapId, world.player.cellX, world.player.cellY))
end
print(("[driver] %s mouth ended at %s (%d,%d)"):format(
mapId, world.map.id, world.player.cellX, world.player.cellY))
end
print("[driver] PASS the cave mouth cannot be walked through")
end
+78
View File
@@ -0,0 +1,78 @@
-- #1104: the Ruins of Alph ladders warp somewhere else.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love .
local Mon = require("src.battle.gen2.Mon")
-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126,
-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings.
local CASES = {
{ "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 },
{ "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" },
{ "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" },
{ "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" },
{ "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" },
{ "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
-- Hold until the map changes, then let the load settle and STOP: holding
-- past the arrival would walk the player off the landing tile.
local function walkUntilWarp(dir, frames, fromMap)
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
if game.world.map and game.world.map.id ~= fromMap then
clearDirs()
-- The ARRIVAL cell is the one the warp table names. Standing on a
-- door/cave/staircase tile then forces one step DOWN off it
-- (engine/overworld/player_movement.asm:201-213), so the settled cell
-- is legitimately one row lower on an outdoor mouth.
local ax, ay = game.world.player.cellX, game.world.player.cellY
for _ = 1, 45 do coroutine.yield() end
return game.world.map.id, ax, ay,
game.world.player.cellX, game.world.player.cellY
end
end
clearDirs()
return game.world.map and game.world.map.id, nil, nil
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local bad = 0
for _, c in ipairs(CASES) do
local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6]
-- default approach is from below, the way the report describes facing the
-- ladder from below; a chamber's exit sits on the bottom row instead
local dir = c[7] or "up"
local sy = (dir == "up") and wy + 1 or wy - 1
world:setMap(src, wx, sy, dir)
wait(12)
local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY)
local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src)
local ok = (m == wantMap and ax == wantX and ay == wantY)
if not ok then bad = bad + 1 end
print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)")
:format(ok and "OK" or "MISMATCH", from, tostring(m),
tostring(ax), tostring(ay), tostring(sx), tostring(sy),
wantMap, wantX, wantY))
end
print(("[driver] %s ruins warps: %d mismatches")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+100
View File
@@ -0,0 +1,100 @@
-- #1155: "MAGNITUDE always rolls a 4".
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local ROLLS = 15
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local caster = Mon.new(game.data, "DUNSPARCE", 50)
local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua")
caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } }
print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect)
.. ", stored power = " .. tostring(def.power))
game.save.party = { caster }
local seen, damages, shown = {}, {}, 0
local screen
for i = 1, ROLLS do
if not (screen and game.stack:top() == screen and not screen.battle.over) then
local dummy = Mon.new(game.data, "SNORLAX", 60)
dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = dummy }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 300)
end
local foe = screen.battle.enemy
local before = foe.hp
caster.moves[1].pp = def.pp
screen:submit({ kind = "move", move = "MAGNITUDE" })
for _, event in ipairs(screen.queue) do
local number = event.text and event.text:match("^Magnitude (%d+)")
if number then
seen[number] = (seen[number] or 0) + 1
if shown < 1 then
U.wait(1)
U.shot(game, out .. "/01-magnitude-text.png")
shown = 1
end
end
end
drain(game, screen, 400)
local dealt = before - foe.hp
if dealt > 0 then damages[dealt] = true end
if i % 10 == 0 then U.wait(5) end
end
local numbers = {}
for number, count in pairs(seen) do
numbers[#numbers + 1] = number .. "x" .. count
end
table.sort(numbers)
print("[driver] magnitudes over " .. ROLLS .. " uses: "
.. table.concat(numbers, " "))
local distinctPowers = 0
for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
check(#numbers > 1, "MAGNITUDE rolls more than one number")
check(seen["4"] == nil or seen["4"] < ROLLS,
"and is not stuck on MAGNITUDE 4")
check(distinctPowers > 1, "the rolled power reaches the damage (" ..
distinctPowers .. " distinct damage figures)")
if #failures > 0 then
error(#failures .. " magnitude checks failed")
end
print("[driver] #1155 clear; shot in " .. out)
end
+113
View File
@@ -0,0 +1,113 @@
-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- The reporter's own matchup, three times over.
local caster = Mon.new(game.data, "MAGNEMITE", 20)
local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing")
print("[driver] SONICBOOM effect = " .. tostring(boom.effect)
.. ", stored power = " .. tostring(boom.power))
caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } }
game.save.party = { caster }
-- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not
-- count: what matters is that every use that CONNECTS is 20.
local dealt, hits, wrong = {}, 0, 0
for i = 1, 6 do
local rattata = Mon.new(game.data, "RATTATA", 11)
rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = rattata }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = rattata.hp
caster.hp = caster.maxHp or caster.hp
caster.moves[1].pp = boom.pp
screen:submit({ kind = "move", move = "SONICBOOM" })
drain(game, screen, 400)
dealt[i] = before - rattata.hp
if dealt[i] > 0 then
hits = hits + 1
if dealt[i] ~= 20 then wrong = wrong + 1 end
end
if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(45)
end
print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ")
.. " (0 is a miss)")
check(hits >= 3, "SONIC BOOM connected at least three times")
check(wrong == 0, "and every hit was exactly 20")
-- The two siblings on the same command.
local dragon = Mon.new(game.data, "DRATINI", 25)
local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing")
local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing")
dragon.moves = {
{ id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp },
{ id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp },
}
game.save.party = { dragon }
local target = Mon.new(game.data, "SNORLAX", 60)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = target.hp
screen:submit({ kind = "move", move = "DRAGON_RAGE" })
drain(game, screen, 400)
local rageDealt = before - target.hp
before = target.hp
screen:submit({ kind = "move", move = "SEISMIC_TOSS" })
drain(game, screen, 400)
local tossDealt = before - target.hp
U.shot(game, out .. "/02-siblings.png")
print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)")
:format(rageDealt, tossDealt, dragon.level))
check(rageDealt == 40, "DRAGON RAGE deals exactly 40")
check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level")
if #failures > 0 then
error(#failures .. " constant-damage checks failed")
end
print("[driver] #1157 fixed; shots in " .. out)
end
+96
View File
@@ -0,0 +1,96 @@
-- #1158: you could not see the trainer's incoming mon before the switch offer.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function runToPhase(game, screen, phase, frames)
for _ = 1, (frames or 600) do
if screen.phase == phase then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch).
game.options = game.options or {}
game.options.battleStyle = "SHIFT"
local lead = Mon.new(game.data, "TYPHLOSION", 40)
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(game.data, "FERALIGATR", 40)
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, bench }
local foe1 = Mon.new(game.data, "PIDGEY", 5)
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local foe2 = Mon.new(game.data, "PIDGEOTTO", 20)
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER",
party = { foe1, foe2 } } }), "trainer startBattle failed")
local screen = battleScreen(game)
runToPhase(game, screen, "menu", 400)
foe1.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
-- Page by page, collecting what the box actually showed.
local pages, sawBoxWithName = {}, false
local incoming = foe2.nickname or foe2.species or "PIDGEOTTO"
for _ = 1, 900 do
local message = screen.message
if message and pages[#pages] ~= message
and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then
pages[#pages + 1] = message
if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end
if message:find(incoming, 1, true) then
U.shot(game, out .. "/02-names-the-mon.png")
sawBoxWithName = true
end
end
if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then
break
end
U.tap(game, "a")
U.wait(4)
end
U.shot(game, out .. "/03-yes-no.png")
for i, page in ipairs(pages) do
print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / "))))
end
check(sawBoxWithName,
"the incoming " .. incoming .. " is named in the box before the yes/no")
check(screen.phase == "ask-shift",
"and the prompt still ends on the YES/NO question")
check(#pages > 1, "the offer runs as pages, not one over-long line")
if #failures > 0 then
error(#failures .. " send-out order checks failed")
end
print("[driver] #1158 fixed; shots in " .. out)
end
+131
View File
@@ -0,0 +1,131 @@
-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics).
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local BattleState = require("src.ui.gen2.BattleState")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- One drawPic call with love.graphics.draw intercepted: how many blits it made
-- and where the first one landed.
local function probePic(screen, mon, back)
local G = love.graphics
local real = G.draw
local count, x, y = 0, nil, nil
G.draw = function(image, a, b, ...)
count = count + 1
if count == 1 then
-- draw(image, x, y, ...) or draw(image, quad, x, y, ...)
if type(a) == "number" then x, y = a, b else x, y = b, (...) end
end
end
local ok, err = pcall(screen.drawPic, screen, mon, back)
G.draw = real
if not ok then error(err) end
return count, x, y
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
------------------------------------------------------------------ #1159
local digger = Mon.new(game.data, "SANDSHREW", 30)
local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache")
digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } }
game.save.party = { digger }
local target = Mon.new(game.data, "SNORLAX", 40)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = probePic(screen, screen.battle.player, true)
check(before > 0, "the back pic is on the field before DIG")
U.shot(game, out .. "/01-before-dig.png")
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == true,
"SUBSTATUS_UNDERGROUND is set after the charge turn")
local underground = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws while underground: " .. underground)
check(underground == 0, "the back pic is GONE for the charge turn")
U.shot(game, out .. "/02-underground.png")
-- The enemy front pic is untouched by the player's own dig.
local foe = probePic(screen, screen.battle.enemy, false)
check(foe > 0, "the enemy front pic is unaffected")
-- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it.
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == false,
"the substatus is cleared when the stored attack lands")
local after = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws after the attack: " .. after)
check(after > 0, "the back pic is back on the field")
U.shot(game, out .. "/03-resurfaced.png")
------------------------------------------------------------------ #1160
-- A stubbed pic of each size through the same placement code. trueColor is
-- set so the probe never enters the GBC palette shader.
local realPic = screen.pic
local function place(size, back)
local data = love.image.newImageData(size, size)
local image = love.graphics.newImage(data)
screen.pic = function() return image, true, "mods/front" .. size .. ".png" end
local n, x, y = probePic(screen, screen.battle.enemy, back)
screen.pic = realPic
return n, x, y
end
local _, x56, y56 = place(56, false)
print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56)))
check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)")
local _, x40, y40 = place(40, false)
print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40)))
check(x40 == 104 and y40 == 16,
"a small 40x40 front pic still stands on the box's ground line")
local _, x64, y64 = place(64, false)
print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64)))
check(x64 == 96 and y64 == 0,
"a 64x64 front pic pins to the box corner and overlaps the HUD")
if #failures > 0 then
error(#failures .. " checks failed")
end
print("[driver] #1159 / #1160 fixed; shots in " .. out)
end
+82
View File
@@ -0,0 +1,82 @@
-- #1167: the Ilex Forest hidden Ether and hidden Super Potion.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love .
local Mon = require("src.battle.gen2.Mon")
local HiddenItems = require("src.world.gen2.HiddenItems")
local CASES = {
{ name = "ETHER", x = 27, y = 1, event = 136 },
{ name = "SUPER_POTION", x = 17, y = 7, event = 137 },
{ name = "FULL_HEAL", x = 9, y = 17, event = 138 },
}
local FACE = {
up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function tap(btn)
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
coroutine.yield()
game.input.state[btn] = false
coroutine.yield()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
world:setMap("ILEX_FOREST", 9, 18, "up")
wait(30)
assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST")
-- what the engine itself thinks it has for this map
local listed = HiddenItems.unfound(world.map.def, world.events)
print("[driver] engine hidden items on ILEX_FOREST:")
for _, row in ipairs(listed) do
print((" (%2d,%2d) item=%s flag=%d")
:format(row.x, row.y, tostring(row.item), row.event))
end
local function bagCount(name)
local n = 0
for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do
if row.item == name or row.id == name then n = n + (row.count or 1) end
end
return n
end
local bad = 0
for _, c in ipairs(CASES) do
local got = false
for dir, d in pairs(FACE) do
local sx, sy = c.x + d[1], c.y + d[2]
if world.map:isWalkable(sx, sy) then
world:setMap("ILEX_FOREST", sx, sy, dir)
wait(20)
world.player.facing = dir
local before = bagCount(c.name)
tap("a")
wait(60)
for _ = 1, 240 do
if not world:busy() then break end
tap("a")
end
wait(20)
if world.events:get(c.event) or bagCount(c.name) > before then
got = true
print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s")
:format(c.name, c.x, c.y, dir))
break
end
end
end
if not got then
bad = bad + 1
print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y))
end
end
print(("[driver] %s ilex hidden items: %d missing")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+48
View File
@@ -0,0 +1,48 @@
-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love .
local U = require("tests.drivers.util")
local SaveData = require("src.core.SaveData")
local GoldSave = require("src.core.gen2.Save")
return function(game)
U.wait(45)
local save = game.save
assert(save and save.player, "gold save did not boot")
save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
STORM = true, MINERAL = true, GLACIER = true, RISING = true }
save.player.kantoBadges = { BOULDER = true, CASCADE = true }
save.pokedex = save.pokedex or {}
save.pokedex.caught = { [155] = true, [158] = true, [152] = true }
local ok, err = game:writeSave()
if not ok then
U.log("FAIL gold writeSave:", tostring(err))
else
local want = GoldSave.summary(save)
local active = SaveData.activeSlot("gold")
local row
for _, slot in ipairs(SaveData.listSlots("gold")) do
if slot.id == active then row = slot end
end
if not (row and row.meta) then
U.log("FAIL launcher has no slot row for", tostring(active))
else
U.log(("launcher: %d badges - %s - %d caught"):format(
row.meta.badges, row.meta.timeText, row.meta.dexCount))
U.log(("continue: %d badges - %d:%02d - %d caught"):format(
want.badges, want.hours, want.minutes, want.caught))
if row.meta.badges == want.badges and row.meta.dexCount == want.caught then
U.log("PASS launcher summary matches CONTINUE for slot", tostring(active))
else
U.log("FAIL launcher summary disagrees with CONTINUE")
end
end
end
while true do
coroutine.yield()
end
end
@@ -0,0 +1,58 @@
-- GameActivity exposes an optional Android host seam without requiring any
-- host implementation (or its native libraries) in the stock build.
-- Self-contained: luajit tests/engine/android_host_extension_test.lua
local path = "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java"
local file = assert(io.open(path, "rb"))
local source = file:read("*a")
file:close()
local function check(value, message)
if not value then error(message, 2) end
end
local function position(text)
local p = source:find(text, 1, true)
check(p, "missing: " .. text)
return p
end
check(source:find("protected String[] getHostLibraries()", 1, true),
"host libraries are an overridable protected extension")
check(source:find("return new String[0];", 1, true),
"the vanilla host library list is empty")
local cpp = position('libraries[0] = "c++_shared";')
local mpg = position('libraries[1] = "mpg123";')
local openal = position('libraries[2] = "openal";')
local host = position("System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);")
local love = position('libraries[libraries.length - 1] = "love";')
check(cpp < mpg and mpg < openal and openal < host and host < love,
"optional libraries load after dependencies while liblove remains last")
for _, hook in ipairs({
"onHostCreateBeforeSDL", "onHostCreateAfterSDL", "onHostResume",
"onHostPause", "onHostDestroy",
}) do
check(source:find("protected void " .. hook, 1, true),
hook .. " is a protected extension hook")
end
check(position("onHostCreateBeforeSDL(savedInstanceState);") <
position("super.onCreate(savedInstanceState);") and
position("super.onCreate(savedInstanceState);") <
position("onHostCreateAfterSDL(savedInstanceState);"),
"create hooks bracket SDL creation")
check(position("super.onResume();") < position("onHostResume();"),
"resume hook runs after SDL resumes")
check(position("onHostPause();") < position("super.onPause();"),
"pause hook runs before SDL pauses")
check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and
not source:find("QuestBridge", 1, true),
"generic Android activity must not require Quest classes")
print("android_host_extension_test: ok")
+73
View File
@@ -0,0 +1,73 @@
-- Optional native-host display lifecycle. The default path is inert; a fake
-- backend proves callback order and arguments without graphics or a ROM.
-- luajit tests/engine/host_display_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostDisplay = require("src.core.HostDisplay")
-- Vanilla: no backend, no state requirement, and no invented return value.
HostDisplay.setBackend(nil)
eq(HostDisplay.update(1 / 60), nil, "default update is a no-op")
eq(HostDisplay.beginFrame("game", {}), nil, "default beginFrame is a no-op")
eq(HostDisplay.endFrame("game", {}), nil, "default endFrame is a no-op")
-- Bad installation fails at the boundary instead of producing a later frame
-- error whose source is difficult for a native host to diagnose.
local ok, err = pcall(HostDisplay.setBackend, function() end)
check(not ok, "non-table backend is rejected")
check(tostring(err):find("table or nil", 1, true) ~= nil,
"backend type error explains the accepted shape")
local calls = {}
local subject = { tag = "launcher-instance" }
local fake = {}
function fake:update(dt)
calls[#calls + 1] = { "update", self, dt }
return "updated"
end
function fake:beginFrame(kind, gotSubject)
calls[#calls + 1] = { "begin", self, kind, gotSubject }
return "begun"
end
function fake:endFrame(kind, gotSubject)
calls[#calls + 1] = { "end", self, kind, gotSubject }
return "ended"
end
HostDisplay.setBackend(fake)
eq(HostDisplay.update(0.25), "updated", "update return is forwarded")
eq(HostDisplay.beginFrame("launcher", subject), "begun",
"beginFrame return is forwarded")
eq(HostDisplay.endFrame("launcher", subject), "ended",
"endFrame return is forwarded")
eq(#calls, 3, "each lifecycle callback fires exactly once")
eq(calls[1][1], "update", "update is first")
eq(calls[1][2], fake, "update receives the backend as self")
eq(calls[1][3], 0.25, "update receives dt")
eq(calls[2][1], "begin", "beginFrame precedes endFrame")
eq(calls[2][3], "launcher", "beginFrame receives the frame kind")
eq(calls[2][4], subject, "beginFrame receives the drawn subject")
eq(calls[3][1], "end", "endFrame is last")
eq(calls[3][3], "launcher", "endFrame receives the frame kind")
eq(calls[3][4], subject, "endFrame receives the drawn subject")
-- Every callback is optional. Replacing and clearing a backend must not retain
-- callbacks from the old host across a restart or test process.
local partialCalls = 0
HostDisplay.setBackend({
endFrame = function() partialCalls = partialCalls + 1 end,
})
eq(HostDisplay.update(1), nil, "missing optional update remains a no-op")
eq(HostDisplay.beginFrame("editor", {}), nil,
"missing optional beginFrame remains a no-op")
HostDisplay.endFrame("editor", {})
eq(partialCalls, 1, "present optional callback still runs")
HostDisplay.setBackend(nil)
HostDisplay.endFrame("game", {})
eq(partialCalls, 1, "clearing backend detaches old callbacks")
T.finish("host display")
+114
View File
@@ -0,0 +1,114 @@
-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has
-- to look like a phone; POKEPORT_TOUCH=1 is the other way in.
local realGetOS = love.system.getOS
love.system.getOS = function() return "Android" end
local LauncherSettings = require("src.import.LauncherSettings")
local TouchControls = require("src.core.TouchControls")
local function labels(model)
local out = {}
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do out[#out + 1] = row.label end
end
return out
end
local function findRow(model, label)
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do
if row.label == label then return row end
end
end
end
local function has(model, label)
for _, l in ipairs(labels(model)) do
if l == label then return true end
end
return false
end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
local red = LauncherSettings.open(hooks, "red")
check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD")
check(has(red, "VIBRATION"), "and VIBRATION")
check(has(red, "TOUCH CONTROLS"), "and the layout editor")
findRow(red, "TOUCH PAD").step(1)
eq(red.opts.touchControls.enabled, false, "writing to the flat key")
eq(red.opts.gold, nil, "with no gold block invented")
eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- With no hook (the standalone save editor's shape) the editor row is gone
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
-- game's block to write the dragged layout into: TouchControlsEditor persists
-- to opts.gold only when it was loaded with version = "gold". Source-shape
-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins
-- the handoff either side of this one.
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local importerSrc = read("src/import/RomImporter.lua")
check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil,
"the gear hands the launcher tab to the host")
local mainSrc = read("main.lua")
local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)")
eq(body, "version", "main.lua's opener takes that tab")
check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil,
"and loads the editor with it, so Gold's layout lands in the gold block")
love.system.getOS = realGetOS
T.finish("launcher gold touch rows")
@@ -0,0 +1,90 @@
-- Launcher modal focus routing. The immediate-mode focus ring is shared by
-- the main launcher and its mod dialogs, so arrows and an armed confirmation
-- must be handled before the modal key guard returns.
-- luajit tests/engine/launcher_modal_focus_input_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local function resetFocus()
Kit.focusId = "modal-primary"
Kit._navQueue = nil
Kit._activateId = nil
end
local function importer(field)
return setmetatable({ _flex = true, [field] = {} }, RomImporter)
end
local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
}
for _, field in ipairs(modalFields) do
resetFocus()
local imp = importer(field)
imp:keypressed("right")
eq(Kit._navQueue, "right", field .. " accepts focus navigation")
check(imp._ringArmed, field .. " navigation arms the focus ring")
imp:keypressed("return")
eq(Kit._activateId, "modal-primary",
field .. " activates the focused modal control")
check(imp[field] ~= nil, field .. " remains open until its button dispatches")
end
-- Enter without prior focus navigation must not invent an activation. This is
-- the vanilla compatibility contract behind the launcher's legacy Enter-to-
-- play shortcut; a modal simply absorbs that otherwise-unhandled key.
do
resetFocus()
local imp = importer("_modConfirm")
imp:keypressed("return")
eq(Kit._activateId, nil, "unarmed modal Enter does not activate focus")
check(not imp._ringArmed, "unarmed modal Enter does not arm focus")
end
-- A focused text field remains exclusive. The modal fix must not move the
-- ring when an arrow is intended for a launcher text-input state.
do
resetFocus()
local imp = setmetatable({
_flex = true,
_findSearchFocus = true,
findQuery = "PIKA",
}, RomImporter)
imp:keypressed("right")
eq(Kit._navQueue, nil, "focused search field does not navigate the ring")
check(not imp._ringArmed, "focused search field does not arm the ring")
end
-- Escape remains owned by the existing modal close path.
do
resetFocus()
local imp = importer("_findDetails")
imp:keypressed("escape")
eq(imp._findDetails, nil, "Escape closes details")
end
do
resetFocus()
local imp = importer("_modReleaseNotes")
imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end
do
resetFocus()
local imp = importer("_modVersions")
imp._modConfirm = {}
imp:keypressed("escape")
eq(imp._modConfirm, nil, "Escape closes confirmation")
eq(imp._modVersions, nil, "Escape clears versions behind confirmation")
end
resetFocus()
T.finish("launcher modal focus input")
@@ -51,7 +51,9 @@ check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
"main.lua mentions prepareOverlayHandoff + touch editor")
-- prepare must run inside openTouchControlsEditor, not only openEditor
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend")
-- The signature takes the launcher tab since #1100, so match any parameter
-- list rather than pinning the arity.
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(.-%)(.-)\nend")
check(touchOpen ~= nil, "openTouchControlsEditor body found")
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
"openTouchControlsEditor prepares overlay handoff like the save editor")
+56
View File
@@ -104,6 +104,34 @@ local function nameSet(list)
return s
end
-- Host-family compatibility is independent from the numeric shell revision.
-- Missing fields preserve the historical ordinary-LOVE defaults.
do
check(Boot.canHost({}, 1, "love"),
"canHost: legacy payload defaults to ordinary LOVE host and shell 1")
check(Boot.canHost({ payloadHost = "love", minShell = 2 }, 2, "love"),
"canHost: matching host and sufficient shell pass")
check(not Boot.canHost({ payloadHost = "love", minShell = 2 }, 1, "love"),
"canHost: newer shell requirement fails")
check(not Boot.canHost({ payloadHost = "special", minShell = 1 }, 9, "love"),
"canHost: a high shell revision cannot override a host mismatch")
check(Boot.canHost({ payloadHost = "special", minShell = 3 }, 3, "special"),
"canHost: a specialized host accepts its own payload")
check(not Boot.canHost(nil, 1, "love"),
"canHost: malformed payload metadata fails closed")
end
-- Even an older mismatched-host payload is not ours to clean up.
do
local candidates = {
{ name = "other-old.love", engine = "0.5.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, nil, "select: old payload for another host is not runnable")
eq(#del, 0, "select: old payload for another host is not deleted")
end
-- empty candidate list: nothing to run, nothing to delete
do
local chosen, del = Boot.select({}, "1.0.0", 1)
@@ -127,6 +155,34 @@ do
check(not d["b.love"], "select: the chosen payload is never deleted")
end
-- A newer payload for another native host is neither selected nor deleted.
-- It may be valid for another full package sharing this save directory.
do
local candidates = {
{ name = "other.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
{ name = "ours.love", engine = "1.5.0", minShell = 1,
payloadHost = "love" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, "ours.love", "select: chooses the matching host payload")
eq(#del, 0, "select: incompatible newer host payload is retained")
end
-- The same candidate set selects the specialized payload when the bundled
-- package identifies as that host family.
do
local candidates = {
{ name = "ordinary.love", engine = "2.1.0", minShell = 1,
payloadHost = "love" },
{ name = "special.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "special")
eq(chosen, "special.love", "select: specialized package chooses its host payload")
eq(#del, 0, "select: newer ordinary payload remains available to its host")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it
do
+5
View File
@@ -252,10 +252,15 @@ do
local outcome
screen.onDone = function(result) outcome = result end
-- Battle lines end in `prompt` and PromptButton waits on A/B with no
-- countdown (home/joypad.asm:383-412), so the drain presses.
local function drain(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" or screen.phase == "done" then return true end
end
return false
+6
View File
@@ -159,10 +159,16 @@ local function newScreen(opts)
return screen, battle, player, save, pushed
end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
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
+6
View File
@@ -157,10 +157,16 @@ local function newBattleOverWorld(inventory)
return screen, battle, world, save, pushed, player
end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
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
+2 -2
View File
@@ -1119,10 +1119,10 @@ check("seismic toss deals the level",
do
check("static damage uses move power",
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
{ power = 40 }), 40)
40), 40)
check("sonic boom fixed damage is 20",
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
{ power = 20 }), 20)
20), 20)
local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" })
boomWild.hp = 100
+39 -21
View File
@@ -262,10 +262,21 @@ local function run(screen, frames)
end
end
-- One frame of a drain. Battle lines end in `prompt` and PromptButton waits
-- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits
-- the same way (engine/battle/core.asm:7069), so a drain presses rather than
-- idling for a timer that never runs out.
local function drainStep(screen)
local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box"
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
end
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == "menu" then return true end
end
return false
@@ -366,8 +377,7 @@ do
local crawled, restarted = false, false
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then
crawled = true
end
@@ -404,8 +414,7 @@ do
local maxBefore = player.maxHp
screen:submit({ kind = "move", move = "TACKLE" })
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == "done" then break end
end
check(player.maxHp > maxBefore, "the level-up raised the maximum")
@@ -601,8 +610,7 @@ do
local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 4000) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == phase then return true end
end
return false
@@ -622,9 +630,15 @@ do
noBattle.enemy.hp = 1
local noTurn = noBattle.turn
noScreen:submit({ kind = "move", move = "TACKLE" })
check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch")
-- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the
-- offer (data/text/battle.asm:222-231): the incoming mon is named on an
-- earlier page, and only the last one carries the yes/no box (#1158).
check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch")
check((noScreen.message or ""):find("is about to use"),
"with BattleText_EnemyIsAboutToUseWillPlayerChangeMon")
check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question")
check((noScreen.message or ""):find("change POK"),
"whose last page is the one YesNoBox opens over")
-- NO falls through to the send-out with nothing switched and nothing spent.
noScreen.messageTimer = 0
@@ -1083,8 +1097,7 @@ do
-- not ring is anything from the faint onward.
local latched, rangAfterFaint, sawExpLine = false, false, false
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.lowHealthAlarmDisabled then latched = true end
if latched and siren then rangAfterFaint = true end
if latched and (screen.message or ""):find("EXP") then sawExpLine = true end
@@ -1155,7 +1168,9 @@ do
eq(screen.phase, "refuse-move", "the disabled row is refused")
eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled")
eq(wild.hp, before, "and the enemy got no free turn")
run(screen, 120)
-- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the
-- list comes back on a press, not on a timer.
tap("a")
eq(screen.phase, "moves", "the list comes back")
-- The same for a spent row.
@@ -1168,7 +1183,7 @@ do
eq(screen.message, "There's no PP left for this move!",
"with BattleText_TheresNoPPLeftForThisMove")
eq(wild.hp, before, "and still no enemy turn")
run(screen, 120)
tap("a")
eq(screen.phase, "moves", "and the list comes back again")
end
@@ -1221,8 +1236,7 @@ end
-- so the question is reached by pages rather than by one button.
local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 900) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == phase then return true end
end
return false
@@ -1236,11 +1250,15 @@ do
check(runToPhase(screen, "ask-forget"), "and its pages run into the question")
eq(screen.message, "move to make room\nfor EMBER?",
"whose last page is the one YesNoBox opens over")
run(screen, 60)
local tap = tapper(screen)
-- The question's own `prompt` is read first and YesNoBox opens after it,
-- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the
-- answer is the second press, not the first.
tap("a")
eq(screen.phase, "ask-forget", "the last page holds until it is read")
tap("b")
eq(screen.phase, "stop-learning", "NO there asks whether to stop learning")
run(screen, 60)
tap("a")
tap("b")
eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask")
check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit")
@@ -1251,8 +1269,8 @@ end
do
local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen)
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "YES opens the picker")
tap("a") -- slot 1
@@ -1263,9 +1281,9 @@ end
do
local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen)
tap("a")
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "the picker is up")
screen.forgetIndex = 4
tap("a")
@@ -1273,7 +1291,7 @@ do
eq(screen.message, "HM moves can't be\nforgotten now.",
"with MoveCantForgetHMText")
eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1")
run(screen, 60)
tap("a")
eq(screen.phase, "choose-forget", "the picker is still up after the line")
eq(lead.moves[4].id, "SURF", "and SURF is still there")
end
+10 -5
View File
@@ -356,11 +356,14 @@ local optionsGame, optionsInput = newGame(Save.newGame())
local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- The cart's seven value rows, then the port's audio, speed and display
-- rows, then CANCEL -- which is what makes this screen scroll.
check("sixteen rows", #OptionsMenu.ROWS, 16)
-- The cart's seven value rows, then CONTROLS, the port's audio, speed and
-- display rows, the three touch rows and CANCEL -- which is what makes this
-- screen scroll. The touch three are gated to mobile by buildRows; ROWS
-- itself carries every descriptor.
check("twenty rows", #OptionsMenu.ROWS, 20)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
check("starts on TEXT SPEED", options:row().key, "textSpeed")
check("default text speed", options.options.textSpeed, "MID")
@@ -401,7 +404,9 @@ local exiting = OptionsMenu.new(exitGame, {
options = Save.defaultOptions(),
onDone = function(o) savedOptions = o end,
})
exiting.index = #OptionsMenu.ROWS
-- The screen's own rows, not ROWS: buildRows drops the touch three off a
-- desktop, so the raw descriptor count overshoots CANCEL.
exiting.index = #exiting.rows
exitInput:press("a")
exiting:update(0)
check("CANCEL leaves", savedOptions ~= nil, true)
+5 -2
View File
@@ -17,14 +17,17 @@ local Nests = require("src.core.gen2.Nests")
-- Landmark indices from constants/landmark_constants.asm: Johto runs below
-- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28.
local DATA = {
maps = {
-- gen2Maps, matching Game2's key for the Gold map table.
gen2Maps = {
ROUTE_29 = { landmark = 2 }, -- Johto
ILEX_FOREST = { landmark = 11 }, -- Johto
ROUTE_1 = { landmark = 0x2f }, -- Kanto
UNION_CAVE_1F = { landmark = 9 }, -- Johto
NOWHERE = { }, -- no landmark at all
},
encounters = {
-- Game2 loads the encounter tables under gen2Encounters, not `encounters`:
-- the flat name is Gen 1's and Gen2Compat only maps it for mods.
gen2Encounters = {
grass = {
ROUTE_29 = { slots = {
MORN = { { species = "PIDGEY" }, { species = "SENTRET" } },
+8 -5
View File
@@ -417,22 +417,25 @@ check("only the written byte is stored", memCount, 1)
local _, _, _, loadReport = Save.load("gold")
check("load reports a clean save", Save.emptyReport(loadReport), true)
-- A second write backs the first up, so the previous file is always recoverable.
-- A second write backs the first up, so the previous file is always
-- recoverable. The file lives at saves/<version>/<slot>.lua since Gold grew
-- launcher slots (#1107); the flat save_gold.lua is only the migration source.
local SLOT = "saves/gold/slot1.lua"
written.player.money = 5555
Save.save(written)
check("backup written", files["save_gold.lua.bak"] ~= nil, true)
check("backup written", files[SLOT .. ".bak"] ~= nil, true)
check("new value loads", Save.load("gold").player.money, 5555)
-- A corrupt main file falls back to the backup rather than losing the game.
files["save_gold.lua"] = "this is not a lua table"
files[SLOT] = "this is not a lua table"
local recoveredSave, how = Save.load("gold")
check("recovered from the backup", recoveredSave ~= nil, true)
check("recovery reported", how, "bak")
check("backup held the previous money", recoveredSave.player.money, 4321)
-- A staged .tmp is preferred over the backup: it is the newer of the two.
files["save_gold.lua"] = nil
files["save_gold.lua.tmp"] = files["save_gold.lua.bak"]
files[SLOT] = nil
files[SLOT .. ".tmp"] = files[SLOT .. ".bak"]
local staged, stagedHow = Save.load("gold")
check("recovered from the staged copy", staged ~= nil, true)
check("staged recovery reported", stagedHow, "tmp")
+2 -1
View File
@@ -3315,7 +3315,8 @@ local function grassLatchChecks()
check(hp.jumping, "and it is a jump")
check(not hp.grassShake, "no rustle spawns for the airborne cells")
check(not hp.inGrass, "and IN_GRASS is clear for the whole hop")
for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end
-- A hop clears two cells, so it runs two step-times, not one (#1165).
for _ = 1, PlayerMod.STEP_FRAMES * 2 + 1 do hopGrass:step() end
check(hp.inGrass, "the landing tile latches it once the hop ends")
end
grassLatchChecks()
@@ -48,10 +48,15 @@ function fs.load(path)
end
function fs.getDirectoryItems(path)
local items = {}
-- -print plus a basename in Lua, not -printf: that is a GNU extension and
-- BSD find (macOS) fails the whole call, which silently emptied the listing
-- and left the probe mod undiscovered.
local pipe = io.popen("find " .. quote(full(path))
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
.. " -mindepth 1 -maxdepth 1 -print 2>/dev/null")
if pipe then
for item in pipe:lines() do items[#items + 1] = item end
for item in pipe:lines() do
items[#items + 1] = item:match("[^/]+$") or item
end
pipe:close()
end
table.sort(items)
+12 -3
View File
@@ -21,6 +21,7 @@ local Net = require("src.link.Net")
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Session = require("src.link.Session")
local S = require("tests.harness").suite("mod link")
local check, eq = S.check, S.eq
@@ -529,11 +530,19 @@ local function linkGame(name, species, data)
return { data = data or Data, save = save, stack = stack, input = mkInput() }
end
-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua
-- :75 wraps every backend the same way), so a loopback end has to be wrapped
-- here too or :update reaches for a getStatus the transport does not have.
local function linkSession(transport, role)
return Session.new(transport, { role = role, kind = "link" })
end
-- two paired states, host already listening and guest already dialling
local function pairStates(gameA, gameB)
local netA, netB = Net.loopbackPair()
local host, guest = LinkState.new(gameA), LinkState.new(gameB)
host.net, guest.net = netA, netB
host.net = linkSession(netA, "host")
guest.net = linkSession(netB, "guest")
host.stage, guest.stage = "hosting", "joining"
gameA.stack:push(host)
gameB.stack:push(guest)
@@ -573,7 +582,7 @@ check(host.trade.strict, "a v2 verdict unpacks strictly")
local gameOld = linkGame("RED", "PIDGEY")
local oldNet, peerNet = Net.loopbackPair()
local v1guest = LinkState.new(gameOld)
v1guest.net = oldNet
v1guest.net = linkSession(oldNet, "guest")
v1guest.stage = "joining"
gameOld.stack:push(v1guest)
v1guest:update(1 / 60)
@@ -591,7 +600,7 @@ check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")
local gameLone = linkGame("RED", "PIDGEY")
local loneNet, silentNet = Net.loopbackPair()
local v1host = LinkState.new(gameLone)
v1host.net = loneNet
v1host.net = linkSession(loneNet, "host")
v1host.stage = "hosting"
gameLone.stack:push(v1host)
v1host:update(1 / 60)
@@ -207,6 +207,11 @@ if type(storage) == "table" then
T.check(type(normalBytes) == "string" and normalBytes ~= "",
"first checkpoint anchor is durably represented before restart")
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
-- Model a durable checkpoint captured by the previous shipped engine.
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime state.
checkpoint.identity.engineVersion = "0.1.79"
SaveData.resetSlotState()
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
titleRuntime.save.options = { volume = 9, bindings = {} }
@@ -224,7 +229,14 @@ if type(storage) == "table" then
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint,
local recaptured = checkpoints:capture(titleRuntime)
T.eq(recaptured and recaptured.identity
and recaptured.identity.engineVersion, Version.engine,
"cross-version resume recaptures the running engine version")
if recaptured and recaptured.identity then
recaptured.identity.engineVersion = checkpoint.identity.engineVersion
end
T.same(recaptured, checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"a successfully verified title resume emits checkpoint.restored exactly once")
+16
View File
@@ -64,6 +64,17 @@ local function freshGame(mapX, mapY)
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
-- OverworldState is a singleton and :enter does not clear the movement
-- latches, so a scenario that runs after a few thousand other checks can
-- inherit a half-finished script and never walk. Reset them here: this
-- suite asserts a frame budget, so it has to start from a known state.
OverworldState.scriptMoves = {}
OverworldState.pendingScripts = {}
OverworldState.emote = nil
OverworldState.engaging = false
OverworldState.teleportOut = nil
OverworldState.transitioning = nil
OverworldState.wildEncounterGraceSteps = 0
StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up")
Game.overworld = OverworldState
return pressed
@@ -114,6 +125,11 @@ end
-- keeps running straight into the lab.
-- =====================================================================
scenario(function()
-- The trigger tile is in Pallet's north grass, so the escort walk rolls for
-- wild encounters as it goes. Pin the stream: run standalone this suite got
-- one draw sequence and run inside tests/run_tests.lua another, and one of
-- them dropped a battle on top of the escort and ate the frame budget.
math.randomseed(require("tests.harness").SEED)
GameVersion.set("red")
local pressed = freshGame(8, 2)
local played = {}
+12 -4
View File
@@ -2788,15 +2788,23 @@ do
press("down")
eq(om.index, 25, "cursor reaches CONTROLS")
press("down")
eq(om.index, 26, "CANCEL stays the fixed final row")
eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen")
eq(om.index, 26, "cursor reaches DATE FORMAT")
press("down")
eq(om.index, 27, "cursor reaches TIME FORMAT")
press("down")
-- CANCEL is appended after the descriptor list rather than living in it, so
-- it lands one past #rows and the window holds the last six boxes. Counted
-- off #rows so the next row added here is not read as a wrap bug.
local cancelRow = #om.rows + 1
eq(om.index, cancelRow, "CANCEL stays the fixed final row")
eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 26, "up from the top wraps to CANCEL")
eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, cancelRow, "up from the top wraps to CANCEL")
eq(om2.scroll, cancelRow - 5, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)