Fix Gen 1/2 battle, menu, PC and audio parity gaps; wire luacheck into CI

CLOSES #1484, CLOSES #1486, CLOSES #1513, CLOSES #1517, CLOSES #1556, CLOSES #1564, CLOSES #1567
This commit is contained in:
bryanthaboi
2026-08-21 19:22:34 -04:00
parent 087a275189
commit c0f4315cd8
45 changed files with 2487 additions and 168 deletions
+43 -7
View File
@@ -83,15 +83,36 @@ return function(game)
-- the announcement-time row performMove inserts directly as well as the
-- ones animNext adds, which is the whole difference the fix makes.
local function watchAnims(battle)
local seen, mark = {}, {}
local seen, mark, types = {}, {}, {}
return seen, function()
for _, row in ipairs(battle.queue) do
if row.anim and not mark[row] then
mark[row] = true
seen[#seen + 1] = row.anim
types[row.anim] = row.hit and row.hit.animType or false
end
end
end, types
end
-- engine/battle/animations.asm:427-437
local function rideAndSample(battle, poll, frames, shotPath)
local peak, shot = 0, false
for i = 1, frames do
poll()
if i % 6 == 0 then U.tap(battle.game, "a") end
local fx = battle.fx
if fx and fx.shakeX and not battle.animPlaying then
local dx = math.abs(fx.shakeX)
if dx > peak then peak = dx end
if dx > 0 and not shot then
shot = true
U.shot(battle.game, shotPath)
end
end
U.wait(1)
end
return peak
end
local function listOf(seen)
@@ -136,23 +157,28 @@ return function(game)
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local seen, poll = watchAnims(battle)
local seen, poll, types = watchAnims(battle)
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
check("BIDE is the move on the list", pickBide(battle, poll))
for _ = 1, 20 do poll() U.wait(1) end
U.shot(game, DIR .. "/bug375_store_spiral.png")
for _ = 1, 30 do poll() U.wait(1) end
local peak = rideAndSample(battle, poll, 180,
DIR .. "/bug1564_store_shake.png")
U.shot(game, DIR .. "/bug375_store_text.png")
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
battle.player.bideTurns ~= nil)
check("the storing spiral carries wAnimationType 6 (#1564)",
types["XSTATITEM_ANIM"] == 6)
check(("the screen shook, peak dx = %d px (want 3)"):format(peak), peak == 3)
-- ride out the locked turns: the storing text needs A presses and the menu
-- never comes back until BIDE unleashes
local released = false
for _ = 1, 120 do
for _ = 1, 220 do
U.tap(game, "a")
for _ = 1, 6 do poll() U.wait(1) end
if has(seen, "BIDE") then
@@ -179,7 +205,7 @@ return function(game)
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
ow:pushBattle(foeBattle)
local foeSeen, foePoll = watchAnims(foeBattle)
local foeSeen, foePoll, foeTypes = watchAnims(foeBattle)
check("the foe's battle reached the menu",
waitPhase(foeBattle, "menu", 60, foePoll))
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
@@ -192,10 +218,20 @@ return function(game)
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
local foePeak = rideAndSample(foeBattle, foePoll, 180,
DIR .. "/bug1564_enemy_store_shake.png")
check("the foe's spiral carries wAnimationType 3 (#1564)",
foeTypes["XSTATITEM_DUPLICATE_ANIM"] == 3)
check(("the foe's shake peaked at %d px (want 6)"):format(foePeak),
foePeak == 6)
U.log("The pad is yours in a battle where both sides know only BIDE, so")
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
U.log("white and balls spiral inward, silently, and then it says storing")
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
U.log("white and balls spiral inward, silently. Then, still in silence, the")
U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and")
U.log("back, twice, and only then does it say storing energy. On the foe's")
U.log("turn the same creep goes out to 6 px and takes twice as long.")
U.log("No flash on the RATTATA and no BIDE thud until the turn it")
U.log("unleashes, where the thud and its animation come after the text and")
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
@@ -0,0 +1,101 @@
-- scripts/Route16Gate1F.asm:38 / home/overworld.asm:1224
-- data/maps/force_bike_surf.asm:5
-- POKEPORT_DRIVER=tests/drivers/bike_gate_wall_bug1548_test.lua \
-- POKEPORT_IDENTITY=bug1548 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Watch the sprite after each teleport: it must never overlap the gate
-- building's black outline. Compare .bazinga/august21p2/media/1548-1.jpg.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local OverworldState = require("src.world.OverworldController")
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
local function settle()
for _ = 1, 12 do
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(25)
end
U.wait(60)
end
local function landed()
local ow = game.overworld
local p = ow.player
return p.cellX, p.cellY, ow.map:isWalkableCell(p.cellX, p.cellY)
end
local function bikeless()
game.save.inventory.BICYCLE = nil
game.save.onBike, game.save.forcedBike = false, nil
end
game.save.player.name = "PROBE"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
bikeless()
local fm = game.data.field.forcedMovement
check("field.forcedMovement carries the Route 16 cells",
fm and fm.tiles and fm.tiles.ROUTE_16 ~= nil)
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
bikeless()
U.teleport(game, c[1], c[2], c[3], "left")
U.wait(20)
settle()
local x, y, walk = landed()
U.log(("%s (%d,%d) facing left -> (%d,%d) walkable=%s")
:format(c[1], c[2], c[3], x, y, tostring(walk)))
check(("%s (%d,%d): the refusal leaves the player on a walkable cell")
:format(c[1], c[2], c[3]), walk)
end
bikeless()
U.teleport(game, "ROUTE_16", 17, 10, "left")
U.wait(20)
settle()
U.shot(game, SHOT_DIR .. "/bug1548_route16_after.png")
bikeless()
U.teleport(game, "ROUTE_16", 16, 10, "right")
U.wait(20)
U.hold(game, "right", 24)
settle()
local bx, by = landed()
U.log(("stepped onto ROUTE_16 (17,10) from the west -> (%d,%d)"):format(bx, by))
check("a shove with open ground behind it still moves one cell",
bx == 16 and by == 10)
bikeless()
while game.stack:top() do game.stack:pop() end
game.stack:push(OverworldState, "ROUTE_16", 18, 10, "left")
game.overworld:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
U.wait(20)
local cx, cy, cwalk = landed()
U.log(("boot inside the wall (18,10) -> (%d,%d) walkable=%s")
:format(cx, cy, tostring(cwalk)))
check("a save parked inside the gate wall is lifted out on load", cwalk)
settle()
local dx, dy, dwalk = landed()
U.log(("after the refusal that follows -> (%d,%d) walkable=%s")
:format(dx, dy, tostring(dwalk)))
check("and the refusal it lands on cannot push it back in", dwalk)
U.shot(game, SHOT_DIR .. "/bug1548_repaired.png")
game.save.inventory.BICYCLE = 1
game.save.onBike, game.save.forcedBike = false, nil
U.teleport(game, "ROUTE_16", 17, 10, "left")
U.wait(30)
local ex, ey, ewalk = landed()
check("with a BICYCLE the forced tile mounts instead of shoving",
game.save.onBike == true and ex == 17 and ey == 10 and ewalk)
U.log(ok and "all clear" or "a check failed")
while true do coroutine.yield() end
end
@@ -0,0 +1,46 @@
-- Driver: #1517's other half, the error handler that hid the crash.
--
-- boot.lua's deferErrhand picks `love.errorhandler or love.errhand`, so a
-- love.errorhandler that returns nil ends love.run's `while func do` loop and
-- the process leaves to the OS with no error screen and nothing on stdout.
-- LOVE 11.5 pre-populates love.errhand only, so main.lua capturing
-- love.errorhandler captured nil and every Lua error in every shipped build
-- exited silently. Measured on the installed 11.5:
-- love.errorhandler type: nil / love.errhand type: function
--
-- Nothing here can be a unit test: the deliverable is whether a human sees
-- LOVE's blue error screen or an app that vanishes.
--
-- POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love .
--
-- Pre-fix: the window closes on the first drawn frame after the probe arms,
-- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler
-- probe" plus the lua-error.log hint, and stays up until it is dismissed.
return function(game)
local U = dofile("tests/drivers/util.lua")
U.log("[1517] love.errorhandler at boot was: "
.. (rawget(love, "errorhandler") and "a function" or "nil"))
U.log("[1517] love.errhand is: " .. type(rawget(love, "errhand")))
U.wait(30)
-- Raise from a draw callback, not from this coroutine: main.lua resumes the
-- driver under coroutine.resume and prints its errors itself, which is the
-- one path that does NOT reach love.errorhandler. StateStack:draw only
-- calls the states it is actually holding, so the probe goes on the live
-- top state rather than on game.overworld, which is not on the stack while
-- the title screen or the load menu is up.
local target = game.stack:top()
U.log("[1517] arming the probe on " .. tostring(target and target.screenId))
local realDraw = target.draw
target.draw = function(self, ...)
if realDraw then realDraw(self, ...) end
error("bug1517: error-handler probe")
end
U.log("[1517] armed; the next drawn frame raises from love.draw")
U.log("[1517] you should now see LOVE's blue error screen, not a closed app")
while true do coroutine.yield() end
end
+8 -5
View File
@@ -70,15 +70,18 @@ return function(game)
U.tap(game, "right")
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
-- SELECT armed the row here for #1427; since #1567 it is filtered out of the
-- TM/HM pocket entirely (engine/items/tmhm.asm:207), so these taps must leave
-- the list exactly as shot 03 has it: no hollow ▷, no "Where should this be
-- moved to?", no reorder.
U.tap(game, "down")
U.tap(game, "select")
shot("04-tmhm-armed")
shot("04-tmhm-select-ignored")
U.tap(game, "up")
shot("05-tmhm-destination")
shot("05-tmhm-still-numbered")
U.tap(game, "a")
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
U.tap(game, "b")
shot("06-tmhm-unchanged")
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
+112
View File
@@ -0,0 +1,112 @@
-- Assertion driver: a KEY ITEM and an HM through the player's item PC.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1486_test.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- Issue #1486. What the unit tier cannot show: the two screens a player
-- actually looks at. .DepositItem (engine/events/pokecenter_pc.asm:504) reads
-- CANT_TOSS only to force x1 and skip .AskQuantity, and PlaceMenuItemQuantity
-- (engine/menus/menu_2.asm:18) draws no ×NN under such a row.
--
-- Shots land in /tmp/gold-bug1486:
--
-- 01-key-items-pocket.png the DEPOSIT chooser on the KEY ITEMS pocket
-- 02-deposited.png "Deposited 1 BICYCLE(S)." -- before the fix this
-- is the unchanged pocket with no message at all
-- 03-pc-list.png the PC list: POTION with its ×03, BICYCLE and
-- HM01 with no ×NN at all
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1486"
local fails = 0
local function ok(cond, msg)
if cond then print("[1486] ok " .. msg)
else fails = fails + 1 print("[1486] FAIL " .. msg) end
return cond
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 4)
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
local Bag = require("src.inventory.Bag")
local Mon = require("src.battle.gen2.Mon")
save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
save.inventory = {}
save.pcItems = { POTION = 3 }
assert(Bag.add(save, "BICYCLE", 1, game.data), "BICYCLE into the bag")
assert(Bag.add(save, "HM_CUT", 1, game.data), "HM01 into the bag")
local function topId()
local top = game.stack:top()
return top and top.screenId or nil
end
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"),
"setMap CHERRYGROVE_POKECENTER_1F failed")
U.wait(5)
local pcX, pcY
for cy = 0, w.map.heightCells - 1 do
for cx = 0, w.map.widthCells - 1 do
if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end
end
end
assert(pcX, "no COLL_PC tile in the Pokecenter")
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"),
"setMap onto the PC tile failed")
U.wait(5)
tap("a", 8) -- PCScript
tap("a", 4) -- the turn-on line
tap("down", 4)
tap("a", 4) -- <PLAYER>'s PC
tap("a", 4)
tap("a", 6) -- both PokecenterPlayersPCText pages
ok(topId() == "Gen2ItemPcMenu",
"<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")")
-- DEPOSIT ITEM -> the PACK chooser -> the KEY ITEMS pocket.
tap("down", 4)
tap("a", 6)
tap("right", 4) -- ITEM -> BALL
tap("right", 4) -- BALL -> KEY ITEMS
U.shot(game, out .. "/01-key-items-pocket.png")
tap("a", 6) -- the BICYCLE row: x1, no prompt
ok(save.pcItems.BICYCLE == 1,
"the BICYCLE landed in the PC (" .. tostring(save.pcItems.BICYCLE) .. ")")
ok(save.inventory.BICYCLE == nil,
"and left the bag (" .. tostring(save.inventory.BICYCLE) .. ")")
U.shot(game, out .. "/02-deposited.png")
tap("a", 4) -- the Deposited line
-- The same, one pocket over: an HM.
tap("right", 4) -- KEY ITEMS -> TM/HM
tap("a", 6)
ok(save.pcItems.HM_CUT == 1,
"HM01 landed in the PC (" .. tostring(save.pcItems.HM_CUT) .. ")")
ok(save.inventory.HM_CUT == nil,
"and left the bag (" .. tostring(save.inventory.HM_CUT) .. ")")
tap("a", 4)
tap("b", 6) -- close the PACK
-- WITHDRAW ITEM: the list is where PlaceMenuItemQuantity shows.
tap("up", 4)
tap("a", 6)
U.shot(game, out .. "/03-pc-list.png")
tap("a", 6) -- the first row, x1 or the selector
print(("[1486] %d failures"):format(fails))
love.event.quit(fails == 0 and 0 or 1)
end
+142
View File
@@ -0,0 +1,142 @@
-- #1556, the two battle jingles: _NewDexDataText's sound_slot_machine_start
-- (data/text/common_3.asm:285) on a first catch, and _LearnedMoveText's
-- sound_dex_fanfare_50_79 (data/text/common_3.asm:119) on a level-up move.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_battle.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-battle (default)
--
-- Both ride the message queue's `sfx` + `waitSfx` keys, which the "Gotcha!"
-- line and the "grew to level" line already used -- the two lines next door
-- to them did not. So the ear is comparing neighbours: the catch jingle must
-- be followed by a SECOND, different one over the #DEX line, and the level-up
-- fanfare by a second over "learned".
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-battle"
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
local function reset() heard = {} end
local function report(label, want)
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
U.log(" want:", want)
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local save, data = game.save, game.data
local function battleScreen()
local top = game.stack:top()
return (top and top.battle) and top or nil
end
local function tapUntil(predicate, tries, btn)
for _ = 1, tries or 400 do
if predicate() then return true end
U.tap(game, btn or "a")
U.wait(2)
end
return predicate()
end
-- ---- 1. a first catch: "Gotcha!" then the #DEX line ---------------------
save.party = { Mon.new(data, "CYNDAQUIL", 20) }
save.inventory = { MASTER_BALL = 1 }
save.pokedexReceived = true
save.pokedex = { seen = {}, caught = {} }
local wild = Mon.new(data, "PIDGEY", 3)
assert(wild, "the cache carries no PIDGEY")
assert(world:startBattle({ wild = wild }), "the wild battle refused to start")
assert(tapUntil(function()
local screen = battleScreen()
return screen ~= nil and screen.phase == "menu"
end), "the battle never reached BattleMenu")
local screen = battleScreen()
-- BattleMenuHeader's 2x2 grid: 1 FIGHT / 2 PkMn, 3 PACK / 4 RUN.
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
if screen.menuIndex <= 2 then U.tap(game, "down") U.wait(3) end
U.tap(game, "a")
U.wait(6)
local pack = game.stack:top()
assert(pack and pack.rows, "the battle PACK did not open")
local ballRow
for index, row in ipairs(pack.rows) do
if row.id == "MASTER_BALL" then ballRow = index end
end
assert(ballRow, "the battle PACK does not show the MASTER BALL")
for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end
reset()
U.tap(game, "a")
U.wait(180) -- the throw, the wobbles, "Gotcha!"
report("01 catch:",
"Sfx_CaughtMon, then Sfx_SlotMachineStart over the #DEX line")
U.shot(game, out .. "/01-caught.png")
for _ = 1, 6 do U.tap(game, "a") U.wait(20) end
U.shot(game, out .. "/02-dex-line.png")
for _ = 1, 30 do
if not battleScreen() then break end
U.tap(game, "a")
U.wait(10)
end
-- ---- 2. a level-up that teaches a move ---------------------------------
local SPECIES = "CYNDAQUIL"
local def = data.pokemon and data.pokemon[SPECIES]
local target
for _, entry in ipairs((def and def.levelMoves) or {}) do
if entry.level and entry.level > 6 and not target then target = entry.level end
end
if not target then
U.log("SKIP 02 -- no level-up move row for " .. SPECIES)
else
local hero = Mon.new(data, SPECIES, target - 1)
local growth = Mon.growthFor(data, def.growthRate)
hero.experience = Mon.experienceForLevel(growth, target) - 1
-- Only three moves, so LearnMove takes the free slot rather than ForgetMove
-- (engine/pokemon/learn.asm:23-29).
while hero.moves and #hero.moves > 3 do table.remove(hero.moves) end
save.party = { hero }
save.inventory = {}
U.log(("levelling %s %d -> %d for its %s row")
:format(SPECIES, hero.level, target, tostring(target)))
local prey = Mon.new(data, "PIDGEY", 2)
prey.hp = 1
assert(world:startBattle({ wild = prey }), "the second battle refused")
assert(tapUntil(function()
local s = battleScreen()
return s ~= nil and s.phase == "menu"
end), "the second battle never reached BattleMenu")
screen = battleScreen()
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
if screen.menuIndex > 2 then U.tap(game, "up") U.wait(3) end
reset()
U.tap(game, "a") -- FIGHT
U.wait(6)
U.tap(game, "a") -- the first move
U.wait(240)
report("02 level-up move:",
"Sfx_DexFanfare5079 twice: \"grew to level\" and \"learned\"")
U.shot(game, out .. "/03-level-up.png")
for _ = 1, 8 do U.tap(game, "a") U.wait(30) end
U.shot(game, out .. "/04-learned.png")
end
Sound.play = realPlay
U.log("done -- the controls are yours")
end
+196
View File
@@ -0,0 +1,196 @@
-- #1556: Bill's PC -- the deposit / withdraw / release cry, and the six
-- SFX_WRONG refusals (engine/pokemon/bills_pc.asm).
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_billspc.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-billspc (default)
--
-- Eight moments, each seeded, driven through the real screen and screenshotted.
-- The log names what sounded; the ear is the point. DepositPokemon and
-- TryWithdrawPokemon both `call PlayMonCry` right after RemoveMonFromPartyOrBox
-- (:1762 and :1817), and PlayMonCry itself bails on an EGG
-- (home/pokemon.asm:113), so a boxed egg must move in silence.
--
-- The run ends with the PC screen open so a human takes the controls there.
local U = require("tests.drivers.util")
local Boxes = require("src.core.gen2.Boxes")
local Mon = require("src.battle.gen2.Mon")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-billspc"
local heard = {}
local realPlay, realCry = Sound.play, Sound.playCry
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
Sound.playCry = function(data, species, clip)
heard[#heard + 1] = "cry:" .. tostring(species)
return realCry(data, species, clip)
end
local function reset() heard = {} end
local function report(label, want)
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
U.log(" want:", want)
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
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save = game.save
local data = game.data
local function mon(species, level)
return Mon.new(data, species, level)
end
local function openBox(mode)
Screens.push(game, "Gen2BoxMenu", {
save = save, mode = mode,
onClose = function() game.stack:pop() end,
})
U.wait(8)
return game.stack:top()
end
local function close()
while game.stack:top() and game.stack:top().submenuRows do
game.stack:pop()
end
U.wait(4)
end
local function fillBox(index, n, species)
local box = Boxes.box(save, index)
for i = #box, 1, -1 do box[i] = nil end
for _ = 1, n do box[#box + 1] = mon(species or "RATTATA", 5) end
end
local function setParty(n, species)
save.party = {}
for _ = 1, n do save.party[#save.party + 1] = mon(species or "CYNDAQUIL", 10) end
end
save.currentBox = 1
-- ---- 1. WITHDRAW a mon: its cry ------------------------------------------
setParty(1)
fillBox(1, 1, "PIDGEY")
local menu = openBox("withdraw")
reset()
tap("a") -- the submenu
tap("a", 12) -- WITHDRAW
report("01 withdraw:", "cry:PIDGEY")
U.shot(game, out .. "/01-withdraw-cry.png")
tap("a") -- clear anything on screen
close()
-- ---- 2. DEPOSIT it back: the cry again -----------------------------------
setParty(3)
save.party[#save.party + 1] = mon("PIDGEY", 5)
fillBox(1, 0)
menu = openBox("deposit")
for _ = 1, 3 do tap("down") end
reset()
tap("a")
tap("a", 12) -- DEPOSIT
report("02 deposit:", "cry:PIDGEY")
U.shot(game, out .. "/02-deposit-cry.png")
tap("a")
close()
-- ---- 3. WITHDRAW into a full party: SFX_WRONG (:1845) --------------------
setParty(Boxes.PARTY_SIZE)
fillBox(1, 1, "PIDGEY")
menu = openBox("withdraw")
reset()
tap("a")
tap("a", 12)
report("03 withdraw, party full:", "Sfx_Wrong + \"You can't take any more\"")
U.shot(game, out .. "/03-party-full.png")
tap("a")
close()
-- ---- 4. DEPOSIT into a full box: SFX_WRONG (:1790) ----------------------
setParty(3)
fillBox(1, Boxes.MONS_PER_BOX)
menu = openBox("deposit")
reset()
tap("a")
tap("a", 12)
report("04 deposit, box full:", "Sfx_Wrong + \"The BOX is full.\"")
U.shot(game, out .. "/04-box-full.png")
tap("a")
close()
-- ---- 5. DEPOSIT the last healthy mon: SFX_WRONG -------------------------
setParty(2)
save.party[2].hp = 0
fillBox(1, 0)
menu = openBox("deposit")
reset()
tap("a")
tap("a", 12)
report("05 deposit, last healthy:", "Sfx_Wrong + \"can't deposit the last\"")
U.shot(game, out .. "/05-last-mon.png")
tap("a")
close()
-- ---- 6. RELEASE a boxed mon: its cry (:1866) ----------------------------
setParty(3)
fillBox(1, 1, "SENTRET")
menu = openBox("withdraw")
reset()
tap("select", 8) -- RELEASE asks first
tap("up") -- the YES/NO box defaults to NO
tap("a", 14)
report("06 release:", "cry:SENTRET before \"was released.\"")
U.shot(game, out .. "/06-release-cry.png")
tap("a")
close()
-- ---- 7. RELEASE an EGG: SFX_WRONG, no cry (:1625) -----------------------
setParty(3)
fillBox(1, 0)
local egg = mon("TOGEPI", 5)
egg.isEgg = true
Boxes.box(save, 1)[1] = egg
menu = openBox("withdraw")
reset()
tap("select", 10)
report("07 release an EGG:", "Sfx_Wrong, and NO cry:TOGEPI")
U.shot(game, out .. "/07-release-egg.png")
tap("a")
close()
-- ---- 8. MOVE into a full box: SFX_WRONG (:1567) -------------------------
setParty(3)
fillBox(1, 1, "HOOTHOOT")
fillBox(2, Boxes.MONS_PER_BOX)
menu = openBox("move")
reset()
tap("a") -- the MOVE/STATS/CANCEL submenu
tap("a", 8) -- MOVE -> the insert cursor
tap("right", 8) -- box 2, which is full
tap("a", 10)
report("08 move into a full box:", "Sfx_Wrong + \"There's no room!\"")
U.shot(game, out .. "/08-no-room.png")
Sound.play, Sound.playCry = realPlay, realCry
U.log("done -- the PC is open; the controls are yours")
end
+203
View File
@@ -0,0 +1,203 @@
-- #1556: the bump sound, which the Gen 2 port never wired.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_bump.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-bump (default)
--
-- Nothing here can be asserted by a unit test: the deliverable is the sound.
-- What the log gives a reader is the RATE, which is the half that is easy to
-- get wrong -- .BumpSound is `call CheckSFX / ret c`
-- (engine/overworld/player_movement.asm:771), a busy-channel test rather than
-- a frame counter, so a held direction re-rings only once the previous Sfx_Bump
-- has stopped. A per-frame count means Sound.sfxBusy() is not seeing it.
--
-- Five moments, in the cart's own terms:
-- 1. a wall .CheckLandPerms carry -> .bump (:264-265)
-- 2. an NPC .CheckNPC = 0 -> .bump (:267-269)
-- 3. a ledge hop .TryJump's carry returns ABOVE .NotMoving (:80-81),
-- so the hop is Sfx_JumpOverLedge and NO bump
-- 4. a map connection the step is taken; silence
-- 5. a map edge with no connection behind it: the border block is a wall
--
-- The run ends in the overworld so a human takes the controls where it stops.
local U = require("tests.drivers.util")
local Permissions = require("src.world.gen2.Permissions")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-bump"
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
local function reset() heard = {} end
local function report(label)
local counts, order = {}, {}
for _, name in ipairs(heard) do
if not counts[name] then order[#order + 1] = name end
counts[name] = (counts[name] or 0) + 1
end
local parts = {}
for _, name in ipairs(order) do
parts[#parts + 1] = ("%s x%d"):format(name, counts[name])
end
U.log(label, #parts > 0 and table.concat(parts, ", ") or "(silence)")
return counts
end
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local function walkable(map, x, y)
return map:inBounds(x, y) and map:isWalkable(x, y)
end
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
-- A standing cell whose neighbour in `dir` is a wall.
local function findWall(map)
for y = 0, map.heightCells - 1 do
for x = 0, map.widthCells - 1 do
if walkable(map, x, y) then
for dir, d in pairs(DELTA) do
local nx, ny = x + d[1], y + d[2]
if map:inBounds(nx, ny) and not map:isWalkable(nx, ny) then
return x, y, dir
end
end
end
end
end
end
-- ---- 1. a wall -----------------------------------------------------------
assert(w:setMap("PLAYERS_HOUSE_1F", 4, 4, "down"), "setMap PLAYERS_HOUSE_1F")
U.wait(5)
local wx, wy, wdir = findWall(w.map)
if wx then
assert(w:setMap("PLAYERS_HOUSE_1F", wx, wy, wdir))
U.wait(8)
reset()
U.hold(game, wdir, 90)
U.wait(5)
local counts = report("01 wall (" .. wdir .. ", 90 frames held):")
U.log(" Sfx_Bump over 90 held frames:", counts.Sfx_Bump or 0,
"-- a per-frame count here means the CheckSFX gate is not working")
U.shot(game, out .. "/01-wall.png")
else
U.log("SKIP 01 wall -- no wall cell found in PLAYERS_HOUSE_1F")
end
-- ---- 2. an NPC -----------------------------------------------------------
local npc, npcDir
for _, e in ipairs(w.entities or {}) do
if e ~= w.player and e.cellX and not e.bigObject then
for dir, d in pairs(DELTA) do
local sx, sy = e.cellX - d[1], e.cellY - d[2]
if walkable(w.map, sx, sy) then npc, npcDir = { sx, sy }, dir break end
end
end
if npc then break end
end
if npc then
assert(w:setMap("PLAYERS_HOUSE_1F", npc[1], npc[2], npcDir))
U.wait(8)
reset()
U.hold(game, npcDir, 45)
U.wait(5)
report("02 NPC (" .. npcDir .. "):")
U.shot(game, out .. "/02-npc.png")
else
U.log("SKIP 02 NPC -- no reachable NPC in PLAYERS_HOUSE_1F")
end
-- ---- 3. a ledge ----------------------------------------------------------
local ROUTES = { "ROUTE_29", "ROUTE_30", "ROUTE_31", "ROUTE_32",
"ROUTE_33", "ROUTE_34", "ROUTE_35", "ROUTE_36" }
local hopped = false
for _, mapId in ipairs(ROUTES) do
if w:setMap(mapId, 5, 5, "down") then
U.wait(5)
local map = w.map
for y = 0, map.heightCells - 1 do
for x = 0, map.widthCells - 1 do
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
if facings then
for dir, on in pairs(facings) do
local d = on and DELTA[dir]
if d and walkable(map, x + d[1] * 2, y + d[2] * 2) then
assert(w:setMap(mapId, x, y, dir))
U.wait(8)
reset()
U.hold(game, dir, 40)
U.wait(20)
local counts = report(
("03 ledge hop (%s %s):"):format(mapId, dir))
U.log(" want Sfx_JumpOverLedge and NO Sfx_Bump; bumps:",
counts.Sfx_Bump or 0)
U.shot(game, out .. "/03-ledge.png")
hopped = true
break
end
end
end
if hopped then break end
end
if hopped then break end
end
end
if hopped then break end
end
if not hopped then U.log("SKIP 03 ledge -- no hoppable ledge found") end
-- ---- 4 and 5. map edges --------------------------------------------------
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
U.wait(5)
local DIR_CONN = { up = "north", down = "south", left = "west",
right = "east" }
local map = w.map
local function edgeCell(dir)
local d = DELTA[dir]
for y = 0, map.heightCells - 1 do
for x = 0, map.widthCells - 1 do
if walkable(map, x, y)
and not map:inBounds(x + d[1], y + d[2]) then
return x, y
end
end
end
end
for _, dir in ipairs({ "up", "down", "left", "right" }) do
local conn = map:connection(DIR_CONN[dir])
local ex, ey = edgeCell(dir)
if ex then
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
U.wait(8)
reset()
U.hold(game, dir, 40)
U.wait(20)
local counts = report(("%s edge %s (connection: %s):")
:format(conn and "04" or "05", dir,
tostring(conn and conn.mapId or "none")))
U.log(" want", conn and "silence" or "Sfx_Bump", "-- bumps:",
counts.Sfx_Bump or 0)
U.shot(game, out .. ("/0%s-edge-%s.png"):format(conn and "4" or "5", dir))
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
U.wait(5)
end
end
Sound.play = realPlay
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"))
U.log("done -- the controls are yours; walk into anything")
end
+207
View File
@@ -0,0 +1,207 @@
-- #1556: the field jingles that ride a TX_SOUND at the end of a string --
-- RARE CANDY's level-up, "X learned MOVE!", the trophy -- plus the TM/HM
-- refusals, which are `call PlaySFX` next to the message instead.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_fanfare.lua \
-- perl -e 'alarm 420; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-fanfare (default)
--
-- The one deliberate SILENCE is moment 4: TeachTMHM's `.compatible` arm is
-- `callfar KnowsMove / jr c, .nope` (engine/items/tmhm.asm:139-140) with no
-- PlaySFX at all, so "X already knows MOVE!" must stay mute. Only the
-- INCOMPATIBLE arm rings (:131).
--
-- The box is supposed to HOLD for the fanfare before it takes a button:
-- TextCommand_SOUND is PlaySFX then WaitSFX (home/text.asm), which
-- TextBox.soundOpts models with auto.wait.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local PackMenu = require("src.ui.gen2.PackMenu")
local Sound = require("src.core.Sound")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-fanfare"
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name)
heard[#heard + 1] = name
return realPlay(data, name)
end
local function reset() heard = {} end
local function report(label, want)
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
U.log(" want:", want)
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
U.wait(45)
local w = game.world
assert(w and w.map, "gold world did not boot")
local save, data = game.save, game.data
local SPECIES = "CYNDAQUIL"
local def = data.pokemon and data.pokemon[SPECIES]
assert(def, "the cache carries no " .. SPECIES)
-- Which TMs this species may and may not learn, straight off its BASE_TMHM.
local canLearn, cannotLearn = nil, nil
local allowed = {}
for _, moveId in ipairs(def.tmhm or {}) do allowed[moveId] = true end
for itemId, item in pairs(data.items or {}) do
if item.teaches and tostring(itemId):sub(1, 3) == "TM_" then
if allowed[item.teaches] then
canLearn = canLearn or itemId
else
cannotLearn = cannotLearn or itemId
end
end
end
U.log("TM that fits:", tostring(canLearn),
"-- TM that does not:", tostring(cannotLearn))
local function seed()
local mon = Mon.new(data, SPECIES, 12)
save.party = { mon }
return mon
end
local function openPack()
local pack = PackMenu.new(game, {
save = save, world = w, onClose = function() end })
game.stack:push(pack)
U.wait(6)
return pack
end
local field = game.overworld or game.stack:top()
local function popToField()
for _ = 1, 12 do
if game.stack:top() == field or not game.stack:top() then break end
game.stack:pop()
U.wait(2)
end
end
-- ---- 1. RARE CANDY: "X grew to level N!" + the level-up fanfare ---------
local mon = seed()
save.inventory = { RARE_CANDY = 2 }
reset()
game:usePartyItem("RARE_CANDY")
U.wait(8)
tap("a", 30) -- the party pick
report("01 rare candy:", "Sfx_DexFanfare5079 under \"grew to level 13!\"")
U.shot(game, out .. "/01-rare-candy.png")
tap("a", 10)
popToField()
-- ---- 2. a TM the mon CAN learn: "X learned MOVE!" + the fanfare ---------
if canLearn then
mon = seed()
save.inventory = { [canLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = canLearn })
U.wait(10)
tap("a", 30) -- the party pick
report("02 TM " .. canLearn .. ":",
"Sfx_DexFanfare5079 under \"learned ...!\"")
U.shot(game, out .. "/02-tm-learned.png")
tap("a", 10)
popToField()
else
U.log("SKIP 02 -- no TM in this cache that " .. SPECIES .. " can learn")
end
-- ---- 3. a TM the mon CANNOT learn: SFX_WRONG (tmhm.asm:131) ------------
if cannotLearn then
mon = seed()
save.inventory = { [cannotLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = cannotLearn })
U.wait(10)
tap("a", 20)
report("03 TM " .. cannotLearn .. ":",
"Sfx_Wrong under \"can't learn ...!\"")
U.shot(game, out .. "/03-tm-refused.png")
tap("a", 10)
popToField()
else
U.log("SKIP 03 -- every TM in this cache fits " .. SPECIES)
end
-- ---- 4. a TM the mon ALREADY KNOWS: SILENCE ----------------------------
if canLearn then
mon = seed()
local moveId = data.items[canLearn].teaches
mon.moves = mon.moves or {}
mon.moves[1] = { id = moveId, pp = 10, maxPp = 10 }
save.inventory = { [canLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = canLearn })
U.wait(10)
tap("a", 20)
report("04 TM already known:", "SILENCE -- KnowsMove has no PlaySFX")
U.shot(game, out .. "/04-tm-already-known.png")
tap("a", 10)
popToField()
end
-- ---- 5. a TM on an EGG: SFX_WRONG (tmhm.asm:104-111), a regression -----
if canLearn then
mon = seed()
mon.isEgg = true
save.inventory = { [canLearn] = 1 }
local pack = openPack()
reset()
pack:openTeachParty({ id = canLearn })
U.wait(10)
tap("a", 20)
report("05 TM on an EGG:", "Sfx_Wrong, and the list stays up")
U.shot(game, out .. "/05-tm-egg.png")
tap("b", 6)
popToField()
end
-- ---- 6. the trophy box: _SentTrophyHomeText's fanfare ------------------
if data.items and data.items.NORMAL_BOX then
seed()
save.inventory = { NORMAL_BOX = 1 }
save.decorations = save.decorations or {}
local pack = openPack()
pack:rebuild()
local row
for index, entry in ipairs(pack.rows) do
if entry.id == "NORMAL_BOX" then row = index end
end
if row then
pack.index = row
reset()
pack:useSelected()
U.wait(20)
report("06 trophy box:", "Sfx_DexFanfare5079 with the trophy line")
U.shot(game, out .. "/06-trophy.png")
tap("a", 6)
else
U.log("SKIP 06 -- the NORMAL BOX is not in the ITEM pocket here")
end
popToField()
else
U.log("SKIP 06 -- this cache has no NORMAL_BOX")
end
Sound.play = realPlay
U.log("done -- the controls are yours")
end
+105
View File
@@ -0,0 +1,105 @@
-- #1567: the TM/HM pocket's row order.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1567_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1567 (default)
--
-- Nothing here can be asserted: the bug IS the order rows land in.
-- TMHM_DisplayPocketItems walks the fixed 57-byte wTMsHMs array from 1 to 57
-- and prints every non-zero slot (engine/items/tmhm.asm:341), so the cart's
-- pocket is always TM01..TM50 then HM01..HM07 no matter what the player picked
-- up first. The port drew it from the acquisition-ordered bag list instead.
--
-- The seed is deliberately scrambled -- gold_bug1425_test.lua seeds its TMs
-- already in numeric order, which is why its screenshots never showed this.
--
-- The run ends with the PACK still open on the TM pocket, so a human takes the
-- controls exactly where the screenshots stop.
local U = require("tests.drivers.util")
local Bag = require("src.inventory.Bag")
local PackMenu = require("src.ui.gen2.PackMenu")
-- Picked up back to front: TM/HM numbers 57, 50, 51, 5, 1, 53. TM_ROAR is the
-- one on the far side of the ITEM_C3 hole (id $c4, number 5), so it also pins
-- that the number and not the item id is what sorts.
local SEED = {
{ "HM_WATERFALL", 1 },
{ "TM_NIGHTMARE", 2 },
{ "HM_CUT", 1 },
{ "TM_ROAR", 3 },
{ "TM_DYNAMICPUNCH", 12 },
{ "HM_SURF", 1 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1567"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.inventory = {}
save.bagOrder = {}
for _, entry in ipairs(SEED) do
local id, count = entry[1], entry[2]
if not (game.data.items and game.data.items[id]) then
U.log("[driver] SKIP", id, "-- not in this cache")
else
save.inventory[id] = count
table.insert(Bag.order(save), id)
end
end
-- A POTION and a SUPER POTION so the ITEM pocket can be compared against the
-- TM pocket at the end: SELECT must still arm a row over there.
for _, entry in ipairs({ { "POTION", 5 }, { "SUPER_POTION", 2 } }) do
if game.data.items and game.data.items[entry[1]] then
save.inventory[entry[1]] = entry[2]
table.insert(Bag.order(save), entry[1])
end
end
U.log("[driver] pickup order:", table.concat(Bag.order(save), " "))
local pack = PackMenu.new(game, { save = save, world = game.world,
onClose = function() end })
game.stack:push(pack)
shot("00-items") -- POTION over SUPER POTION, pickup order
U.tap(game, "right")
U.tap(game, "right")
U.tap(game, "right")
-- TM01 DYNAMICPUNCH ×12, TM05 ROAR ×03, TM50 NIGHTMARE ×02, then HM01 CUT,
-- HM03 SURF, HM07 WATERFALL with no ×NN at all (engine/items/tmhm.asm:390).
-- Before the fix this read HM07, TM50, HM01, TM05, TM01, HM03.
shot("01-tmhm-numbered")
local order = {}
for i = 1, #pack.rows do order[i] = pack.rows[i].id end
U.log("[driver] TM/HM rows:", table.concat(order, " "))
-- engine/items/tmhm.asm:207 filters SELECT out of this pocket: no hollow ▷,
-- no "Where should this be moved to?".
U.tap(game, "select")
shot("02-tmhm-select-ignored")
U.tap(game, "down")
U.tap(game, "select")
shot("03-tmhm-select-ignored-row2")
-- The ITEM pocket, where SELECT still arms (engine/items/pack.asm:1290).
U.tap(game, "right")
U.tap(game, "select")
shot("04-items-select-arms")
U.tap(game, "b")
-- Back to the TM pocket for the human.
U.tap(game, "right")
U.tap(game, "right")
U.tap(game, "right")
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
end
@@ -0,0 +1,197 @@
-- #1513: exp credit has to be per THIS appearance of the enemy's active mon.
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037), and AI_Switch farcalls it right
-- after EnemySwitch (engine/battle/ai/items.asm:697), so the moment the rival
-- rotates, the only mon still credited is the one the player has on the field.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_IDENTITY=gold-dev \
-- POKEPORT_DRIVER=tests/drivers/gold_exp_participants_bug1513.lua love .
--
-- What to look for, in order:
-- 00 CYNDAQUIL is out against JOEY's GEODUDE.
-- 01 TOTODILE has been switched in through the real party menu, so BOTH
-- party mons have now "met" GEODUDE.
-- 02 "JOEY withdrew GEODUDE!" -- the rotation the ticket is about.
-- 03 the exp lines for the KO of the mon that came IN (PIDGEY). Only
-- TOTODILE may be named here. A "CYNDAQUIL gained N EXP. Points!" line
-- in this shot is the bug.
-- 04 the party screen: CYNDAQUIL's level and EXP bar are pixel-identical to
-- shot 01's, TOTODILE's have moved.
-- 05 GEODUDE comes back out and is KO'd. CYNDAQUIL still gains nothing --
-- that half is correct cart behavior (ram/wram.asm:775-776, "All bits
-- cleared if the enemy faints") and must NOT be "fixed".
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
if screen.battle.over then return false end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- Page the queue out, shooting the first box that carries `needle`.
local function pageUntilMenu(game, screen, out, needles)
local shot = {}
for _ = 1, 1200 do
local message = screen.message
if message then
for needle, path in pairs(needles) do
if not shot[needle] and message:find(needle, 1, true) then
U.shot(game, out .. "/" .. path)
shot[needle] = message
end
end
end
if screen.phase == "menu" or screen.phase == "done"
or screen.battle.over then
break
end
U.tap(game, "a")
U.wait(3)
end
return shot
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1513"
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
local lead = Mon.new(game.data, "CYNDAQUIL", 20)
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(game.data, "TOTODILE", 20)
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, bench }
local foe1 = Mon.new(game.data, "GEODUDE", 8)
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local foe2 = Mon.new(game.data, "PIDGEY", 8)
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
-- attributes[6] is the low byte of TRNATTR_AI_ITEM_SWITCH: OFTEN
-- (src/battle/gen2/Ai.lua:1307). The real Rival1 class is SWITCH_SOMETIMES
-- (pokegold:data/trainers/attributes.asm), the same path at a lower roll.
assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY",
party = { foe1, foe2 }, attributes = { 0, 0, 0, 0, 0, 0x01, 0 } } }),
"trainer startBattle failed")
local screen = battleScreen(game)
assert(runToPhase(game, screen, "menu", 400), "never reached the menu")
U.shot(game, out .. "/00-lead-out.png")
------------------------------------------------- the player's own switch
-- Through the real party menu, so the human sees the same path they used.
screen:chooseMenu("party")
for _ = 1, 240 do
if screen.battle.player == bench then break end
local top = game.stack:top()
if top ~= screen then
U.tap(game, "down")
U.wait(3)
U.tap(game, "a")
U.wait(6)
U.tap(game, "a")
U.wait(6)
else
U.wait(2)
end
end
check(screen.battle.player == bench, "TOTODILE was switched in")
check(screen.battle.participants[1] and screen.battle.participants[2],
"both party mons are credited against GEODUDE")
pageUntilMenu(game, screen, out, {})
U.shot(game, out .. "/01-switched.png")
print(("[driver] before the rotation: cyndaquil %d totodile %d")
:format(lead.experience, bench.experience))
local leadExp = lead.experience
local benchExp = bench.experience
------------------------------------------------------- the AI's rotation
-- Perish Song at one turn left is CheckAbleToSwitch's maximum score, the
-- cheapest way to make the AI commit to the rotation on demand.
screen.battle:volatile(screen.battle.enemy).perish = 1
screen:submit({ kind = "move", move = "TACKLE" })
local seen = pageUntilMenu(game, screen, out, {
["withdrew"] = "02-rival-withdrew.png",
["gained"] = "03-exp-lines.png",
})
check(seen["withdrew"] ~= nil, "JOEY withdrew GEODUDE")
check(screen.battle.enemy == foe2, "and sent PIDGEY out")
check(screen.battle.participants[1] == nil,
"CYNDAQUIL lost its credit when the rival rotated")
check(screen.battle.participants[2] == true,
"and TOTODILE, the mon on the field, kept it")
------------------------------------------------------------ the KO payout
if not screen.battle.over and (foe2.hp or 0) > 0 then
if screen.phase ~= "menu" then
runToPhase(game, screen, "menu", 400)
end
foe2.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
local paid = pageUntilMenu(game, screen, out, {
["gained"] = "03-exp-lines.png",
})
if paid["gained"] then print("[driver] exp box: " .. paid["gained"]) end
end
print(("[driver] after the PIDGEY KO: cyndaquil %d totodile %d")
:format(lead.experience, bench.experience))
check(lead.experience == leadExp,
"CYNDAQUIL earned NOTHING from the mon it never faced")
check(bench.experience > benchExp, "TOTODILE was paid for it")
if screen.phase == "menu" then
screen:chooseMenu("party")
U.wait(20)
U.shot(game, out .. "/04-party-exp.png")
U.tap(game, "b")
U.wait(10)
if game.stack:top() ~= screen then
U.tap(game, "b")
U.wait(10)
end
end
------------------------------------------- GEODUDE comes back and faints
-- Correct cart behavior: no residual credit for a previous appearance.
leadExp = lead.experience
benchExp = bench.experience
if not screen.battle.over then
runToPhase(game, screen, "menu", 600)
if screen.phase == "menu" and screen.battle.enemy == foe1 then
foe1.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
pageUntilMenu(game, screen, out, { ["gained"] = "05-geodude-ko.png" })
print(("[driver] after the GEODUDE KO: cyndaquil %d totodile %d")
:format(lead.experience, bench.experience))
check(lead.experience == leadExp,
"CYNDAQUIL still earns nothing when GEODUDE comes back (correct)")
check(bench.experience > benchExp, "and TOTODILE takes the whole share")
else
print("[driver] GEODUDE never came back out; second half skipped")
end
end
if #failures > 0 then
error(#failures .. " exp participant checks failed")
end
print("[driver] #1513 fixed; shots in " .. out)
end
+23 -6
View File
@@ -221,6 +221,13 @@ return function(game)
stored[i] = mon(species, 10 + i)
end
Boxes.rename(save, 2, "GRASS")
-- $5d for a held item, $5c for MAIL (engine/pokemon/bills_pc.asm:1079-1094).
-- A boxed mon can never hold mail, so the letter goes on a party mon.
stored[1].item = "BERRY"
save.party[2].item = "FLOWER_MAIL"
local Mail = require("src.core.gen2.Mail")
Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!",
save.player.name or "GOLD", save.player.id or 0, save.party[2].species))
local pc = PcMenu.new(game, { save = save })
show("20-pc-menu", pc)
@@ -228,12 +235,22 @@ return function(game)
pc.pickIndex = 2
show("21-pc-changebox", pc)
show("22-pc-withdraw", BoxMenu.new(game, {
save = save, mode = "withdraw",
}))
show("23-pc-deposit", BoxMenu.new(game, {
save = save, mode = "deposit",
}))
-- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu
-- swaps the mon's colours in (engine/pokemon/bills_pc.asm:305-309, :356-369,
-- engine/gfx/cgb_layouts.asm:284-289); the icon is BG palette 0 either way.
local wd = BoxMenu.new(game, { save = save, mode = "withdraw" })
show("22-pc-withdraw", wd)
wd.index = wd:total() -- CANCEL
wd:ensureVisible()
show("22b-pc-withdraw-cancel", wd)
wd.index = 1
wd:ensureVisible()
wd.phase = "submenu"
show("22c-pc-withdraw-submenu", wd)
local dep = BoxMenu.new(game, { save = save, mode = "deposit" })
show("23-pc-deposit", dep)
dep.index = 2 -- the mon holding FLOWER MAIL
show("23b-pc-deposit-mail", dep)
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
-- SetDayOfWeek), each at its picker rather than at its opening page.
@@ -0,0 +1,120 @@
-- Driver: #1517, a Gen 2 stat block in a Gen 1 party.
--
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
-- (engine/pokemon/status_screen.asm:255-296). Gen 2 splits that word into
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
-- block over a Yellow party leaves the port with no `special` to print.
--
-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/yellow \
-- ~/Library/Application\ Support/LOVE/bug1517/yellow
-- POKEPORT_VERSION=yellow POKEPORT_GAME=yellow POKEPORT_IDENTITY=bug1517 \
-- POKEPORT_TOUCH=0 POKEPORT_SPEED=8 SHOT_DIR=/tmp/bug1517 \
-- POKEPORT_DRIVER=tests/drivers/summary_stats_bug1517_test.lua love .
--
-- The copy is not optional: a fresh POKEPORT_IDENTITY has no cache and the
-- boot hangs with no output.
--
-- Pre-fix the LOVE window vanishes the moment STATS is chosen, with no error
-- screen and nothing on stdout (that silence is the other half of #1517).
-- Post-fix the status screen opens and SPECIAL reads the CalcStats value
-- logged below. The run ends on the open screen, so a human takes the
-- controls at exactly the frame the reporter's build died on.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1517"
local Pokemon = require("src.pokemon.Pokemon")
local Stats = require("src.pokemon.Stats")
local SaveData = require("src.core.SaveData")
local SummaryMenu = require("src.ui.SummaryMenu")
local function top() return game.stack:top() end
U.newGame(game)
U.wait(20)
-- BUTTERFREE because the reporter's party led with one, and CONFUSION is a
-- special move, so the same block would have crashed Damage.applyStage.
local species = game.data.pokemon.BUTTERFREE and "BUTTERFREE" or "PIDGEY"
local def = game.data.pokemon[species]
local mon = Pokemon.new(game.data, species, 21)
local want = Stats.calc(def, 21, mon.dvs, mon.statExp)
-- the shape src/battle/gen2/Mon.lua Mon.stats produces: no `special`
mon.stats = {
hp = want.hp, attack = want.attack, defense = want.defense,
speed = want.speed, specialAttack = want.special, specialDefense = want.special,
}
mon.hp = mon.stats.hp
game.save.party = { mon }
U.log(("[1517] fixture: %s :L21, stats.special = %s, CalcStats says %d")
:format(species, tostring(mon.stats.special), want.special))
SaveData.validate(game.save, game.data)
local after = game.save.party[1].stats
U.log("[1517] after SaveData.validate, special = " .. tostring(after.special)
.. ", specialAttack = " .. tostring(after.specialAttack))
U.log("[1517] the repair layer is SaveData.validate -> scrubKnownMon -> "
.. "Stats.ensure; a nil above means it did not run")
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local function cursorTo(menu, field, wanted)
for _ = 1, 40 do
if not menu or menu[field] == wanted then return menu and menu[field] == wanted end
U.tap(game, menu[field] < wanted and "down" or "up")
U.wait(3)
end
return menu[field] == wanted
end
U.tap(game, "start")
U.wait(10)
local menu = top()
if not (menu and menu.screenId == "StartMenu") then
U.log("[1517] FAIL no start menu; top = " .. tostring(menu and menu.screenId))
while true do coroutine.yield() end
end
local row
for i, it in ipairs(menu.items or {}) do
if it.label == "POKéMON" then row = i break end
end
if not (row and cursorTo(menu, "index", row)) then
U.log("[1517] FAIL could not reach the POKéMON row")
while true do coroutine.yield() end
end
U.tap(game, "a")
U.wait(10)
local party = top()
if not (party and party.screenId == "PartyMenu") then
U.log("[1517] FAIL no party menu; top = " .. tostring(party and party.screenId))
while true do coroutine.yield() end
end
U.shot(game, DIR .. "/01-party-list.png")
U.tap(game, "a")
U.wait(8)
local subRow
for i, it in ipairs(party.subItems or {}) do
if it.action == "stats" then subRow = i break end
end
if not (subRow and cursorTo(party, "subIndex", subRow)) then
U.log("[1517] FAIL could not reach the STATS row")
while true do coroutine.yield() end
end
U.shot(game, DIR .. "/02-stats-selected.png")
U.log("[1517] opening STATS -- pre-fix the app dies here, silently")
U.tap(game, "a")
U.wait(30)
local screen = top()
local opened = getmetatable(screen) == SummaryMenu or screen.screenId == "SummaryMenu"
U.log("[1517] SURVIVED, top = " .. tostring(screen and screen.screenId))
U.shot(game, DIR .. "/03-status-screen.png")
U.log(("[1517] %s: SPECIAL on screen must read %d")
:format(opened and "read it off the frame" or "WRONG SCREEN", want.special))
U.log("[1517] shots in " .. DIR .. " -- the status screen is yours")
while true do coroutine.yield() end
end
@@ -0,0 +1,100 @@
-- A Gen 1 party mon carries one Special word (macros/ram.asm:28-37) and
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48,
-- constants/battle_constants.asm:11-17), so a block missing a key is not a
-- Gen 1 record and the load-time repair must rebuild it. Gen 2 splits it
-- into SpclAtk/SpclDef (pokegold macros/ram.asm:29-42). #1517
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 Stats = require("src.pokemon.Stats")
local SaveData = require("src.core.SaveData")
local data = {
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
moves = { FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 } },
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
constants = { fallbackMove = "FIX_TACKLE" },
}
local DEF = data.pokemon.FIXMON_A
local DVS = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }
-- src/battle/gen2/Mon.lua Mon.stats' shape: no `special`
local function gen2Shaped()
return {
species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
statExp = {},
stats = { hp = 62, attack = 27, defense = 29, speed = 37,
specialAttack = 8, specialDefense = 8 },
moves = { { id = "FIX_TACKLE", pp = 35 } },
}
end
do
local mon = gen2Shaped()
eq(mon.stats.special, nil, "premise: a Gen 2 block has no `special`")
Stats.ensure(DEF, mon)
local want = Stats.calc(DEF, 21, DVS, {})
for _, key in ipairs(Stats.ORDER) do
eq(mon.stats[key], want[key], "ensure rebuilds " .. key .. " from CalcStats")
end
eq(mon.stats.specialAttack, nil, "and drops the Gen 2 keys")
eq(mon.stats.specialDefense, nil, "both of them")
end
do
local mon = gen2Shaped()
local save = { party = { mon }, boxes = {}, inventory = {}, pcItems = {},
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
SaveData.validate(save, data)
check(type(save.party[1].stats.special) == "number",
"validate repairs a party mon carrying a Gen 2 stat block")
local ok = pcall(string.format, "%3d", save.party[1].stats.special)
check(ok, "SummaryMenu's ('%3d'):format over stats.special no longer raises")
ok = pcall(Stats.applyStage, save.party[1].stats.special, 0)
check(ok, "and Damage's applyStage over curStats.special no longer raises")
end
-- a box mon reached through the same pass
do
local save = { party = {}, boxes = { { gen2Shaped() } }, inventory = {},
pcItems = {},
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
SaveData.validate(save, data)
check(type(save.boxes[1][1].stats.special) == "number",
"the repair reaches box mons too")
end
do -- a complete block is never rewritten
local want = Stats.calc(DEF, 21, DVS, {})
local mon = { species = "FIXMON_A", level = 21, hp = 5, dvs = DVS, statExp = {},
stats = { hp = want.hp, attack = 999, defense = want.defense,
speed = want.speed, special = want.special },
moves = { { id = "FIX_TACKLE", pp = 35 } } }
Stats.ensure(DEF, mon)
eq(mon.stats.attack, 999, "a complete block is returned untouched")
eq(mon.hp, 5, "and a stored current HP below the max is kept")
end
do -- a mon with no block at all still gets one (#233, #304 unchanged)
local mon = { species = "FIXMON_A", level = 21, dvs = DVS, statExp = {} }
Stats.ensure(DEF, mon)
local want = Stats.calc(DEF, 21, DVS, {})
eq(mon.stats.special, want.special, "a box mon with no stats still gets them")
eq(mon.hp, want.hp, "and a missing current HP fills to the maximum")
end
do -- no species definition: nothing to rebuild from, so leave it alone
local mon = { species = "MISSINGNO", level = 21,
stats = { hp = 62, attack = 27 } }
Stats.ensure(nil, mon)
eq(mon.stats.attack, 27, "an unknown species leaves a partial block as-is")
eq(mon.stats.special, nil, "rather than inventing one")
end
T.finish()
+77
View File
@@ -851,6 +851,83 @@ do
check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35)
end
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037), so every ENEMY-initiated mon change
-- wipes both bitfields and re-credits the mon the player has out. The player's
-- own send-outs only ever call AddBattleParticipant (core.asm:2655, :2681,
-- :3783, :4989, :5014).
do
local function creditBattle()
local party = {
Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }),
Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }),
}
local foes = {
Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }),
Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }),
}
for _, mon in ipairs(party) do
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
end
for _, mon in ipairs(foes) do
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
end
local battle = Battle.new({
data = DATA, party = party,
-- attributes[6] is the low byte of the switch flags: OFTEN.
trainer = { class = "YOUNGSTER", name = "JOEY", party = foes,
attributes = { 0, 0, 0, 0, 0, 0x01, 0 } },
random = zeroRandom,
})
battle:switch(2)
return battle, party, foes
end
-- AI_Switch (engine/battle/ai/items.asm:697).
local rotate, rotateParty, rotateFoes = creditBattle()
check("both mons are credited before the rotation",
rotate.participants[1] and rotate.participants[2], true)
rotate:volatile(rotate.enemy).perish = 1
check("the AI rotated", rotate:enemyTrySwitchOrItem(), true)
check("the rotation installed the second foe", rotate.enemy, rotateFoes[2])
check("the bench mon lost its credit", rotate.participants[1], nil)
check("only the mon on the field keeps it", rotate.participants[2], true)
local benchExp = rotateParty[1].experience
local activeExp = rotateParty[2].experience
rotate:awardExperience(rotate.enemy)
check("the bench mon earns nothing from the new foe",
rotateParty[1].experience, benchExp)
check("and the mon that faced it is still paid",
rotateParty[2].experience > activeExp, true)
-- ForceEnemySwitch (engine/battle/core.asm:2937), reached only from
-- BattleCommand_ForceSwitch (effect_commands.asm:4999).
local roar, _, roarFoes = creditBattle()
roar.firstMover = "enemy"
Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH(roar, roar.player, roar.enemy,
nil, "ROAR", true)
check("Roar dragged the second foe out", roar.enemy, roarFoes[2])
check("the bench mon lost its credit to Roar", roar.participants[1], nil)
check("and the mon on the field keeps it", roar.participants[2], true)
-- engine/battle/move_effects/baton_pass.asm:59.
local baton, _, batonFoes = creditBattle()
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(baton, baton.enemy)
check("the baton passed to the second foe", baton.enemy, batonFoes[2])
check("the bench mon lost its credit to the baton",
baton.participants[1], nil)
check("and the mon on the field keeps it", baton.participants[2], true)
-- PassedBattleMonEntrance only adds (engine/battle/core.asm:5014): the
-- player's own baton pass must NOT wipe the set.
local playerBaton, playerBatonParty = creditBattle()
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(playerBaton, playerBaton.player)
check("the player's baton pass moved the lead back in",
playerBaton.player, playerBatonParty[1])
check("and credited both mons",
playerBaton.participants[1] and playerBaton.participants[2], true)
end
-- Switching costs the turn and adds the newcomer to the participant set.
local switchParty = {
Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }),
+66
View File
@@ -483,6 +483,72 @@ packInput:press("right")
pack:update(0)
check("pocket wraps around", pack:pocket().id, "ITEM")
-- engine/items/tmhm.asm:341 -- TMHM_DisplayPocketItems walks wTMsHMs 1..57, so
-- the pocket is TM01..TM50 then HM01..HM07 whatever order the player picked
-- them up in, and tmhm.asm:207 keeps SELECT out of it entirely.
do
local tmSave2 = Save.newGame()
tmSave2.inventory = {
HM_WATERFALL = 1, TM_NIGHTMARE = 2, HM_CUT = 1, TM_ROAR = 3,
TM_DYNAMICPUNCH = 12, TM_LEGACY = 1,
}
tmSave2.bagOrder = {
"HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_ROAR", "TM_DYNAMICPUNCH",
"TM_LEGACY",
}
local tmGame2, tmInput2 = newGame(tmSave2)
tmGame2.data.items = {
TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01",
pocket = "TM_HM", index = 191, tmNumber = 1 },
TM_ROAR = { id = "TM_ROAR", name = "TM05", pocket = "TM_HM",
index = 195, tmNumber = 5 },
TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM",
index = 240, tmNumber = 50 },
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM",
index = 241, tmNumber = 51 },
HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM",
index = 247, tmNumber = 57 },
-- A cache (or a mod) with no tmNumber falls back to the ItemNames index.
TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM",
index = 300 },
}
local tmPack = PackMenu.new(tmGame2, { pocket = "TM_HM" })
local function ids(rows)
local out = {}
for i = 1, #rows do out[i] = rows[i].id end
return table.concat(out, ",")
end
check("TM/HM pocket is TM-number ordered, not pickup ordered",
ids(tmPack.rows),
"TM_DYNAMICPUNCH,TM_ROAR,TM_NIGHTMARE,HM_CUT,HM_WATERFALL,TM_LEGACY")
check("the first row is TM01", tmPack.rows[1].id, "TM_DYNAMICPUNCH")
check("and a tmNumber-less item lands after HM07",
tmPack.rows[#tmPack.rows].id, "TM_LEGACY")
check("the TM keeps its count", tmPack.rows[1].showCount, true)
check("the HM shows none", tmPack.rows[4].showCount, false)
tmPack.index = 1
tmInput2:press("select")
tmPack:update(0)
check("SELECT cannot arm a TM/HM row", tmPack.switching, nil)
check("and prints no move prompt", tmPack.message, nil)
-- The other three pockets still reorder on SELECT (pack.asm:1290).
tmSave2.inventory.POTION = 1
tmSave2.inventory.SUPER_POTION = 1
tmSave2.bagOrder = { "POTION", "SUPER_POTION" }
tmGame2.data.items.POTION =
{ id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }
tmGame2.data.items.SUPER_POTION =
{ id = "SUPER_POTION", name = "SUPER POTION", pocket = "ITEM", index = 2 }
tmPack.pocketIndex = 1
tmPack:rebuild()
tmPack.index = 1
tmInput2:press("select")
tmPack:update(0)
check("but the ITEM pocket still arms", tmPack.switching, 1)
end
-- CANCEL sits one past the last row.
check("cancel is past the end", pack:total(), #pack.rows + 1)
pack.index = pack:total()
+59 -4
View File
@@ -310,7 +310,8 @@ do
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
end
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
-- .DepositItem: CANT_TOSS only skips .AskQuantity. A KEY ITEM deposits x1,
-- and .Submenu withdraws it back the same way (issue #1486).
do
local save = newSave(1)
local game, input = newGame(save)
@@ -320,9 +321,63 @@ do
press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket
press(pc, input, "a") -- choose the BICYCLE
eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM")
eq(pc.message, nil, "no message either: .no_toss is a bare ret")
eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag")
eq(save.inventory.BICYCLE, 1, "still there")
eq(save.pcItems.BICYCLE, 1, "the BICYCLE lands in the PC")
eq(save.inventory.BICYCLE, nil, "and leaves the bag")
eq(pc.message.pages[1][1], "Deposited 1", "_PlayersPCDepositItemsText")
press(pc, input, "a") -- clear it
press(pc, input, "b") -- close the PACK
press(pc, input, "up", "a") -- WITHDRAW ITEM
eq(pc.phase, "withdraw", "the PC item list opens on the KEY ITEM")
press(pc, input, "a") -- the BICYCLE row
eq(pc.qtyState, nil, "no quantity selector on the way back either")
eq(save.inventory.BICYCLE, 1, "the BICYCLE is back in the bag")
eq(save.pcItems.BICYCLE, nil, "and out of the PC")
end
-- The TM_HM pocket's HMs are CANT_TOSS too, and deposit prompt-free x1.
do
local save = newSave(1)
local game, input = newGame(save)
Bag.add(save, "HM_CUT", 1, game.data)
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
press(pc, input, "down", "a") -- DEPOSIT ITEM
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
press(pc, input, "a") -- choose HM01
eq(pc.qtyState, nil, "no quantity selector for an HM")
eq(save.pcItems.HM_CUT, 1, "HM01 lands in the PC")
eq(save.inventory.HM_CUT, nil, "and leaves the bag")
end
-- PlaceMenuItemQuantity: the PC list draws no xNN for a CANT_TOSS row.
do
local save = newSave(1)
local game = newGame(save)
save.pcItems = { POTION = 3, HM_CUT = 1 }
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
local Chrome = require("src.ui.gen2.Chrome")
local TIMES = "\xc3\x97"
local saved = { print = Chrome.print, box = Chrome.box,
cursor = Chrome.cursor }
local printed = {}
Chrome.print = function(text, x, y)
printed[#printed + 1] = { text = text, x = x, y = y }
end
Chrome.box = function() end
Chrome.cursor = function() end
pc.phase = "withdraw"
pc:rebuild()
local drew, err = pcall(function() pc:drawList() end)
Chrome.print, Chrome.box, Chrome.cursor = saved.print, saved.box, saved.cursor
check(drew, "the PC list draws: " .. tostring(err))
local rowY = {}
for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end
local counts = {}
for _, p in ipairs(printed) do
if p.x == 7 then counts[p.y] = p.text end
end
eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN")
eq(counts[rowY.HM_CUT + 1], nil,
"and the HM row draws none (PlaceMenuItemQuantity .done)")
end
-- An empty bag never opens the PACK (.CheckItemsInBag).
@@ -0,0 +1,144 @@
-- T4: a mod may not reach a Gen 2 engine module from a Gen 1 game.
--
-- src/battle/gen2/Mon.lua returns { hp, attack, defense, speed, specialAttack,
-- specialDefense }; a Gen 1 party mon carries `special` instead. A Yellow mod
-- that required that module and ran refreshStats over save.party wrote the Gen
-- 2 block onto Gen 1 mons, and every reader of stats.special raised from then
-- on (#1517). The require shim refuses it now, whatever the manifest declares:
-- engine_internals is a disclosure, not a generation gate.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local PROBE = [[
local mod = ...
local out = mod.exports
local function attempt(name)
local ok, result = pcall(require, name)
if ok then return nil, type(result) end
return tostring(result), nil
end
out.attempt = attempt
out.monErr, out.monType = attempt("src.battle.gen2.Mon")
out.saveErr = attempt("src.core.gen2.Save")
out.worldErr = attempt("src.ui.gen2.Chrome")
out.game2Err = attempt("src.core.Game2")
out.semverErr, out.semverType = attempt("src.mods.Semver")
out.statsErr, out.statsType = attempt("src.pokemon.Stats")
out.compatErr, out.compatType = attempt("src.mods.Gen2Compat")
out.loggerErr, out.loggerType = attempt("src.core.Logger")
]]
local function manifest(id, permissions)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
.. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "")
end
-- ------- Gen 1 boot: refused, and the permission does not unlock it
local FILES = {
["mods/gen2_declared/manifest.json"] =
manifest("gen2_declared", ',"permissions":["engine_internals"]'),
["mods/gen2_declared/main.lua"] = PROBE,
["mods/gen2_undeclared/manifest.json"] = manifest("gen2_undeclared"),
["mods/gen2_undeclared/main.lua"] = PROBE,
}
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
"the Gen 2 mon module is not loaded before the Gen 1 boot")
local run = T.sdk.loadMods({ "mods/gen2_declared", "mods/gen2_undeclared" },
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
T.eq(#run.errors, 0,
"a mod that only probes still loads clean (" .. tostring(run.errors[1]) .. ")")
local declared = run.loader.exports.gen2_declared or {}
local undeclared = run.loader.exports.gen2_undeclared or {}
T.check(declared.monErr, "src.battle.gen2.Mon is refused on a Gen 1 game")
T.check(declared.monErr and declared.monErr:find("src.battle.gen2.Mon", 1, true),
"the refusal names the module: " .. tostring(declared.monErr))
T.check(declared.monErr and declared.monErr:find("gen2_declared", 1, true),
"and the mod that asked for it")
T.check(declared.monErr and declared.monErr:find("mod.game", 1, true),
"and the surface to use instead")
T.eq(declared.monType, nil, "nothing came back for the mod to call")
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
"and the module was never loaded at all")
T.check(declared.saveErr, "src.core.gen2.Save is refused the same way")
T.check(declared.worldErr, "so is src.ui.gen2.Chrome")
T.check(declared.game2Err and declared.game2Err:find("src.core.Game2", 1, true),
"and src.core.Game2, the Gen 2 service owner: " .. tostring(declared.game2Err))
T.check(undeclared.monErr,
"a mod with no permissions is refused for the same reason")
T.eq(declared.monErr and declared.monErr:gsub("gen2_declared", "X"),
undeclared.monErr and undeclared.monErr:gsub("gen2_undeclared", "X"),
"engine_internals is a disclosure, not a generation gate: same refusal")
-- ------- what the gate does NOT touch
T.eq(declared.semverErr, nil, "the supported requires still resolve")
T.eq(declared.semverType, "table", "and hand back the module")
T.eq(declared.statsErr, nil, "src.pokemon.Stats is still published to mods")
T.eq(declared.compatErr, nil,
"src.mods.Gen2Compat is not a Gen 2 engine module: it is the adapter table")
T.eq(declared.compatType, "table", "so it still answers")
T.eq(declared.loggerErr, nil,
"an undeclared engine require is still a warning, not a block")
T.eq(declared.loggerType, "table", "and still resolves")
T.eq(undeclared.loggerErr, nil,
"including for a mod that declared no permissions at all")
-- a lazy require made long after the entry chunk ran is gated too: the mod's
-- own require is what the shim identifies, not the load phase
do
local err = declared.attempt and declared.attempt("src.world.gen2.World")
T.check(err and err:find("src.world.gen2.World", 1, true),
"a require made after load is refused too: " .. tostring(err))
end
-- the shim only judges a mod's own require; engine and harness callers keep
-- the module they asked for
do
local ok, module = pcall(require, "src.world.gen2.WorldAPI")
T.check(ok, "a non-mod caller still reaches a Gen 2 module: " .. tostring(module))
T.eq(type(module), "table", "and gets the real one")
end
run.release()
-- ------- an unguarded reach fails the whole mod, on the boot error feed
local HARD = {
["mods/gen2_hard/manifest.json"] = manifest("gen2_hard",
',"permissions":["engine_internals"]'),
["mods/gen2_hard/main.lua"] = [[
local mod = ...
local Mon = require("src.battle.gen2.Mon")
mod.exports.reached = Mon ~= nil
]],
}
local hard = T.sdk.loadMods({ "mods/gen2_hard" },
{ fs = T.sdk.memfs(HARD), data = {}, generation = 1 })
T.eq(hard.loader.mods.gen2_hard.state, "failed",
"a mod that reaches across the generation does not load")
T.eq(hard.loader.exports.gen2_hard, nil, "and publishes nothing")
T.check(hard.errors[1] and hard.errors[1]:find("src.battle.gen2.Mon", 1, true),
"the boot error feed names the module: " .. tostring(hard.errors[1]))
hard.release()
-- ------- Gen 2 boot: the same require is the mod's own generation
local gen2 = T.sdk.loadMods({ "mods/gen2_declared" },
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
local onGold = gen2.loader.exports.gen2_declared or {}
T.eq(onGold.monErr, nil,
"on a Gen 2 game the same require is answered: " .. tostring(onGold.monErr))
T.eq(onGold.monType, "table", "with the real Gen 2 mon module")
T.eq(onGold.game2Err, nil, "and src.core.Game2 is this game's service owner")
gen2.release()
T.finish("cross_generation_require")
+40
View File
@@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition
Game.save.onBike = false
Game.save.inventory.BICYCLE = nil
-- home/overworld.asm:1224
local function settle(o)
drainText()
for _ = 1, 60 do o:updateScriptMoves(); o.player:update() end
end
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
Game.save.inventory.BICYCLE = nil
Game.save.onBike, Game.save.forcedBike = false, nil
pushOW(c[1], c[2], c[3], "left")
ow = OW
check(not ow.map:isWalkableCell(c[2] + 1, c[3]),
("%s (%d,%d): the cell behind a left-facing arrival is the gate wall")
:format(c[1], c[2], c[3]))
settle(ow)
eq(#ow.scriptMoves, 0,
("%s (%d,%d): the refusal shove settles"):format(c[1], c[2], c[3]))
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
("%s (%d,%d): the bikeless Cycling Road refusal never shoves into the "
.. "gate wall"):format(c[1], c[2], c[3]))
end
Game.save.onBike, Game.save.forcedBike = false, nil
pushOW("ROUTE_16", 17, 10, "right")
ow = OW
settle(ow)
eq(ow.player.cellX, 16, "an unobstructed refusal shove still moves one cell")
eq(ow.player.cellY, 10, "and only along the arrival axis")
Game.save.onBike, Game.save.forcedBike = false, nil
ow:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
"a save parked inside the Route 16 gate wall is lifted out on load")
eq(ow.player.cellY, 10, "the repair stays on the row it was saved on")
settle(ow)
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
"and the refusal it lands on cannot push it back in")
popToOW()
Game.save.onBike, Game.save.forcedBike = false, nil
-- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only
-- until BOTH boulders are down (CheckBothEventsSet)
Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil
+11 -1
View File
@@ -42,7 +42,8 @@ local function trace(tb)
if row.text then
out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
elseif row.anim then
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer }
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer,
hit = row.hit }
elseif row.drain then
out[#out + 1] = { drain = true }
end
@@ -95,6 +96,9 @@ do
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
eq(rows[spiral] and rows[spiral].isPlayer, true,
"on the player's side of the field")
-- effects.asm:1461-1471
eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6,
"PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)")
local used = indexOfText(rows, "used BIDE!")
local storing = indexOfText(rows, "storing energy!")
check(used and spiral and used < spiral,
@@ -119,6 +123,8 @@ do
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM")
check(dup and not rows[dup].isPlayer, "attributed to the enemy side")
eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3,
"and wAnimationType 3 on the enemy's turn (#1564)")
end
-- locked turns: .BideCheck decrements with no text of its own in pokered and
@@ -151,6 +157,10 @@ do
check(unleashed ~= nil, "the release prints UnleashedEnergyText")
check(hit ~= nil, "the release plays BIDE's own animation (#375)")
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side")
-- core.asm:3526-3528 -> effects.asm:1476-1477
check(rows[hit] and rows[hit].hit == nil,
".UnleashEnergy rejoins PlayCurrentMoveAnimation, which zeroes "
.. "wAnimationType")
check(unleashed and hit and unleashed < hit,
"the animation follows the text")
local drainAt