CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444

This commit is contained in:
bryanthaboi
2026-07-30 11:14:51 -04:00
parent 0f581e2f69
commit 4b9a326f6b
46 changed files with 4171 additions and 173 deletions
+71 -20
View File
@@ -102,6 +102,18 @@ return function(game)
battle.enemy.mon.hp = 400
battle.enemy.shownHP = 400
-- A PIDGEY that level knows WHIRLWIND, and in a wild battle that blows the
-- player out of the fight (result "run") the moment the foe picks it, which
-- takes the untested types down with it. Drop it and the foe keeps the
-- GUST / SAND-ATTACK / QUICK ATTACK the hand-off notes below describe.
-- curMoves aliases mon.moves, so one pass covers both.
for i = #battle.enemy.mon.moves, 1, -1 do
local id = battle.enemy.mon.moves[i].id
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
table.remove(battle.enemy.mon.moves, i)
end
end
-- ---- watch the fx layer ------------------------------------------------
-- one entry per applying-attack row, labelled with the move that queued it
local rows, cur, using = {}, nil, nil
@@ -131,10 +143,13 @@ return function(game)
end
end
-- step n frames, sampling every one, pressing A every `mash` frames
-- step n frames, sampling every one, pressing A every `mash` frames -- but
-- only while a box is up. An A that lands on the FIGHT menu opens the move
-- list and fires the highlighted move, so mashing past the end of a turn
-- starts extra turns behind the driver's back and eventually ends the battle.
local function pump(n, mash, stop)
for i = 1, n do
if mash and i % mash == 0 then
if mash and i % mash == 0 and battle.phase == "messages" then
table.insert(game.input.pressQueue, "a")
end
U.wait(1)
@@ -144,16 +159,34 @@ return function(game)
end
end
-- a full turn is the player's announcement, animation and damage drain plus
-- the foe's, so budget frames for the slowest of them rather than the typical
-- one: a short budget hands the next move a battle still in `messages`
local function toMenu()
pump(400, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
return battle.phase == "menu"
end
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where up/down
-- walk the slots. A press that lands on a frame the battle is not reading
-- input is simply lost, so every step retries instead of assuming.
-- The move list is a flat column in the classic layout, but the wide one
-- lays the four slots out 2x2 (WideBattle.navigate): up/down swap rows and
-- left/right swap columns, so walking down the list there never reaches
-- slots 2 and 4. Return the press that closes the gap in either layout.
local function towardSlot(slot)
if battle.moveIndex == slot then return nil end
if battle:wideLayout() then
local row, col = math.floor((battle.moveIndex - 1) / 2), (battle.moveIndex - 1) % 2
local wantRow, wantCol = math.floor((slot - 1) / 2), (slot - 1) % 2
if row ~= wantRow then return row < wantRow and "down" or "up" end
return col < wantCol and "right" or "left"
end
return battle.moveIndex < slot and "down" or "up"
end
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where the presses
-- above walk the slots. A press that lands on a frame the battle is not
-- reading input is simply lost, so every step retries instead of assuming.
local function useMove(slot)
for _ = 1, 40 do
for _ = 1, 80 do
if battle.phase == "moveSelect" then break end
if battle.phase == "menu" then
if battle.menuIndex ~= 1 then
@@ -161,13 +194,17 @@ return function(game)
else
U.tap(game, "a")
end
else
-- the last turn's boxes are still up: keep turning pages
U.tap(game, "a")
end
U.wait(4)
end
if battle.phase ~= "moveSelect" then return false end
for _ = 1, 30 do
if battle.moveIndex == slot then break end
U.tap(game, battle.moveIndex < slot and "down" or "up")
local dir = towardSlot(slot)
if not dir then break end
U.tap(game, dir)
U.wait(3)
end
if battle.moveIndex ~= slot then return false end
@@ -193,15 +230,22 @@ return function(game)
check("the battle reached its FIGHT menu", toMenu())
for _, m in ipairs(MOVES) do
local sent = useMove(m.slot)
check("chose " .. m.id .. " from the move menu", sent)
if sent then
-- catch the shake mid-flight for the screenshot: the offset is live for
-- only a couple of dozen frames
local shotAt
-- catch the shake mid-flight for the screenshot: the offset is live for
-- only a couple of dozen frames
local shotAt
-- HYPNOSIS lands 6 times in 10 and TACKLE 19 in 20, and a miss plays no
-- applying-attack animation at all, so give each move a few turns to
-- connect instead of reading a whiffed one as a regression
local sent, r
for _ = 1, 8 do
-- the foe leans on SAND-ATTACK, and stacked accuracy drops make even
-- TACKLE whiff turn after turn, so hand the accuracy back each try
battle.player.stages.accuracy = 0
sent = useMove(m.slot)
if not sent then break end
-- A every 8 frames: the text box between the announcement and the
-- animation waits on the button like any other
pump(400, 8, function()
pump(1200, 8, function()
local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0
or (not m.peak and battle.fx.blink
and battle.fx.blink.frames > 0))
@@ -209,12 +253,19 @@ return function(game)
shotAt = true
U.shot(game, DIR .. "/" .. SHOTS[m.id])
end
local r = lastRowFor(m.id)
return r ~= nil and battle.fx.shakeProg == nil
and (r.frames > 0 or r.blinked)
local row = lastRowFor(m.id)
-- back at the menu with the row in hand: the turn is over either way
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
return row ~= nil and battle.fx.shakeProg == nil
and (row.frames > 0 or row.blinked)
end)
toMenu()
local r = lastRowFor(m.id)
r = lastRowFor(m.id)
if r then break end
U.log(" " .. m.id .. " did not connect that turn; using it again")
end
check("chose " .. m.id .. " from the move menu", sent)
if sent then
check(m.id .. " queued an applying-attack row", r ~= nil)
if r then
check(("%s is animation type %d (got %s)")
@@ -0,0 +1,182 @@
-- Walking into grass with no useable POKeMON must black the player out, not
-- cancel the battle and hand the map back (#425). pokered core.asm:158-162
-- runs .checkAnyPartyAlive after the intro and jumps to HandlePlayerBlackOut
-- (core.asm:1145-1166, PlayerBlackedOutText2). Machine half also in
-- tests/parity_blackout_no_party.lua. Never under POKEPORT_SPEED: the wipe,
-- the theme change and the two text boxes come apart from each other.
-- POKEPORT_DRIVER=tests/drivers/blackout_no_party_bug425_test.lua POKEPORT_IDENTITY=bug425 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROUTE_1"
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function waitFor(fn, limit)
for _ = 1, limit do
if fn() then return true end
U.wait(1)
end
return false
end
-- every song request in order, so "the battle theme was never restored"
-- and "the map theme was never playing" read differently in the log
local songs = {}
local realPlay = Music.play
Music.play = function(data, song, loop, ctx)
songs[#songs + 1] = { song = song, frame = U.frame() }
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
return realPlay(data, song, loop, ctx)
end
local function playedAfter(song, frame)
for _, s in ipairs(songs) do
if s.song == song and s.frame >= frame then return s end
end
return nil
end
local opts = game.save.options
if (opts and opts.musicVol or 0) == 0 then
U.log("MUSIC VOLUME IS 0: the theme swap this bug is about is silent.")
U.log("Raise it in OPTION, quit and rerun; the log half still holds.")
end
if (opts and opts.sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the wipe and the text beeps will be")
U.log("silent too.")
end
-- MAGIKARP with SPLASH only would still be a battle; 0 HP is the state
-- under test, and money is the blackout's other visible half
game.save.party = { Pokemon.new(game.data, "MAGIKARP", 10) }
local mon = game.save.party[1]
local full = mon.stats.hp
mon.hp = 0
game.save.money = 3000
-- Route 1's first grass row. data/generated/maps.lua holds the tiles
-- (pokered data/maps/objects/Route1.asm has no grass in it), so the cell is
-- checked against the loaded map and rescanned if a map edit moved it.
local GRASS = { x = 10, y = 6 }
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
local ow = game.overworld
local function grassAt(cx, cy)
return ow.map:isGrassCell(cx, cy) and ow.map:isWalkableCell(cx, cy)
end
if not grassAt(GRASS.x, GRASS.y) then
local found
for y = 0, ow.map.heightCells - 1 do
for x = 0, ow.map.widthCells - 1 do
-- a neighbouring grass cell too, so the hand-off half below has
-- somewhere to step without leaving the patch
if grassAt(x, y) and grassAt(x + 1, y) then found = { x = x, y = y } break end
end
if found then break end
end
if found then
U.log(("(%d, %d) is not grass any more, standing on")
:format(GRASS.x, GRASS.y), found.x, found.y)
GRASS = found
U.teleport(game, MAP, found.x, found.y, "down")
ow = game.overworld
end
end
check("the player is standing in " .. MAP .. " grass",
grassAt(GRASS.x, GRASS.y))
local heal = ow:healPoint()
U.log("heal point:", heal.map, heal.x, heal.y)
local MAP_SONG = game.data.audio.mapSongs[MAP]
local HEAL_SONG = game.data.audio.mapSongs[heal.map]
local WILD_SONG = game.data.audio.battle.wild
U.log("songs:", tostring(MAP_SONG), tostring(WILD_SONG), tostring(HEAL_SONG))
-- the encounter OverworldState:checkEncounter would build for this cell
local encDef = game.data.encounters[MAP]
local slots = encDef and encDef.grass and encDef.grass.slots
check(MAP .. " has a grass encounter table", slots ~= nil and #slots > 0)
if not slots then
U.log("Nothing can be encountered here; rerun after a ROM re-import.")
while true do coroutine.yield() end
end
local start = U.frame()
local battle = BattleState.newWild(game, slots[1].species, slots[1].level)
check("the encounter starts out flagged dead (no healthy party)",
battle.dead == true)
local got
battle.onFinish = function(result)
got = result
ow:afterBattle(result, battle)
end
ow:pushBattle(battle)
check("the wipe started the battle theme",
waitFor(function() return playedAfter(WILD_SONG, start) ~= nil end, 120))
local function box()
local top = game.stack:top()
return getmetatable(top) == TextBox and top or nil
end
local shown = waitFor(function() return box() ~= nil end, 600)
check("the blackout text came up over the map", shown)
if shown then
local lines = {}
for _, page in ipairs(box().pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
local text = table.concat(lines, " / ")
U.log("box reads:", text)
check("it is PlayerBlackedOutText2, both paragraphs",
text:find("out of", 1, true) ~= nil
and text:find("blacked", 1, true) ~= nil)
U.wait(60)
U.shot(game, SHOT_DIR .. "/bug425_blackout.png")
U.log("captured", SHOT_DIR .. "/bug425_blackout.png")
end
-- page both paragraphs, then let the warp run
for _ = 1, 12 do
if not box() then break end
U.tap(game, "a")
U.wait(20)
end
local warped = waitFor(function()
return game.overworld and game.overworld.map
and game.overworld.map.id == heal.map
and not game.overworld.transitioning
end, 900)
check("the player was warped to the heal point map " .. heal.map, warped)
check("onFinish reported a loss, not a cancelled battle", got == "lose")
check("the party was revived", mon.hp == full)
check("half the money is gone (3000 -> 1500)", game.save.money == 1500)
if HEAL_SONG then
check("the heal point's own theme replaced the battle theme",
playedAfter(HEAL_SONG, start) ~= nil)
end
local stillBattle = songs[#songs] and songs[#songs].song == WILD_SONG
check("the battle theme is not what is still playing", not stillBattle)
U.log(("%d passed, %d failed"):format(pass, fail))
-- re-arm for the hand-off: same 0 HP party, back in the grass
mon.hp = 0
game.save.money = 3000
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
U.log("MAGIKARP is at 0 HP again and you are back in the Route 1 grass.")
U.log("Step around until an encounter rolls: the wipe and the battle theme,")
U.log("then the two boxes over the map with the route theme back under them,")
U.log("then the warp to Pallet Town. Landing back in the grass with the")
U.log("battle music looping, over and over, is the bug.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,171 @@
-- Eye check on the Mt Moon B2F Jessie & James ambush (#423): the duo walk up
-- to the player and leave behind a fade. pokeyellow scripts/MtMoonB2F.asm
-- MtMoonB2FScript_49e15 simulates PAD_UP, Script6/Script9 walk Jessie six and
-- James five LEFT steps, Script8/Script11 face them; Script14 hides them between
-- GBFadeOutToBlack / GBFadeInFromBlack. No POKEPORT_SPEED (the fade and the
-- theme sting ride the audio clock), and no POKEPORT_IDENTITY (it re-imports):
-- POKEPORT_VERSION=yellow SHOT_DIR=/tmp/shots POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/jessie_james_mtmoon_bug423_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local GameVersion = require("src.core.GameVersion")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
pcall(function() io.stdout:setvbuf("no") end) -- LOVE block-buffers stdout
-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 JESSIE at (9,3),
-- object 6 JAMES at (9,4). MtMoonB2FScript_49e15's trigger is (3,5), so the
-- PAD_UP step puts the player on (3,4) and the walks land (3,3) and (4,4).
local MAP = "MT_MOON_B2F"
local TRIGGER = { x = 3, y = 5 }
local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES"
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
local WANT = {
player = { x = TRIGGER.x, y = TRIGGER.y - 1 },
jessie = { x = TRIGGER.x, y = TRIGGER.y - 2, facing = "down" },
james = { x = TRIGGER.x + 1, y = TRIGGER.y - 1, facing = "left" },
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function npcNamed(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- ---- what the eye cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush and the")
U.log(" bubble blip over the player are both silent, so the audio half")
U.log(" cannot be judged. Set SFX to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
check("running Yellow", GameVersion.isYellow())
-- one strong mon with one damaging move: a stat move stalls the A-mash
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = { { id = "TACKLE", pp = 35 } }
game.save.party = { mon }
game.save.flags[BEAT] = nil
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
-- ---- reach the trigger ---------------------------------------------------
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
local ow = game.overworld
U.hold(game, "up", 20)
U.wait(10)
if not ow.runner:isRunning() then
-- a map or mod edit blocked the approach from below: step in from whatever
-- free walkable neighbour is left
local sides = { { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" } }
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("approach from below failed, stepping in from", cx, cy)
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
U.hold(game, s[3], 20)
U.wait(10)
if ow.runner:isRunning() then break end
end
end
end
check("the ambush script is running", ow.runner:isRunning())
U.shot(game, DIR .. "/jj423_0_trigger.png")
-- ---- the duo close in ----------------------------------------------------
-- Sample every mash: the closed-in positions only hold between the motto and
-- the challenge line, and the battle push ends the window.
local closed = nil
local battle = nil
for _ = 1, 1500 do
local top = game.stack:top()
if getmetatable(top) == BattleState then
battle = top
break
end
local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES)
if not closed and jessie and james and not jessie.moving and not james.moving
and jessie.cellX == WANT.jessie.x and james.cellX == WANT.james.x then
closed = {
px = ow.player.cellX, py = ow.player.cellY,
jx = jessie.cellX, jy = jessie.cellY, jf = jessie.facing,
mx = james.cellX, my = james.cellY, mf = james.facing,
}
U.shot(game, DIR .. "/jj423_1_scene.png")
end
U.tap(game, "a")
U.wait(3)
end
check("the duo walked in instead of staying at (9,3) and (9,4)", closed ~= nil)
closed = closed or {}
U.log("player", tostring(closed.px), tostring(closed.py),
"jessie", tostring(closed.jx), tostring(closed.jy), tostring(closed.jf),
"james", tostring(closed.mx), tostring(closed.my), tostring(closed.mf))
check("the player took the simulated PAD_UP step onto (3,4)",
closed.px == WANT.player.x and closed.py == WANT.player.y)
check("Jessie is standing directly above the player",
closed.jx == WANT.jessie.x and closed.jy == WANT.jessie.y)
check("Jessie is facing down at him", closed.jf == WANT.jessie.facing)
check("James is standing beside the player",
closed.mx == WANT.james.x and closed.my == WANT.james.y)
check("James is facing left at him", closed.mf == WANT.james.facing)
check("the challenge line opened the battle", battle ~= nil)
if battle then
for _ = 1, 240 do
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
U.wait(1)
end
U.shot(game, DIR .. "/jj423_1b_battle.png")
end
-- ---- and leave behind a fade --------------------------------------------
local peakFade, fadeShot = 0, false
local settled = false
for i = 1, 6000 do
local overlay = ow.fadeOverlay
local alpha = overlay and overlay.alpha or 0
if alpha > peakFade then peakFade = alpha end
if game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and game.save.flags[BEAT] then
settled = true
break
end
if alpha > 0.35 and not fadeShot then
fadeShot = true
U.shot(game, DIR .. "/jj423_2_fade.png")
end
U.tap(game, "a")
-- step frame by frame while the overlay lives: a 12-frame ramp sampled
-- every third frame reads as no fade at all
U.wait((alpha > 0 or i > 1200) and 1 or 3)
end
U.shot(game, DIR .. "/jj423_3_done.png")
check("the scene ran to its end", settled)
check("the beat flag is set", game.save.flags[BEAT] == true)
check("Jessie is gone", npcNamed(ow, JESSIE) == nil)
check("James is gone", npcNamed(ow, JAMES) == nil)
U.log(("peak fade alpha %.2f"):format(peakFade))
check("the exit dipped the screen toward black", peakFade > 0.3)
U.log("jj423_1_scene.png must show Jessie one cell ABOVE the player looking")
U.log("down at him and James on his right looking left, not the pair parked")
U.log("across the corridor; the exclamation bubble pops over the player just")
U.log("before they start walking, so watch the window live for that beat.")
U.log("jj423_2_fade.png is the room dimming with the duo still standing in")
U.log("it, and jj423_3_done.png is the same room lit and empty; the second")
U.log("Music_MeetJessieJames sting covers the fade, then the cave theme.")
while true do
coroutine.yield()
end
end
+125
View File
@@ -0,0 +1,125 @@
-- Eye check on the enemy pic in the Mt Moon Jessie & James fight (#439).
-- pokeyellow home/trainers2.asm:36-50 IsFightingJessieJames points
-- wTrainerPicPointer at JessieJamesPic when ROCKET fights with wTrainerNo
-- >= $2a, leaving GetTrainerName alone. Needs a Yellow cache re-imported
-- after #439. No POKEPORT_SPEED: the theme sting rides the audio clock.
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/jj_battle_pic_bug439_test.lua POKEPORT_IDENTITY=bug439 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokeyellow data/maps/objects/MtMoonB2F.asm parks JESSIE at (9, 3) and
-- JAMES at (9, 4); MtMoonB2FScript's coordinate trigger is the (3, 5) tile
-- west of them, reachable from (3, 6) once a fossil is in the bag.
local MAP = "MT_MOON_B2F"
local TRIGGER = { x = 3, y = 5 }
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the eye cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush is")
U.log(" silent, so the scene's audio half cannot be judged. Set SFX")
U.log(" to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
check("running Yellow", GameVersion.isYellow())
local trainer = game.data.trainers.OPP_ROCKET
check("OPP_ROCKET is in the trainer table", trainer ~= nil)
trainer = trainer or {}
check("the name is still ROCKET, as GetTrainerName leaves it",
trainer.name == "ROCKET")
-- a Yellow cache built before #439 has no duo pic at all, and every check
-- below it would then be measuring the old grunt-only behaviour
if not trainer.picJessieJames then
U.log("FAIL the Yellow cache predates #439: no duo pic was extracted.")
U.log(" Re-import the Yellow ROM from the launcher, then run again.")
end
check("the cache carries a duo pic", trainer.picJessieJames ~= nil)
if trainer.picJessieJames then
check("the duo pic file is on the read path",
CacheFs.exists(trainer.picJessieJames)
or love.filesystem.getInfo(trainer.picJessieJames) ~= nil)
end
local picked = BattleState.trainerPicPath(trainer, "OPP_ROCKET", 42)
U.log("pic:", tostring(picked))
check("party 42 selects the duo pic", picked == trainer.picJessieJames)
check("a lone grunt party still selects the class pic",
BattleState.trainerPicPath(trainer, "OPP_ROCKET", 3) == trainer.pic)
-- ---- reach the ambush ---------------------------------------------------
-- one strong mon so the fight is survivable if the reader plays it out
game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) }
game.save.flags[BEAT] = nil
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
local ow = game.overworld
U.hold(game, "up", 20)
U.wait(10)
if not ow.runner:isRunning() then
-- a map or mod edit blocked the approach from below: come down onto the
-- trigger from whichever free walkable neighbour is left
local sides = {
{ 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("approach from below failed, stepping in from", cx, cy)
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
U.hold(game, s[3], 20)
U.wait(10)
if ow.runner:isRunning() then break end
end
end
end
check("the ambush script is running", ow.runner:isRunning())
U.shot(game, DIR .. "/jj439_0_trigger.png")
-- A-mash the motto and the challenge line until the battle is pushed, then
-- stop: the trainer pic stays up until the intro text is dismissed
local battle = nil
for _ = 1, 1200 do
local top = game.stack:top()
if getmetatable(top) == BattleState then
battle = top
break
end
U.tap(game, "a")
U.wait(3)
end
check("the ambush opened a battle", battle ~= nil)
if battle then
for _ = 1, 240 do
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
U.wait(1)
end
check("the enemy trainer pic is on screen", battle.showEnemyTrainer == true)
U.shot(game, DIR .. "/jj439_1_intro.png")
U.wait(40)
U.shot(game, DIR .. "/jj439_2_intro_late.png")
U.log("intro text:", tostring(battle.introText))
end
U.log("Both intro shots must show the two-character duo pic: a woman on the")
U.log("left and a man on the right, one 7x7 pic, no Meowth -- not the single")
U.log("crouching ROCKET grunt. The name in the intro line stays ROCKET.")
U.log("The theme under the trigger shot is Music_MeetJessieJames.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,90 @@
-- Manual check of the launcher's Delete labels (#433): a stale hit rect let a
-- click on the Mods tab delete a save slot, and Delete never asked first.
-- No pokered counterpart: src/import/RomImporter.lua is this port's own desktop
-- launcher, so hit-rect coords here come from the panel that draws them.
-- POKEPORT_DRIVER=tests/drivers/launcher_delete_bug433_test.lua POKEPORT_IDENTITY=bug433 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Leave POKEPORT_SPEED unset: this one is clicked by hand, at real time.
return function(game)
local U = dofile("tests/drivers/util.lua")
local RomImporter = require("src.import.RomImporter")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local version = GameVersion.get()
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is
-- a scripted run), so bring a real interactive launcher up ourselves and take
-- over the frame + click handlers the launcher normally owns.
local imp = RomImporter.new(function() end, { launcher = true })
imp.play = function() U.log("Play is inert here: the game is already booted.") end
local prevDraw, prevPressed = love.draw, love.mousepressed
local prevKey, prevText = love.keypressed, love.textinput
love.draw = function()
imp:update(love.timer.getDelta())
imp:draw()
end
love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end
love.keypressed = function(key) imp:keypressed(key) end
love.textinput = function(t) imp:textinput(t) end
local function restore()
love.draw, love.mousepressed = prevDraw, prevPressed
love.keypressed, love.textinput = prevKey, prevText
end
local function slotCount()
return #(SaveData.listSlots(version) or {})
end
-- Two throwaway rows to click on, in this identity's save dir only.
while slotCount() < 2 do imp:_newSlot(version) end
imp.tab = version
imp.pageScroll = 0
U.wait(4)
-- The Delete rects exist only after the panel that owns them has drawn.
local del = (imp.slotDeleteRects or {})[1]
if not check("the save panel drew a Delete rect for a slot row", del ~= nil) then
U.log("Nothing to click: the", version, "tab drew no slot rows.")
restore()
while true do coroutine.yield() end
end
local spot = { x = del.x + del.width / 2, y = del.y + del.height / 2, id = del.id }
U.log(("Delete for %s sits at (%d, %d)."):format(spot.id, spot.x, spot.y))
imp.tab = "mods"
U.wait(4)
check("a Mods frame leaves no save Delete rect live",
imp.slotDeleteRects == nil)
local before = slotCount()
imp:mousepressed(spot.x, spot.y, 1)
U.wait(4)
check("the reporter's click on that spot from Mods deletes nothing",
slotCount() == before and imp._confirmDelete == nil)
imp.tab = version
U.wait(4)
imp:mousepressed(spot.x, spot.y, 1)
U.wait(2)
check("one click on Delete arms instead of deleting",
slotCount() == before and imp._confirmDelete ~= nil
and imp._confirmDelete.id == spot.id)
imp:mousepressed(4, 4, 1) -- a press anywhere else
U.wait(2)
check("a press elsewhere takes the arm back off", imp._confirmDelete == nil)
check("the slot survived the whole sequence", slotCount() == before)
U.log("The launcher on screen is live; the", version, "tab is up, disarmed.")
U.log("Clicking Delete once should turn that label red and read \"Sure?\"")
U.log("without the row moving, and go back to \"Delete\" after ~4 seconds or")
U.log("on any other click. A second click on \"Sure?\" removes the slot.")
while true do
coroutine.yield()
end
end
+22 -10
View File
@@ -105,11 +105,13 @@ return function(game)
speech.picFlip == true and speech.pic == speech.demoPic)
U.shot(game, DIR .. "/oak_nido_2a.png")
-- one A per page of 2A; picFlip must survive every one of them
-- one A per page of 2A; picFlip must survive every one of them. The page
-- count is not fixed (an A during the typewriter only finishes the line), so
-- keep turning until the step moves on rather than budgeting presses.
local held = true
for _ = 1, 8 do
for _ = 1, 60 do
U.tap(game, "a")
U.wait(10)
U.wait(12)
if speech.picFlip ~= true or speech.pic ~= speech.demoPic then
held = false
break
@@ -126,22 +128,32 @@ return function(game)
U.shot(game, DIR .. "/oak_nido_2b.png")
U.log("shots in", DIR)
-- put the beat back so the transition can be watched live: pop the box, rewind
-- the step counter and re-run the demo beat (wipe, cry, page A)
-- put the beat back so the page break can be watched live: pop the box and
-- rewind the step counter. The wipe and the cry are both skipped on the way
-- back in -- the sprite is already standing there and it already called once,
-- so replaying them reads as NIDORINO entering twice -- leaving just page A.
for _ = 1, 8 do
if top() == speech then break end
game.stack:pop()
end
if top() == speech then
speech.picReveal = nil
local Sound = require("src.core.Sound")
local realReveal, realCry = speech.revealPic, Sound.playCry
speech.revealPic = function(self, _, next)
self.picReveal = nil
if next then next() end
end
Sound.playCry = function() end
speech.step = demoIdx
speech:runStep(speech.steps[demoIdx])
U.wait(60)
speech.revealPic, Sound.playCry = realReveal, realCry
U.wait(30)
end
U.log("NIDORINO wipes in from the right facing LEFT, horn toward screen-left.")
U.log("Press A to turn the page: the sprite must not move or mirror, it stays")
U.log("facing left until the screen fades to white for the naming beat.")
U.log("NIDORINO is standing where the wipe left it, facing LEFT with its horn")
U.log("toward screen-left, and page A is back up. Press A to turn the page:")
U.log("the sprite must not move or mirror, it stays facing left until the")
U.log("screen fades to white for the naming beat.")
while true do
coroutine.yield()
@@ -0,0 +1,316 @@
-- Yellow follower cadence (#424): the ledge hop deferred a player step, the
-- standing idle counters at OverworldLoop's two DelayFrames per pass, and the
-- pikapic beat timed by its own pikapic_setduration (pokeyellow engine/pikachu/
-- pikachu_follow.asm Func_fc803/Func_fcc64/Func_fcc92, home/overworld.asm:43,
-- pikachu_pic_animation.asm ExecutePikaPicAnimScript). Never POKEPORT_SPEED:
-- it scales the logic clock alone and every number judged here is a frame count.
-- POKEPORT_DRIVER=tests/drivers/pikachu_cadence_bug424_test.lua POKEPORT_IDENTITY=bug424 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local Pokemon = require("src.pokemon.Pokemon")
local PikachuFollower = require("src.world.PikachuFollower")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local results = {}
local function check(label, ok)
results[#results + 1] = { label = label, ok = ok and true or false }
return ok
end
local function report()
for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end
end
local function idleForever()
while true do coroutine.yield() end
end
if not GameVersion.isYellow() then
check("running the Yellow cache (POKEPORT_VERSION=yellow)", false)
report()
U.log("Red and Blue have no follower, no idle rolls and no pikapic box.")
idleForever()
end
-- ShouldPikachuSpawn's preconditions (pikachu_follow.asm): the lab gift
-- happened and a healthy Pikachu leads the party. Without both there is no
-- follower and every count below would read as the bug.
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
game.save.onBike = false
local frames = 0
local function step(n)
for _ = 1, n do
coroutine.yield()
frames = frames + 1
end
end
-- ROUTE_1 (pokeyellow data/maps/objects/Route1.asm has no object near the
-- ledge run) carries the first plain south-facing ledge; (5, 4) is the cell
-- to hop from, re-derived below out of the cached blocks against
-- data.field.ledges so a map edit moves the test instead of breaking it.
local MAP = "ROUTE_1"
local WANT = { x = 5, y = 4 }
local function southLedge(map, cx, cy)
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end
-- the landing plus one more open cell below it: the queued hop only drains
-- on the player's next step, so the ledge needs room for that step
if not (map:isWalkableCell(cx, cy) and map:isWalkableCell(cx, cy - 1)
and map:isWalkableCell(cx, cy + 2)
and map:isWalkableCell(cx, cy + 3)) then
return false
end
local standing, front = map:cellTile(cx, cy), map:cellTile(cx, cy + 1)
for _, l in ipairs(game.data.field.ledges or {}) do
if (l.tileset or "OVERWORLD") == map.def.tileset
and l.facing == "down" and l.input == "down"
and l.standingTile == standing and l.ledgeTile == front then
return true
end
end
return false
end
U.teleport(game, MAP, WANT.x, WANT.y - 1, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
if not ow then
report()
idleForever()
end
local hx, hy = WANT.x, WANT.y
if not southLedge(ow.map, hx, hy) then
for cy = 1, ow.map.heightCells - 4 do
for cx = 0, ow.map.widthCells - 1 do
if southLedge(ow.map, cx, cy) then hx, hy = cx, cy break end
end
if hx ~= WANT.x or hy ~= WANT.y then break end
end
U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y),
hx, hy)
U.teleport(game, MAP, hx, hy - 1, "down")
U.wait(20)
ow = game.overworld
end
check(("a south ledge to hop at (%d, %d)"):format(hx, hy),
southLedge(ow.map, hx, hy))
local function follower()
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local npc = follower()
if not check("the follower spawned", npc ~= nil) then
report()
idleForever()
end
-- ---- the standing idle clock ----
-- Func_fc803 reloads $20 and decrements it once per UpdateSprites call, and
-- a standing OverworldLoop burns two DelayFrames per pass, so one counter
-- unit is two of this port's fixed steps: the glance lands on frame 64.
local function glanceLeft()
local idle = npc.idle
if idle and idle.kind == "wait" then return idle.frames end
return nil
end
for _ = 1, 90 do
if glanceLeft() then break end
step(1)
end
if not check("the follower is in the standing glance state", glanceLeft() ~= nil) then
report()
idleForever()
end
local last, lastAt = glanceLeft(), frames
-- the first change lands a partial unit after the sampling started, so it
-- only sets the baseline: every gap measured after it is a whole unit
local minGap, maxGap, seen, resetAt, period = 99, 0, 0, nil, nil
for _ = 1, 300 do
step(1)
local now = glanceLeft()
if now == nil then break end
if now ~= last then
seen = seen + 1
local gap = frames - lastAt
if seen > 1 then
if gap < minGap then minGap = gap end
if gap > maxGap then maxGap = gap end
end
if now > last then
if resetAt then period = period or frames - resetAt end
resetAt = frames
end
last, lastAt = now, frames
end
end
check("the idle counter loses one unit every two frames, not every frame",
minGap == 2 and maxGap == 2)
check("a full glance takes 64 frames ($20 units), not 32", period == 64)
U.log("idle counter gap", minGap, "to", maxGap, "frames; glance period",
tostring(period))
-- ---- the ledge hop, one player step late ----
-- Func_fcc08 hands a ledge step to Func_fcc64, which appends the $5-$8 hop
-- on the takeoff step and nothing on the landing step; Func_fcc92 cannot pop
-- a command with nothing queued behind it, so the hop waits for the player's
-- next step and Pikachu sits two cells back until then.
local sawHop, hopLen = false, nil
local function sample()
local f = follower()
if f and f.hopStep then
sawHop = true
hopLen = hopLen or f.stepFrames
end
end
for _ = 1, 200 do
table.insert(game.input.pressQueue, "down")
game.input.state["down"] = true
step(1)
sample()
if (ow.player.hopFrames or 0) > 0 then break end
end
game.input.state["down"] = false
check("the player's ledge hop fired", (ow.player.hopFrames or 0) > 0)
for _ = 1, 150 do
step(1)
sample()
end
npc = follower()
check("the follower waits on the cell the player took off from",
npc ~= nil and npc.cellX == hx and npc.cellY == hy)
check("it does not hop while the player stands still", not sawHop)
if npc then
U.log("player at", ow.player.cellX, ow.player.cellY,
"| follower waiting at", npc.cellX, npc.cellY)
end
-- one more player step drains the buffered hop
for _ = 1, 120 do
table.insert(game.input.pressQueue, "down")
game.input.state["down"] = true
step(1)
sample()
if ow.player.cellY > hy + 2 then break end
end
game.input.state["down"] = false
for _ = 1, 120 do
step(1)
sample()
end
check("the deferred hop then ran", sawHop)
-- Func_fc7aa jumps to Func_fca0a on the $4 movement status before it asks
-- AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer, so a hop is never a
-- Fast step even though its goal is two cells off
local walkLen = ow.player.stepFramesCur or ow.player.stepFrames or 16
check("the hop takes a full step's frames, not a halved Fast step",
hopLen == walkLen)
U.log("hop step frames", tostring(hopLen), "against a walk's", walkLen)
npc = follower()
check("the follower cleared the ledge row",
npc ~= nil and npc.cellY > hy + 1
and ow.map:isWalkableCell(npc.cellX, npc.cellY))
-- ---- the pikapic beat ----
-- happiness 255 with mood 200 is the matrix cell for emotion 20 (a heart
-- bubble and PikaPicAnimScript20: 50 ticks, so 150 frames, with the second
-- full-body pose up for 24 of every 40 ticks).
game.save.pikachuHappiness = 255
game.save.pikachuMood = 200
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
{ "left", -1, 0 }, { "right", 1, 0 } }
local function facingFollower()
local fx, fy = ow.player:facingCell()
return follower() ~= nil and ow:npcAtCell(fx, fy) == follower()
end
if not facingFollower() then
for _, d in ipairs(DIRS) do
if npc and npc.cellX == ow.player.cellX + d[2]
and npc.cellY == ow.player.cellY + d[3] then
U.tap(game, d[1])
U.wait(6)
break
end
end
end
check("player is facing the follower", facingFollower())
-- a muted run sounds like a beat that never started, so say so up front
local sfxVol = game.save.options and game.save.options.sfxVol
U.log("save.options.sfxVol:", tostring(sfxVol))
if (sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the squeak cannot be heard whether or not")
U.log("WARNING one plays. Raise it in OPTIONS before judging the sound.")
end
U.tap(game, "a")
U.wait(1)
local emote = ow.emote
if not check("the A press raised the framed pikapic", emote ~= nil) then
report()
idleForever()
end
check("the beat runs the script's 50 ticks at three frames each",
emote.pikaTotal == 150)
check("its length is not the old flat 50 frame hold", emote.pikaTotal ~= 50)
check("the pikapic marks itself skippable", emote.skippable == true)
check("emotion 20 carries an overlay frameset", type(emote.pikaSeq) == "table")
local lifted, grounded, liftShot = 0, 0, nil
for _ = 1, 60 do
if not ow.emote then break end
local lift = PikachuFollower.picLift(ow.emote)
if lift > 0 then
lifted = lifted + 1
if not liftShot then
liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png"
if U.shot(game, liftShot) then U.log("captured", liftShot)
else liftShot = nil end
end
else
grounded = grounded + 1
end
step(1)
end
check("the pic sits on the box floor for part of the beat", grounded > 0)
check("and rises off it for another part", lifted > 0)
U.log("of the first 60 frames the pic was up for", lifted, "and down for",
grounded)
local leftWhenCut = ow.emote and ow.emote.frames or 0
U.tap(game, "a")
U.wait(2)
check("A cuts the beat short (PikaPicAnimTimerAndJoypad)",
leftWhenCut > 0 and ow.emote == nil)
U.log("cut with", leftWhenCut, "frames still on the clock")
report()
-- hand the pad back one cell above the same ledge so Down replays the hop
U.teleport(game, MAP, hx, hy - 1, "down")
U.wait(20)
U.log("Hold Down: Pikachu should walk up to the ledge lip and WAIT there,")
U.log("then clear both cells in one motion on your next step, at walking")
U.log("speed. Let go and count the first glance: about a second, not half.")
U.log("Face it, press A: the framed pic hops inside the box for the whole")
U.log("beat, and A or B cuts the beat off early.")
idleForever()
end
@@ -71,11 +71,14 @@ return function(game)
-- a cell the player can hop south from, with two walkable cells above it
-- to walk in from and a walkable landing two cells below
local function hopCellOk(map, cx, cy)
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 2)) then
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then
return false
end
if not map:isWalkableCell(cx, cy) then return false end
if not map:isWalkableCell(cx, cy + 2) then return false end
-- one more open cell below the landing: the queued hop only drains on
-- the player's NEXT step, so the ledge needs room to take it (#424)
if not map:isWalkableCell(cx, cy + 3) then return false end
if not (map:isWalkableCell(cx, cy - 1)
and map:isWalkableCell(cx, cy - 2)) then
return false
@@ -209,6 +212,26 @@ return function(game)
U.log("the arc was already over before the capture; no mid-air frame")
end
-- The hop command is buffered a step behind the player: Func_fcc64 appends
-- nothing for the landing half of the jump, so Func_fcc92 cannot pop the
-- hop until another command queues behind it. Pikachu therefore walks to
-- the cell the player took off from and waits there (#424).
step(120)
local waiting = follower()
check("the follower waits on the cell the player took off from",
waiting ~= nil and waiting.cellX == hx and waiting.cellY == hy)
check("it has not hopped while the player stood still", not sawHopStep)
-- one more player step drains the queued hop
for _ = 1, 90 do
table.insert(game.input.pressQueue, "down")
game.input.state["down"] = true
coroutine.yield()
sample()
if ow.player.cellY > hy + 2 then break end
end
game.input.state["down"] = false
-- let the follower finish crossing (its hop is one normal step's frames
-- with a doubled step vector, pikachu_follow.asm Func_fca0a)
step(120)
@@ -242,9 +265,11 @@ return function(game)
U.log("Hold Down to hop the ledge again from here.")
U.log("During the arc there should be one flat ellipse on the ground under")
U.log("the player, not a taller blob with a seam across its middle, and")
U.log("Pikachu should clear both cells in one motion. The near miss to")
U.log("watch for is Pikachu pausing a beat on the ledge tile itself.")
U.log("the player, not a taller blob with a seam across its middle.")
U.log("Pikachu should walk up to the cell you jumped from and STAY there")
U.log("until you take another step, then clear both cells in one motion.")
U.log("The near misses to watch for are Pikachu hopping alongside you and")
U.log("Pikachu pausing a beat on the ledge tile itself.")
while true do
coroutine.yield()
+189
View File
@@ -0,0 +1,189 @@
-- A connection crossing has to carry Yellow's follower, not respawn it (#427).
-- pokeyellow home/overworld.asm:648-655 sets bit 4 of
-- wPikachuOverworldStateFlags before LoadMapHeader, so pikachu_follow.asm's
-- SchedulePikachuSpawnForAfterText takes .normal_spawn_state: coords rebased,
-- sprite data left alone. Never POKEPORT_SPEED here, the offsets are pixels.
-- POKEPORT_DRIVER=tests/drivers/pikachu_seam_bug427_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local PikachuFollower = require("src.world.PikachuFollower")
local GameVersion = require("src.core.GameVersion")
local Map = require("src.world.Map")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
if not GameVersion.isYellow() then
check("only Yellow has a follower; re-run with POKEPORT_VERSION=yellow", false)
while true do coroutine.yield() end
end
-- pokeyellow data/maps/objects/PalletTown.asm puts no object on the town's
-- north exit and PALLET_TOWN's north connection to ROUTE_1 carries offset 0,
-- so the landing column is the column walked off the edge.
local MAP = "PALLET_TOWN"
local NORTH = "ROUTE_1"
local COL = 10
local START_Y = 3
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.onBike = false
game.save.repelSteps = 9999
game.save.player.name = "bryan"
U.teleport(game, MAP, COL, START_Y, "up")
U.wait(10)
local ow = game.overworld
local map = ow.map
-- a usable exit column: four clear cells up to the edge, nothing standing
-- in them, and a landing the engine's own edge read accepts (Map.defPassable
-- is what crossConnection consults before it commits to the crossing)
local function exitOK(cx)
for cy = 0, START_Y do
if not map:isWalkableCell(cx, cy) then return false end
-- ow.npcs carries the follower too, and it is standing in this column
local at = ow:npcAtCell(cx, cy)
if at and not at.pikachuFollower then return false end
end
local p = ow.player
local keepX = p.cellX
p.cellX = cx
local dest, ts, lx, ly = ow:connectionLanding("up")
p.cellX = keepX
return dest ~= nil and dest.id == NORTH
and Map.defPassable(dest, ts, lx, ly, false)
end
if not exitOK(COL) then
local found
for cx = 0, map.widthCells - 1 do
if exitOK(cx) then found = cx break end
end
U.log(("column %d no longer walks off the north edge; using column %s")
:format(COL, tostring(found)))
COL = found or COL
U.teleport(game, MAP, COL, START_Y, "up")
U.wait(10)
ow = game.overworld
map = ow.map
end
check(("column %d walks off %s's north edge onto %s"):format(COL, MAP, NORTH),
exitOK(COL))
local pika = PikachuFollower.current(ow)
check("the follower spawned on " .. MAP, pika ~= nil)
-- exactly one step north, then stop: the follower has to be trailing and
-- settled, since a respawn on the cell it already occupies is invisible
local p = ow.player
local stopY = p.cellY - 1
for _ = 1, 60 do
if p.cellY <= stopY and not p.moving then break end
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
coroutine.yield()
end
game.input.state.up = false
U.wait(20)
U.log(("before the seam: player (%d, %d), follower (%s, %s)")
:format(p.cellX, p.cellY, tostring(pika and pika.cellX),
tostring(pika and pika.cellY)))
check("the follower trails one cell behind before the seam",
pika ~= nil and pika.cellX == p.cellX and pika.cellY == p.cellY + 1)
if U.shot(game, SHOT_DIR .. "/bug427_before_seam.png") then
U.log("captured", SHOT_DIR .. "/bug427_before_seam.png")
end
-- Hold up through the crossing, sampling once per logic step (main.lua
-- resumes the driver exactly once per Game:update). The pixel offset
-- between the two sprites is the measurement: a respawn loses the in-flight
-- step, so the offset snaps even when the new instance lands on the right
-- cell. The mid-seam capture is armed inline instead of through U.shot so
-- the walk keeps running while it lands.
local samples, missing, maxJump = 0, 0, 0
local prevX, prevY
local crossedAt, pikaAfter
local frames = 0
while frames < 240 do
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
frames = frames + 1
coroutine.yield()
local npc = PikachuFollower.current(ow)
if not npc then
missing = missing + 1
else
local pl = ow.player
local ox, oy = npc.px - pl.px, npc.py - pl.py
if prevX then
local jump = math.max(math.abs(ox - prevX), math.abs(oy - prevY))
if jump > maxJump then maxJump = jump end
end
prevX, prevY = ox, oy
samples = samples + 1
end
if not crossedAt and ow.map.def.id == NORTH then
crossedAt = frames
pikaAfter = npc
game.capturePath = SHOT_DIR .. "/bug427_mid_seam.png"
end
if crossedAt and frames >= crossedAt + 24 then break end
end
game.input.state.up = false
U.wait(30)
check("the player crossed the seam onto " .. NORTH,
crossedAt ~= nil and ow.map.def.id == NORTH)
check("the follower across the seam is the same instance",
pikaAfter ~= nil and pikaAfter == pika)
check("it is still the same instance once the walk settles",
PikachuFollower.current(ow) == pika)
check(("it never dropped out of ow.npcs (%d of %d frames)")
:format(missing, samples + missing), missing == 0 and samples > 0)
check(("its pixel offset to the player never jumped (max %dpx)")
:format(maxJump), maxJump <= 4)
local settled = PikachuFollower.current(ow)
p = ow.player
if settled then
U.log(("player (%d, %d) facing %s, follower (%d, %d)")
:format(p.cellX, p.cellY, tostring(p.facing),
settled.cellX, settled.cellY))
end
check("the follower came to rest one cell behind",
settled ~= nil and settled.cellX == p.cellX
and settled.cellY == p.cellY + 1)
local trail = ow.pikachuTrail
check("the chased trail cell was rebased into the new map",
trail ~= nil and ow.map:inBounds(trail.x, trail.y))
local mid = io.open(SHOT_DIR .. "/bug427_mid_seam.png", "rb")
if mid then
mid:close()
U.log("captured", SHOT_DIR .. "/bug427_mid_seam.png")
else
U.log("FAIL screenshot did not reach disk:", SHOT_DIR .. "/bug427_mid_seam.png")
end
if U.shot(game, SHOT_DIR .. "/bug427_after_seam.png") then
U.log("captured", SHOT_DIR .. "/bug427_after_seam.png")
end
U.log("You just walked north out of Pallet onto Route 1 with Pikachu behind")
U.log("you. In bug427_mid_seam.png it has to be below you and mid-stride, on")
U.log("the pixels the walk left it on; the bug put it squarely on a cell in")
U.log("front of you instead, once per seam, which is the pop in the report.")
U.log("Warps still respawn it behind you (walk through any door), and a")
U.log("fainted or deposited Pikachu still leaves no follower at a seam.")
while true do
coroutine.yield()
end
end
+13 -1
View File
@@ -196,6 +196,11 @@ return function(game)
type(emote.pikaPic) == "string"
and love.filesystem.getInfo(emote.pikaPic) ~= nil)
check("a cry source was created", reportCries(before))
-- the beat now runs the pikapic script's own pikapic_setduration, three
-- 60Hz frames per tick, so even the shortest script (32 ticks) outlasts
-- the 50 frame hold this port used to serve every emotion (#424)
check("the hold runs the script's own duration, not a flat 50 frames",
(emote.frames or 0) >= 80)
bubbleReport(emote)
U.log("happiness", tostring(game.save.pikachuHappiness or 90),
"mood", tostring(game.save.pikachuMood or 128))
@@ -209,7 +214,14 @@ return function(game)
-- wPikachuEmotionModifier 5 is MapSpecificPikachuExpression's fifth
-- entry, emotion 25: BOLT_BUBBLE plus PCM clip 35. Forcing it takes the
-- mood roll out of the picture, so a missing bubble here is a real fault.
U.wait(70) -- the 50 frame hold, plus slack, before input is looked at again
-- wait the pikapic beat out instead of counting frames: its length is the
-- script's now, and an A press during it would only cut it short
-- (PikaPicAnimTimerAndJoypad, #424)
for _ = 1, 400 do
if not ow.emote then break end
U.wait(1)
end
U.wait(6)
game.save.pikachuEmotionModifier = 5
check("still facing the follower for the second press", facingFollower())
before = #cries
+136
View File
@@ -0,0 +1,136 @@
-- Ear check: Route 8's wave-channel countermelody, at full volume and in the
-- right octave (#429). ROUTE_8 is MUSIC_ROUTES3 (pokered data/maps/songs.asm:22)
-- and Music_Routes3_Ch3 is a dense 2-frame-per-note line; ChipAudio shipped
-- ch3 at 0.25 volume and 0.5 pitch, a quarter as loud and an octave below the
-- hardware's own octave (audio/engine_1.asm:904-944 writes CHAN3's frequency
-- register unmodified). SELECT toggles the 0.1.38 mix back on to compare.
-- POKEPORT_DRIVER=tests/drivers/route8_music_bug429_test.lua POKEPORT_IDENTITY=bug429 POKEPORT_TOUCH=0 love .
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
return function(game)
local U = dofile("tests/drivers/util.lua")
local ChipAudio = require("src.core.ChipAudio")
local Music = require("src.core.Music")
-- pokered data/maps/objects/Route8.asm keeps its trainers at (8,5), (13,9),
-- (26,3-6) and (42,6), and warps at x 1, 8 and 13, so the road east of the
-- gate is clear; (30, 8) is only a listening spot, walk anywhere from it.
local MAP = "ROUTE_8"
local SONG = "Music_Routes3"
local STAND = { x = 30, y = 8, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local musicVol = opts.musicVol or 7
local sfxVol = opts.sfxVol or 7
if musicVol == 0 then
U.log("FAIL music volume is 0: nothing below can be heard at all.")
U.log(" Set MUSIC to 7 in OPTION first.")
end
if sfxVol == 0 then
U.log("FAIL sfx volume is 0, so the step and menu sounds are gone too;")
U.log(" set SFX to 7 in OPTION if the run should sound normal.")
end
check(("music volume %d, sfx volume %d"):format(musicVol, sfxVol),
musicVol > 0)
check(MAP .. " plays " .. SONG,
(game.data.audio.mapSongs or {})[MAP] == SONG)
local vols = ChipAudio.getChannelVolumes()
local pitches = ChipAudio.getChannelPitches()
check(("the shipped wave mix is unity (volume %s, pitch %s)")
:format(tostring(vols[3]), tostring(pitches[3])),
vols[3] == 1 and pitches[3] == 1)
-- render the ch3 layer alone: a silent or near-silent wave track would make
-- every claim below unhearable no matter what the mix says
local song = (game.data.audio.songs or {})[SONG]
local function waveLayer()
return ChipAudio._renderMusicChannelForTest(game.data, song, 1.5, 3)
end
local function measure(sd)
local peak, crossings, prev = 0, 0, sd:getSample(0)
for index = 1, sd:getSampleCount() - 1 do
local sample = sd:getSample(index)
if math.abs(sample) > peak then peak = math.abs(sample) end
if prev * sample < 0 then crossings = crossings + 1 end
prev = sample
end
return peak, crossings
end
local peak, crossings = measure(waveLayer())
check(("the ch3 countermelody renders (peak %.3f, %d zero crossings)")
:format(peak, crossings), peak > 0.02 and crossings > 200)
ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 })
ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 })
local brokenPeak, brokenCrossings = measure(waveLayer())
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
check(("the 0.1.38 mix would render it at peak %.3f and %d crossings")
:format(brokenPeak, brokenCrossings),
brokenPeak < peak * 0.4 and brokenCrossings < crossings * 0.7)
-- the invariant tests/engine/wave_channel_mix_bug429.lua asserts on blobs;
-- this is the same statement measured on the ROM's own Music_Routes3
-- ---- stand on the road with the theme running --------------------------
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit or a mod blocked the cell: take any free neighbour, the
-- audio is what is being judged and any cell on Route 8 will do
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow.map.id == MAP)
local function restart()
-- the playback queue holds ~6s of already-synthesized audio, so a mix
-- change is inaudible until the song is rebuilt from the new values
Music.stop()
Music.playMap(game.data, MAP)
end
restart()
U.wait(60)
U.log("Route 8's theme is running and the pad is yours; walking works.")
U.log("Under the lead there is a fast wave line, about two notes per beat,")
U.log("as loud as the melody and no lower than the octave it is written in.")
U.log("SELECT swaps in the 0.1.38 mix (quarter volume, a further octave")
U.log("down: a dull rumble that all but vanishes), SELECT again restores it.")
local broken = false
local held = false
while true do
local down = game.input:isDown("select")
if down and not held then
broken = not broken
if broken then
ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 })
ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 })
U.log("0.1.38 mix: wave volume 0.25, one octave down")
else
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
U.log("authentic mix: wave volume 1, pitch 1")
end
restart()
end
held = down
coroutine.yield()
end
end
+14 -5
View File
@@ -121,15 +121,24 @@ return function(game)
check("and the TM is still hers while it is up", not hasTm())
U.shot(game, DIR .. "/bug393_1_scared.png")
-- read the rest of the conversation the way a player does
for i = 2, 8 do
-- read the rest of the conversation the way a player does. The pleading runs
-- seven lines before GiveItem, and the gift box and the SELFDESTRUCT warning
-- follow it, so turn pages until the talk actually ends instead of counting
-- them out -- the bound is only there so a stuck box cannot hang the driver.
-- boxText reads the whole box, so several A presses walk one string: log a
-- box the first time it is seen, not once per page turn
local i, last = 1, first
for _ = 1, 40 do
U.tap(game, "a")
U.wait(40)
local t = boxText()
if not t then break end
U.log(("box %d reads:"):format(i), t)
if t:find("TM36", 1, true) then
U.shot(game, DIR .. "/bug393_2_tm36.png")
if t ~= last then
i, last = i + 1, t
U.log(("box %d reads:"):format(i), t)
if t:find("TM36", 1, true) then
U.shot(game, DIR .. "/bug393_2_tm36.png")
end
end
end
check("TM36 ended up in the bag", hasTm())
+10 -1
View File
@@ -30,7 +30,16 @@ return function(game)
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
check("COLORS is SGB (the default this bug shows in)", mode == "gbc")
-- the seam only exists under the SGB zone clips, so put the renderer there
-- rather than trusting whatever COLORS the save was last left on (setMode
-- is display-only; the option itself is restored below)
if PaletteFX.mode ~= "gbc" then
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
PaletteFX.setMode("gbc")
U.wait(20)
end
check("the view is in SGB (the mode this bug shows in)",
PaletteFX.mode == "gbc")
if (opts and opts.musicVol or 0) == 0 then
U.log("WARNING music volume is 0: the title theme will be silent.")
end
@@ -0,0 +1,170 @@
-- Ear check on Music_YellowIntro across the cut to the title (#436): finish()
-- used to stop the song there, on both the scene-done exit and the A/B/START
-- skip. pokeyellow engine/movie/intro_yellow.asm PlayIntroScene
-- .go_to_title_screen (both exits land there) touches no audio at all; the
-- song only dies at engine/movie/title.asm:149 StopAllMusic, one instruction
-- before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands in for.
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/yellow_intro_music_bug436_test.lua POKEPORT_TOUCH=0 love .
-- SKIP=1 on the same command taps START 300 frames into the movie. Add
-- POKEPORT_IDENTITY only if that identity already carries a Yellow import;
-- an identity with no cache lands in the launcher and nothing runs.
-- Do not set POKEPORT_SPEED: it scales the logic clock only, so the movie and
-- the audio clock drift apart and the comparison means nothing.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local SKIP = os.getenv("SKIP") == "1"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- every music call in order, so "the song was cut" and "the song was never
-- started" read differently in the log; nothing under src/ changes
local events = {}
local realPlay, realStop = Music.play, Music.stop
Music.play = function(data, song, loop, ctx)
events[#events + 1] = { kind = "play", song = song, frame = U.frame() }
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
return realPlay(data, song, loop, ctx)
end
Music.stop = function(...)
events[#events + 1] = { kind = "stop", frame = U.frame() }
U.log(("frame %d stop"):format(U.frame()))
return realStop(...)
end
local function firstPlay(song)
for i, e in ipairs(events) do
if e.kind == "play" and e.song == song then return i, e end
end
return nil
end
local function stopBetween(a, b)
for i = a, b do
if events[i] and events[i].kind == "stop" then return events[i] end
end
return nil
end
local function waitFor(fn, limit)
for _ = 1, limit do
if fn() then return true end
U.wait(1)
end
return false
end
check("this build booted as Yellow", GameVersion.isYellow())
if not GameVersion.isYellow() then
U.log("Only Yellow plays this movie; rerun with POKEPORT_VERSION=yellow.")
while true do coroutine.yield() end
end
local opts = game.save.options
if (opts and opts.musicVol or 0) == 0 then
U.log("MUSIC VOLUME IS 0. There is nothing to hear. Raise it in OPTION,")
U.log("quit and rerun; the log half below still holds.")
end
if (opts and opts.sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the logo crash and the whoosh over the")
U.log("song will be silent, and Pikachu's cry with them.")
end
local songs = game.data.audio and game.data.audio.songs
local INTRO = songs and songs.Music_YellowIntro and "Music_YellowIntro"
or "Music_IntroBattle"
check("the movie's song is in the audio table", songs ~= nil
and songs[INTRO] ~= nil)
-- the copyright card and the GAME FREAK splash run before the scenes
-- (IntroMovie phases 1-2), so wait on the song rather than a frame count
local started = waitFor(function() return firstPlay(INTRO) ~= nil end, 900)
check("the movie started " .. INTRO, started)
if not started then
U.log("The movie never reached its own music; nothing below can run.")
while true do coroutine.yield() end
end
local introIdx = firstPlay(INTRO)
if SKIP then
-- PlayIntroScene:16-19, hJoyPressed & (PAD_A | PAD_B | PAD_START)
U.wait(300)
U.log("tapping START mid-movie (the skip exit)")
U.tap(game, "start")
end
local function titleState()
local top = game.stack:top()
if top and top.screenId == "TitleState" then return top end
return nil
end
-- the scene timers add up to about 1040 frames; the skip path lands sooner
local reached = waitFor(function() return titleState() ~= nil end, 1500)
check("the movie handed off to the title screen", reached)
if not reached then
U.log("The title never came up; nothing below can run.")
while true do coroutine.yield() end
end
local title = titleState()
local cut = U.frame()
U.log(("cut to the title at frame %d, %d frames after the song started")
:format(cut, cut - events[introIdx].frame))
-- the bug: a stop right here, at the cut
local cutStop = stopBetween(introIdx + 1, #events)
check("no music was stopped at the cut", cutStop == nil)
if cutStop then
U.log(("a stop landed on frame %d; before #436 that is finish() killing")
:format(cutStop.frame))
U.log("the song, and the title screen comes up silent until the cry.")
end
check("the title runs the Yellow logo-drop sequence, not the plain title",
title.yellowLayout == true and title.phase == "drop")
U.shot(game, SHOT_DIR .. "/bug436_logo_drop.png")
U.log("captured", SHOT_DIR .. "/bug436_logo_drop.png")
-- drop 32 frames, settle 36, bubble 3, then the cry (title.asm's
-- WaitForSoundToFinish) before MUSIC_TITLE_SCREEN
local seen = title.phase
for _ = 1, 600 do
if title.phase ~= seen then
seen = title.phase
U.log(("frame %d title phase %s"):format(U.frame(), seen))
if seen == "loop" then break end
end
U.wait(1)
end
check("the title sequence reached its interactive loop", title.phase == "loop")
-- read the song off the log rather than naming it: field.boot lets a mod
-- own the title theme (TitleState:startMusic, self.title.music)
local titleIdx, titlePlay
for i = introIdx + 1, #events do
if events[i].kind == "play" then titleIdx, titlePlay = i, events[i] break end
end
check("the title theme started", titlePlay ~= nil)
if titlePlay then
check("it started after the cut, not at it", titlePlay.frame > cut + 32)
U.log(("%s started %d frames after the cut"):format(
tostring(titlePlay.song), titlePlay.frame - cut))
check("nothing stopped the music between the two songs",
stopBetween(introIdx + 1, titleIdx - 1) == nil)
end
U.log("The title is up and the pad is yours. The one thing to hear: the")
U.log("intro song runs unbroken through the cut and on over the logo drop,")
U.log("with the crash and the whoosh layered on top, until the title theme")
U.log("takes over after Pikachu's cry. A break at the cut is the bug.")
U.log("Rerun with SKIP=1 for the START skip; it must sound the same.")
while true do
coroutine.yield()
end
end
+195
View File
@@ -0,0 +1,195 @@
-- The Mt Moon B2F Jessie & James ambush closes in, and vanishes behind a
-- fade (#423). pokeyellow MtMoonB2FScript_49e15 (scripts/MtMoonB2F.asm:225)
-- shows both objects, prints TEXT_MTMOONB2F_TEXT12, then simulates PAD_UP for
-- one player step; Script6/Script9 MoveSprite Jessie with MovementData_f9e65
-- (six $06) and James with f9e66 (its last five), and both objects carry
-- movement byte 2 = LEFT (data/maps/objects/MtMoonB2F.asm), which
-- .determineDirection (engine/overworld/movement.asm) turns into LEFT steps.
-- Script8/Script11 then face Jessie DOWN and James LEFT, and Script14 wraps
-- the two HideObjects in GBFadeOutToBlack / GBFadeInFromBlack. The port used
-- to jump from the motto straight to the challenge with the duo still parked
-- at (9,3)/(9,4), and popped them out with no fade.
-- ROM-free: reads data/scripts/ only.
-- luajit tests/engine/jessie_james_mtmoon_walk.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local sites = require("data.scripts.yellow_jessie_james")
-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 is JESSIE at (9,3),
-- object 6 is JAMES at (9,4), both STAY LEFT. The trigger tile is (3,5).
local JESSIE, JAMES = 2, 6
local JESSIE_START = { x = 9, y = 3 }
local JAMES_START = { x = 9, y = 4 }
local TRIGGER = { x = 3, y = 5 }
-- captures the row list a site's onStep hands to the runner
local function rowsFor(site, x, y, flags)
local captured = nil
local ow = { runner = { run = function(_, rows) captured = rows end } }
local game = { save = { flags = flags or {} } }
local fired = sites[site].onStep(game, ow, x, y)
return captured, fired
end
local function indexOf(rows, pred, from)
for i = (from or 1), #rows do
if pred(rows[i]) then return i end
end
return nil
end
local function isRow(name, arg2)
return function(r)
return r[1] == name and (arg2 == nil or r[2] == arg2)
end
end
local function dirsMatch(dirs, count, want)
if type(dirs) ~= "table" or #dirs ~= count then return false end
for i = 1, count do
if dirs[i] ~= want then return false end
end
return true
end
-- ---- the ambush arms on the pokeyellow conditions ------------------------
check(type(sites.MT_MOON_B2F.onStep) == "function", "MT_MOON_B2F has an onStep")
local armed = { EVENT_GOT_HELIX_FOSSIL = true }
local rows, fired = rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, armed)
check(fired == true, "stepping on (3,5) with a fossil fires the ambush")
check(rows ~= nil, "the ambush handed a script to the runner")
rows = rows or {}
check(select(2, rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, {})) == false,
"no fossil in the bag, no ambush")
check(select(2, rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y,
{ EVENT_GOT_HELIX_FOSSIL = true,
EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = true })) == false,
"a beaten duo does not re-engage")
-- ---- the duo closes in ---------------------------------------------------
local motto = indexOf(rows, isRow("show_text", "_MtMoonJessieJamesText1"))
local challenge = indexOf(rows, isRow("show_text", "_MtMoonJessieJamesText2"))
local battle = indexOf(rows, isRow("start_battle"))
check(motto ~= nil, "the motto plays")
check(challenge ~= nil, "the challenge line plays")
check(battle ~= nil, "the fight starts")
local playerStep = indexOf(rows, function(r)
return r[1] == "walk_npc" and r[2] == "player"
end)
check(playerStep ~= nil, "the simulated PAD_UP step is walked")
if playerStep then
check(dirsMatch(rows[playerStep][3], 1, "up"),
"one step, since wSimulatedJoypadStatesIndex is 1")
check(motto and playerStep > motto,
"the step comes after TEXT12, as StartSimulatingJoypadStates does")
end
local jessieWalk = indexOf(rows, isRow("walk_npc", JESSIE))
local jamesWalk = indexOf(rows, isRow("walk_npc", JAMES))
check(jessieWalk ~= nil, "Jessie walks")
check(jamesWalk ~= nil, "James walks")
check(jessieWalk and jamesWalk and jessieWalk < jamesWalk,
"Script6 moves Jessie before Script9 moves James")
if jessieWalk then
check(dirsMatch(rows[jessieWalk][3], 6, "left"),
"Jessie walks the six $06 of MovementData_f9e65, resolved LEFT")
end
if jamesWalk then
check(dirsMatch(rows[jamesWalk][3], 5, "left"),
"James walks the five $06 of MovementData_f9e66, resolved LEFT")
end
-- where those step counts land them, against the object coordinates
if jessieWalk and jamesWalk and playerStep then
local playerX, playerY = TRIGGER.x, TRIGGER.y - 1
local jx = JESSIE_START.x - #rows[jessieWalk][3]
local jmx = JAMES_START.x - #rows[jamesWalk][3]
eq(jx, playerX, "Jessie ends in the player's column")
eq(JESSIE_START.y, playerY - 1, "Jessie ends one cell above the player")
eq(jmx, playerX + 1, "James ends one cell east of the player")
eq(JAMES_START.y, playerY, "James ends level with the player")
end
local faceJessie = indexOf(rows, isRow("face_object", JESSIE), jessieWalk)
local faceJames = indexOf(rows, isRow("face_object", JAMES), jamesWalk)
check(faceJessie ~= nil and rows[faceJessie][3] == "down",
"Script8 leaves Jessie facing DOWN at the player")
check(faceJames ~= nil and rows[faceJames][3] == "left",
"Script11 leaves James facing LEFT at the player")
local facePlayer = indexOf(rows, isRow("face_player_dir"))
check(facePlayer ~= nil and rows[facePlayer][2] == "up",
"the player turns up to the duo, not right")
local emote = indexOf(rows, isRow("emote", "player"))
check(emote ~= nil, "the player gets the exclamation bubble TEXT12 shows")
if challenge and jamesWalk and faceJames then
check(faceJames < challenge and challenge < (battle or math.huge),
"both walks finish before TEXT13, which precedes the fight")
end
-- ---- and leaves behind a fade -------------------------------------------
-- Script14: GBFadeOutToBlack, HideObject JESSIE, HideObject JAMES,
-- UpdateSprites, Delay3, GBFadeInFromBlack. Checked at all four sites: the
-- other three already faded, so they are the regression half.
local function checkFadedExit(site, label, x, y, flags)
local siteRows = rowsFor(site, x, y, flags)
check(siteRows ~= nil, label .. " handed a script to the runner")
if not siteRows then return end
local fadeOut = indexOf(siteRows, function(r)
return r[1] == "fade" and r[2] == "out"
end)
check(fadeOut ~= nil, label .. " fades out before the duo vanishes")
if not fadeOut then return end
local firstHide = indexOf(siteRows, isRow("hide_object"))
check(firstHide ~= nil and firstHide > fadeOut,
label .. " hides nobody while the screen is still lit")
local secondHide = indexOf(siteRows, isRow("hide_object"), (firstHide or 0) + 1)
check(secondHide ~= nil and secondHide == (firstHide or 0) + 1,
label .. " hides both of them inside the same fade")
local fadeIn = indexOf(siteRows, function(r)
return r[1] == "fade" and r[2] == "in"
end, (secondHide or 0) + 1)
check(fadeIn ~= nil, label .. " fades back in with the duo gone")
local music = indexOf(siteRows, isRow("play_default_music"))
check(music ~= nil and fadeIn and music > fadeIn,
label .. " resumes the map theme after the fade, as PlayDefaultMusic does")
end
checkFadedExit("MT_MOON_B2F", "Mt Moon B2F", TRIGGER.x, TRIGGER.y, armed)
checkFadedExit("ROCKET_HIDEOUT_B4F", "Rocket Hideout B4F", 24, 14, {})
checkFadedExit("POKEMON_TOWER_7F", "Pokemon Tower 7F", 10, 12, {})
checkFadedExit("SILPH_CO_11F", "Silph Co 11F", 3, 3,
{ EVENT_BEAT_SILPH_CO_GIOVANNI = true })
-- every site walks the duo in rather than fighting them from across the room
for _, site in ipairs({ "MT_MOON_B2F", "ROCKET_HIDEOUT_B4F",
"POKEMON_TOWER_7F", "SILPH_CO_11F" }) do
local siteRows = ({
MT_MOON_B2F = function() return rowsFor(site, TRIGGER.x, TRIGGER.y, armed) end,
ROCKET_HIDEOUT_B4F = function() return rowsFor(site, 25, 14, {}) end,
POKEMON_TOWER_7F = function() return rowsFor(site, 11, 12, {}) end,
SILPH_CO_11F = function()
return rowsFor(site, 2, 3, { EVENT_BEAT_SILPH_CO_GIOVANNI = true })
end,
})[site]()
local walk = siteRows and indexOf(siteRows, isRow("walk_npc"))
local start = siteRows and indexOf(siteRows, isRow("start_battle"))
check(walk ~= nil and start ~= nil and walk < start,
site .. " walks somebody before the battle begins")
end
T.finish("jessie james mt moon walk")
+73
View File
@@ -0,0 +1,73 @@
-- Yellow's Jessie & James battle behind their own pic while keeping the
-- ROCKET class and the name "ROCKET" (#439). pokeyellow
-- home/trainers2.asm:36-50 IsFightingJessieJames swaps wTrainerPicPointer
-- for JessieJamesPic when wTrainerClass is ROCKET and wTrainerNo >= $2a
-- (parties 42-45), and GetTrainerName right below it is untouched, so only
-- the pic changes. Red has no such symbol, so the swap must stay Yellow's.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local CLASS_PIC = "assets/generated/battle/trainers/rocket.png"
local DUO_PIC = "assets/generated/battle/trainers/jessie_james.png"
-- a Yellow cache extracted after #439: OPP_ROCKET carries both pics
local yellow = { name = "ROCKET", pic = CLASS_PIC, picJessieJames = DUO_PIC }
-- Red/Blue, and any Yellow cache built before #439, carry only the class pic
local grunt = { name = "ROCKET", pic = CLASS_PIC }
for _, party in ipairs({ 42, 43, 44, 45 }) do
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", party), DUO_PIC,
"party " .. party .. " fights behind the duo pic")
end
T.eq(yellow.name, "ROCKET", "the duo keeps the class name")
-- $2a is the first duo party; every grunt below it keeps the class pic
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", 41), CLASS_PIC,
"party 41 is still a lone grunt")
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", 1), CLASS_PIC,
"the first ROCKET party is still a lone grunt")
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", nil), CLASS_PIC,
"an unnumbered ROCKET falls back to party 1")
T.eq(BattleState.trainerPicPath(
{ pic = "assets/generated/battle/trainers/super_nerd.png",
picJessieJames = DUO_PIC }, "OPP_SUPER_NERD", 42),
"assets/generated/battle/trainers/super_nerd.png",
"the class gate holds: only ROCKET swaps")
T.eq(BattleState.trainerPicPath(grunt, "OPP_ROCKET", 42), CLASS_PIC,
"a cache with no duo pic keeps the grunt instead of a nil pic")
-- The extractor writes jessie_james.png only when the symbol is in the
-- manifest, so the symbol table is the other half of the contract.
local function readFile(path)
local handle = io.open(path, "r")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
return text
end
local yellowManifest = readFile("tools/rom_manifest_yellow.json")
T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable")
if yellowManifest then
local bank, addr = yellowManifest:match(
'"JessieJamesPic"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]')
T.eq(bank, "19", "JessieJamesPic sits in bank 0x13")
T.eq(addr, "31873", "JessieJamesPic sits at 0x7c81 in that bank")
end
for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do
local text = readFile(path)
T.check(text ~= nil, path .. " is readable")
T.check(text == nil or text:find("JessieJamesPic", 1, true) == nil,
path .. " has no such symbol, matching pokered")
end
-- the manifest is generated, so the generator has to keep asking for it
local gen = readFile("tools/make_yellow_manifest.py")
T.check(gen ~= nil, "tools/make_yellow_manifest.py is readable")
T.check(gen == nil or gen:find('"JessieJamesPic"', 1, true) ~= nil,
"a regenerated Yellow manifest keeps JessieJamesPic")
T.finish("jessie james pic")
+134
View File
@@ -0,0 +1,134 @@
-- Launcher Delete affordance (src/import/RomImporter.lua): the per-frame hit
-- rects a draw() clears, and the two-click arm that guards both save-slot and
-- mod deletes (#433). Drives RomImporter:mousepressed / :_resetFrameRects on a
-- bare instance, so no window, no cache and no real save files are involved.
-- luajit tests/engine/launcher_delete_confirm.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")
-- mousepressed timestamps the arm and expires it, so the clock has to move
local clock = 1000
love.timer.getTime = function() return clock end
local RomImporter = require("src.import.RomImporter")
local function rect(id, y)
return { x = 100, y = y or 200, width = 40, height = 14, id = id }
end
-- Only the fields mousepressed reads on its way to the Delete loops, plus
-- recorders in place of the two destructive calls.
local function launcher()
local self = setmetatable({}, RomImporter)
self.android = false
self.panelVersion = "red"
self.tab = "red"
self.slotScroll = {}
self.deletedSlots = {}
self.deletedMods = {}
self.selected = {}
self._deleteSlot = function(_, version, id)
table.insert(self.deletedSlots, version .. "/" .. id)
end
self._deleteMod = function(_, id) table.insert(self.deletedMods, id) end
self._selectSlot = function(_, version, id)
table.insert(self.selected, version .. "/" .. id)
end
return self
end
local function clickDelete(self, r)
self:mousepressed(r.x + 2, r.y + 2, 1)
end
-- ------- a frame that draws no panel leaves no Delete rect behind
do
local self = launcher()
self.slotDeleteRects = { rect("slot1") }
self.modDeleteRects = { rect("bigmod", 260) }
self.slotRects = { rect("slot1") }
self.modRects = { rect("bigmod", 260) }
self:_resetFrameRects()
eq(self.slotDeleteRects, nil, "a frame reset drops the save Delete rects")
eq(self.modDeleteRects, nil, "a frame reset drops the mod Delete rects")
eq(self.slotRects, nil, "and the slot rows they sit on")
eq(self.modRects, nil, "and the mod toggles")
-- the reporter's click: mods tab is up, the press lands where the game tab
-- drew Delete last time it was shown
self.tab = "mods"
clickDelete(self, rect("slot1"))
eq(#self.deletedSlots, 0, "a press on a stale Delete spot deletes nothing")
end
-- ------- a save Delete needs two clicks on the same row
do
local self = launcher()
local r = rect("slot1")
self.slotDeleteRects = { r }
clickDelete(self, r)
eq(#self.deletedSlots, 0, "the first click on Delete does not delete")
check(self._confirmDelete ~= nil and self._confirmDelete.id == "slot1",
"the first click arms that row")
clickDelete(self, r)
eq(self.deletedSlots[1], "red/slot1", "the second click on it deletes")
eq(self._confirmDelete, nil, "the arm is spent")
end
-- ------- the arm is per row, per version, and any other press clears it
do
local self = launcher()
local one, two = rect("slot1", 200), rect("slot2", 230)
self.slotDeleteRects = { one, two }
clickDelete(self, one)
clickDelete(self, two)
eq(#self.deletedSlots, 0, "a click on another row's Delete only arms that row")
eq(self._confirmDelete.id, "slot2", "the arm moved to the row just clicked")
self.slotRects = { rect("slot3", 260) }
clickDelete(self, one) -- re-arm slot1
self:mousepressed(102, 262, 1) -- press somewhere else entirely
clickDelete(self, one)
eq(#self.deletedSlots, 0, "a press elsewhere disarms, so Delete asks again")
self:mousepressed(102, 262, 1) -- clear the arm left by the pair above
clickDelete(self, one)
self.panelVersion = "blue"
clickDelete(self, one)
eq(#self.deletedSlots, 0, "an arm from one game's tab cannot fire on another")
end
-- ------- a stale arm expires instead of committing much later
do
local self = launcher()
local r = rect("slot1")
self.slotDeleteRects = { r }
clickDelete(self, r)
clock = clock + 30
clickDelete(self, r)
eq(#self.deletedSlots, 0, "an arm older than the confirm window is dead")
clickDelete(self, r)
eq(self.deletedSlots[1], "red/slot1", "and the fresh pair still deletes")
end
-- ------- mods delete arms the same way
do
local self = launcher()
local r = rect("bigmod", 260)
self.modDeleteRects = { r }
clickDelete(self, r)
eq(#self.deletedMods, 0, "the first click on a mod's Delete does not delete")
clickDelete(self, r)
eq(self.deletedMods[1], "bigmod", "the second click removes the mod")
end
T.finish("launcher delete confirm")
+88
View File
@@ -0,0 +1,88 @@
-- The launcher's stray-mod scan must not borrow a game folder that is already
-- on the physfs read path: PHYSFS_mount succeeds for such a folder without
-- adding a search-path entry, so the paired unmount drops the mount the
-- portable cache and its mods/ live on, and the MODS panel empties (#413).
-- Runs the real SaveData.gameFolders / LauncherMods scan over a fused macOS
-- layout, with a search path that copies those physfs semantics.
-- luajit tests/engine/launcher_stray_mount.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")
-- scripts/build.sh mac layout: the game is an archive inside the .app, the
-- player's portable folder is the one holding the .app.
local PORTABLE = "/Users/p/Games"
local APP = PORTABLE .. "/gen1recomp.app"
local SOURCE = APP .. "/Contents/Resources/game.love"
local BASE = APP .. "/Contents/MacOS"
love.system = love.system or {}
love.system.getOS = function() return "OS X" end
love.filesystem.getSource = function() return SOURCE end
love.filesystem.getSourceBaseDirectory = function() return BASE end
local CacheFs = require("src.import.CacheFs")
local SaveData = require("src.core.SaveData")
local LauncherMods = require("src.mods.LauncherMods")
-- ------- isReadableRoot: the two folders a fused build can already read
do
check(LauncherMods.isReadableRoot(SOURCE, SOURCE, PORTABLE),
"the physfs source is already readable")
check(LauncherMods.isReadableRoot(PORTABLE, SOURCE, PORTABLE),
"so is the portable folder CacheFs mounted")
check(not LauncherMods.isReadableRoot(BASE, SOURCE, PORTABLE),
"a folder beside them is not, so it still needs a scan mount")
check(not LauncherMods.isReadableRoot(nil, SOURCE, PORTABLE),
"no folder is not a readable folder")
check(not LauncherMods.isReadableRoot("", SOURCE, PORTABLE),
"nor is an empty path, which would match an unresolved cache root")
check(not LauncherMods.isReadableRoot(PORTABLE, SOURCE, nil),
"with no cache root mounted the portable folder is scannable again")
end
-- ------- withMounted answers "could not look", never "nothing there"
do
eq(CacheFs.withMounted("", "stray_scan", function() return "ran" end), nil,
"an empty dir is refused without running fn")
end
-- ------- the scan over that layout leaves the portable mount standing
do
eq(SaveData.gameFolders()[1], PORTABLE,
"the folder holding the .app is the first game folder")
-- PHYSFS_mount on a directory already in the search path bails with success
-- and adds nothing (physfs 2.x and 3.x alike), while PHYSFS_unmount removes
-- whatever entry is there -- so a borrowed mount of an already-mounted
-- folder costs the caller the real one.
local searchPath = { [PORTABLE] = true }
local mounted = {}
CacheFs.root = function() return PORTABLE end
CacheFs.withMounted = function(dir, mountPoint, fn)
mounted[#mounted + 1] = dir
if not searchPath[dir] then searchPath[dir] = true end
local res = fn()
searchPath[dir] = nil
return res
end
LauncherMods.strays()
check(searchPath[PORTABLE],
"the portable folder is still mounted after the stray scan")
for _, dir in ipairs(mounted) do
check(dir ~= PORTABLE, "the scan never remounts the portable folder")
check(dir ~= SOURCE, "nor the source, whose mods/ is readable already")
end
eq(mounted[1], BASE,
"the folders that do need a mount are still scanned")
end
T.finish("launcher stray scan mount")
+208
View File
@@ -0,0 +1,208 @@
-- #442: an Android pick that cannot be used must say so. GameActivity writes
-- pick_error.flag when it has no readable stream for the chosen URI, and a pick
-- it did copy but the importer refuses (wrong size, unknown SHA-1) has to reach
-- startData's messages instead of leaving the launcher silent with the file
-- stuck on disk. Covers the routing, the removal, and the #167 carve-out for a
-- cart that is merely already imported.
-- luajit tests/engine/rom_pick_error_bug442.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 GameVersion = require("src.core.GameVersion")
local RomImporter = require("src.import.RomImporter")
local MiB = 1024 * 1024
local redData = string.rep("R", MiB)
local hackData = string.rep("X", MiB) -- 1 MiB, matches no known cart
local shortData = string.rep("R", MiB / 2) -- trimmed dump
local UNKNOWN_SHA1 = "0000000000000000000000000000000000000000"
-- Map the fake blobs onto real version ids by first byte, so no crypto library
-- is needed headless (same trick as tests/rom_importer_android_pick_test.lua).
love.data = { hash = function(_, data) return { tag = data:sub(1, 1) } end }
love.data.encode = function(_, _, digest)
if type(digest) == "table" and digest.tag == "R" then
return GameVersion.info("red").sha1
end
return UNKNOWN_SHA1
end
love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end
local pickCalls = {}
love.system = {
getOS = function() return "Android" end,
pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end,
}
-- Pre-fix these paths leave the launcher untouched, so read the message
-- defensively: every check should report, not blow up on the first nil.
local function detail(ri) return tostring(ri.detail or "") end
local function clearSaveDir()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
love.filesystem.remove(name)
end
end
-- Only the fields the Android focus / choose / chooseMod paths read. _installMod
-- is stubbed per case because the outcome it reports is what consumePick keys on.
local function freshImporter(opts)
opts = opts or {}
pickCalls = {}
return setmetatable({
android = true,
launcher = true,
workState = nil,
tab = opts.tab or "red",
ready = { red = opts.redReady and true or false, blue = false, yellow = false },
saveNotice = {},
modNotice = nil,
notice = nil,
slotScroll = {},
activeSlot = {},
_installMod = function(self, name)
self._installed = name
self.modNotice = { ok = opts.modOk and true or false, text = "install result" }
end,
_importSave = function(self, version, name)
self._imported = { version = version, name = name }
self.saveNotice[version] = { ok = false, text = "import result" }
end,
}, RomImporter)
end
-- ------- pick_error.flag: GameActivity could not read the chosen URI at all
do
clearSaveDir()
love.filesystem.write("pick_error.flag", "picked_rom.gb")
local ri = freshImporter({})
ri:focus(true)
eq(ri.workState, "error", "an unreadable ROM pick lands on the error state")
check(detail(ri):find("Could not read the picked file", 1, true),
"the ROM notice names the unreadable pick")
check(detail(ri):find("/sdcard/pokeport/save", 1, true),
"the notice offers the save dir as the copy-it-yourself fallback")
eq(love.filesystem.getInfo("pick_error.flag"), nil,
"the flag is consumed, so refocusing does not re-report it")
end
do
clearSaveDir()
love.filesystem.write("pick_error.flag", "picked_mod.zip")
local ri = freshImporter({})
ri:focus(true)
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
"an unreadable mod pick reports on the mods panel")
eq(ri.workState, nil, "a failed mod pick does not error out the ROM panel")
end
do
clearSaveDir()
love.filesystem.write("pick_error.flag", "picked_save.sav")
local ri = freshImporter({ redReady = true, tab = "mods" })
ri.androidPendingVersion = "blue"
ri:focus(true)
check(ri.saveNotice.blue ~= nil and ri.saveNotice.blue.ok == false,
"an unreadable save pick reports on the game it was picked for")
eq(ri.androidPendingVersion, nil, "the pending save target is consumed with it")
end
-- ------- a pick that copied fine but the importer refuses
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", shortData)
local ri = freshImporter({})
ri:focus(true)
eq(ri.workState, "error", "a trimmed pick reports instead of staying silent")
check(detail(ri):find("1 MiB", 1, true), "the wrong-size message names the size")
eq(love.filesystem.getInfo("picked_rom.gb"), nil,
"the refused pick is dropped so the next tap starts clean")
end
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", hackData)
local ri = freshImporter({})
ri:focus(true)
eq(ri.workState, "error", "a cart matching no known SHA-1 reports")
check(detail(ri):find(UNKNOWN_SHA1, 1, true), "the message quotes the hash")
check(detail(ri):find("[b] or [BF]", 1, true),
"the message names the dump tags that can never verify")
eq(love.filesystem.getInfo("picked_rom.gb"), nil, "the bad dump is dropped")
end
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", redData)
local ri = freshImporter({ redReady = true })
ri:focus(true)
eq(ri.workState, nil, "an already-imported cart is not an error (#167)")
check(love.filesystem.getInfo("picked_rom.gb") ~= nil,
"#167's leftover is left alone, not reported and deleted")
end
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", redData)
local read = love.filesystem.read
love.filesystem.read = function(name)
if name == "picked_rom.gb" then return nil end
return read(name)
end
local ri = freshImporter({})
ri:focus(true)
love.filesystem.read = read
eq(ri.workState, "error", "a pick present but unreadable from Lua reports too")
check(detail(ri):find("could not be read", 1, true),
"the unreadable-file message points at the picker")
end
-- Choose must explain the refused pick rather than reopening the picker over it.
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", hackData)
local ri = freshImporter({})
ri:choose("blue")
eq(#pickCalls, 0, "Choose reports the refused pick before reopening the picker")
eq(ri.workState, "error", "Choose surfaces the same message focus does")
end
-- ------- a rejected pick must not wall off the branches under it
do
clearSaveDir()
love.filesystem.write("picked_mod.zip", "not a zip")
love.filesystem.write("picked_rom.gb", shortData)
local ri = freshImporter({})
ri:focus(true)
eq(ri._installed, "picked_mod.zip", "the mod branch runs first")
eq(love.filesystem.getInfo("picked_mod.zip"), nil,
"a rejected SAF mod pick is retired, not left to win every refocus")
ri:focus(true)
eq(ri.workState, "error", "the next focus reaches the ROM pick under it")
end
-- A USB copy is the player's own file: keep it, but stop retrying it this session.
do
clearSaveDir()
love.filesystem.write("mymod.zip", "not a zip")
local ri = freshImporter({})
ri:chooseMod()
eq(ri._installed, "mymod.zip", "Import Mod picks up a USB copy")
check(love.filesystem.getInfo("mymod.zip") ~= nil, "the player's own file stays")
ri:chooseMod()
eq(#pickCalls, 1, "the second tap opens the picker instead of retrying it")
eq(pickCalls[1], "mod", "and asks for a mod archive")
end
clearSaveDir()
T.finish()
+249
View File
@@ -0,0 +1,249 @@
-- #420: importing a .sav on Android. Two halves: a pick that fails has to be
-- retired so the next tap reopens the picker instead of re-running the same
-- file forever, and the crosswalk tables have to come out of the target game's
-- ROM cache -- the launcher runs before CacheFs.mountVersion, so a bare require
-- sees Red's copy at best and nothing at all in a fused build.
-- luajit tests/engine/save_import_retry_bug420.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 GameVersion = require("src.core.GameVersion")
-- ------------------------------------------------------------------
-- Bug A: a failed Android save pick must not block the retry
-- ------------------------------------------------------------------
local RomImporter = require("src.import.RomImporter")
local pickCalls = {}
love.system = {
getOS = function() return "Android" end,
pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end,
}
love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end
local function clearSaveDir()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
love.filesystem.remove(name)
end
end
-- Only the fields the Android save-import path reads. _importSave is stubbed
-- with the outcome consumePick keys on, since the whole question is what the
-- launcher does with the file afterwards.
local function freshImporter(importOk)
pickCalls = {}
return setmetatable({
android = true,
workState = nil,
tab = "red",
ready = { red = true, blue = false, yellow = false },
saveNotice = {},
notice = nil,
_importSave = function(self, version, name)
self._imports = self._imports or {}
self._imports[#self._imports + 1] = { version = version, name = name }
self.saveNotice[version] = { ok = importOk and true or false, text = "result" }
end,
}, RomImporter)
end
do
clearSaveDir()
love.filesystem.write("picked_save.sav", "too short to be SRAM")
local ri = freshImporter(false)
ri:chooseSaveImport("red")
eq(#ri._imports, 1, "the pending SAF pick is what Import save consumes")
eq(love.filesystem.getInfo("picked_save.sav"), nil,
"a rejected SAF pick is dropped: it is GameActivity's own copy")
ri:chooseSaveImport("red")
eq(#pickCalls, 1, "the second tap reopens the picker instead of retrying it")
eq(pickCalls[1], "sav", "and asks for a battery save")
eq(#ri._imports, 1, "the rejected file is not run through the importer twice")
end
do
clearSaveDir()
love.filesystem.write("picked_save.sav", "32768 bytes, as far as this test cares")
local ri = freshImporter(true)
ri:chooseSaveImport("blue")
eq(love.filesystem.getInfo("picked_save.sav"), nil,
"a pick that imported cleanly is still removed")
eq(#pickCalls, 0, "and the picker is not reopened over a successful import")
end
-- A USB copy is the player's own file sitting in the save dir, so a failed one
-- is skipped for the session rather than deleted.
do
clearSaveDir()
love.filesystem.write("pokemon_red.sav", "wrong size")
local ri = freshImporter(false)
ri:chooseSaveImport("red")
eq(ri._imports[1].name, "pokemon_red.sav", "Import save picks up a USB copy")
check(love.filesystem.getInfo("pokemon_red.sav") ~= nil,
"the player's own .sav stays on disk")
ri:chooseSaveImport("red")
eq(#pickCalls, 1, "the skipped USB copy no longer wins the scan")
eq(#ri._imports, 1, "and is not re-imported")
end
-- The focus path (SAF returns, GameActivity has written the pick) retires the
-- same way, so a refocus loop cannot re-run a rejected file.
do
clearSaveDir()
love.filesystem.write("picked_save.sav", "wrong size")
local ri = freshImporter(false)
ri.androidPendingVersion = "yellow"
ri:focus(true)
eq(ri._imports[1].version, "yellow", "focus imports for the game that was picked for")
eq(love.filesystem.getInfo("picked_save.sav"), nil, "and retires the rejected pick")
ri:focus(true)
eq(#ri._imports, 1, "the next focus has nothing left to re-import")
end
clearSaveDir()
-- ------------------------------------------------------------------
-- Bug B: the crosswalk comes from the target game's ROM cache
-- ------------------------------------------------------------------
-- Fake CacheFs, installed before SaveConvert is required so its lazy require
-- picks it up. Each read reports the prefix it was asked under and hands back
-- a module tagged with it, which is what makes "whose cache" observable.
local reads = {}
local SENTINEL = "SENTINEL/"
local cacheFails = false
local fakeCache = { prefix = SENTINEL }
function fakeCache.read(path)
reads[#reads + 1] = { path = path, prefix = fakeCache.prefix }
if cacheFails then error("no such cache entry: " .. path) end
return ("return { cacheTag = %q, path = %q }")
:format(tostring(fakeCache.prefix), path)
end
package.loaded["src.import.CacheFs"] = fakeCache
local SaveConvert = require("src.save_convert.SaveConvert")
local GENERATED = { "pokemon", "moves", "items", "maps" }
local function prefixes()
local seen = {}
for _, r in ipairs(reads) do seen[r.prefix] = (seen[r.prefix] or 0) + 1 end
return seen
end
do
reads = {}
local data, err = SaveConvert.loadData("blue")
check(type(data) == "table", "loadData(blue) resolves: " .. tostring(err))
for _, name in ipairs(GENERATED) do
eq(data and data[name] and data[name].cacheTag, GameVersion.VERSIONS.blue.cachePrefix,
name .. " comes out of Blue's cache, not the un-prefixed read path")
end
eq(prefixes()[GameVersion.VERSIONS.blue.cachePrefix], #GENERATED,
"all four generated tables are read under Blue's cache prefix")
eq(fakeCache.prefix, SENTINEL,
"CacheFs.prefix is launcher-owned state and is put back after the read")
check(data and data.eventFlags ~= nil,
"the save-convert-only crosswalks still load through require")
end
do
reads = {}
SaveConvert.loadData("blue")
eq(#reads, 0, "a second load for the same game reuses the cached set")
end
-- importSav's 2nd arg is the save-format stamp; the 3rd is what selects the
-- crosswalk. Red's cache prefix is the empty string, so the recorded prefix
-- doubles as proof the read went through CacheFs at all.
local zeros = string.rep("\0", SaveConvert.SAVE_SIZE)
do
reads = {}
SaveConvert.importSav(zeros, 2, "red")
eq(prefixes()[GameVersion.VERSIONS.red.cachePrefix], #GENERATED,
"importSav reads the crosswalk from the game named by its 3rd argument")
end
do
reads = {}
SaveConvert.importSav(zeros, 2)
eq(#reads, 0, "with no game named, the format stamp is not read as one")
end
do
reads = {}
local ok, _, err = pcall(SaveConvert.exportSav, { meta = {} }, "yellow")
check(ok, "exportSav takes the same game argument without raising: " .. tostring(err))
eq(prefixes()[GameVersion.VERSIONS.yellow.cachePrefix], #GENERATED,
"exportSav reads Yellow's crosswalk on the way back out")
end
do
reads = {}
local data = SaveConvert.loadData("yellow")
eq(data and data.maps and data.maps.cacheTag, GameVersion.VERSIONS.yellow.cachePrefix,
"Yellow gets Yellow's tables: the set is keyed per game, not global")
eq(#reads, 0, "and shares the set exportSav already warmed")
end
-- The require path still has to carry SaveConvert under plain luajit, where
-- there is no cache to read from.
do
reads = {}
cacheFails = true
local ok, data, err = pcall(SaveConvert.loadData, "red")
cacheFails = false
check(ok, "an unreadable cache falls back instead of raising: " .. tostring(data))
if loadfile("data/generated/maps.lua") then
check(type(data) == "table",
"the require path still resolves the tables headless: " .. tostring(err))
end
end
-- ------------------------------------------------------------------
-- The launcher's glue names the game it is importing for (cross-file contract)
-- ------------------------------------------------------------------
do
local seen = {}
package.loaded["src.save_convert.SaveConvert"] = {
SAVE_SIZE = 32768,
importSav = function(_, version, gameVersion)
seen.import = { version = version, gameVersion = gameVersion }
return nil, "stub"
end,
exportSav = function(_, gameVersion)
seen.export = { gameVersion = gameVersion }
return nil, "stub"
end,
}
package.loaded["src.core.SaveData"] = {
load = function() return { meta = {} } end,
activeSlot = function() return "slot1" end,
buildMeta = function() return {} end,
createSlot = function() return "slot1" end,
writeSlot = function() return true end,
setActiveSlot = function() return true end,
}
local SaveFileIO = require("src.import.SaveFileIO")
SaveFileIO.importToSlot(string.rep("\0", 32768), "yellow")
eq(seen.import and seen.import.gameVersion, "yellow",
"importToSlot tells SaveConvert whose cache to read")
eq(seen.import and seen.import.version, "yellow",
"and still passes the version stamp it always did")
SaveFileIO.exportActiveSlot("blue")
eq(seen.export and seen.export.gameVersion, "blue",
"exportActiveSlot names the game on the way back out")
end
T.finish()
+105
View File
@@ -0,0 +1,105 @@
-- A successful Teleport/Roar/Whirlwind ends the turn where it lands: the
-- second mover never moves and the residual sweep never runs (#438).
-- MainInBattleLoop reads wEscapedFromBattle after every Execute*Move and
-- `ret nz` (engine/battle/core.asm:419-457), leaving the loop before the
-- other side's move and before HandlePoisonBurnLeechSeed; the port queues
-- both moves plus endOfTurn up front, so each has to re-read battle.result.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
-- keyed TELEPORT because SwitchAndTeleportEffect picks its escape and
-- failure text off the move id (Roar and Whirlwind read differently)
Data.moves.TELEPORT = {
id = "TELEPORT", index = 97, name = "FIX TELEPORT",
type = "PSYCHIC", power = 0, accuracy = 100, pp = 20,
effect = "SWITCH_AND_TELEPORT_EFFECT",
}
Data.moves.FIX_SING = {
id = "FIX_SING", index = 98, name = "FIX SING",
type = "NORMAL", power = 0, accuracy = 55, pp = 15,
effect = "SLEEP_EFFECT",
}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
-- foeLevel decides SwitchAndTeleportEffect's auto-success (effects.asm:
-- 810-909): at or above the player's level it always escapes, below it
-- the rng(0, sum) roll of 0 below foeLevel/4 always fails
local function setup(foeLevel)
local battle = BattleState.newWild(game, "FIXMON_C", foeLevel)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "TELEPORT", pp = 20 } end
battle.enemy.curStats.speed = 200 -- the foe moves first
battle.player.curStats.speed = 1
battle.player.mon.status = "PSN" -- something for the residual sweep to do
battle.player.mon.hp = battle.player.curStats.hp
return battle, { id = "FIX_SING", pp = 15 }
end
-- consume the queue the way updateQueue does, minus the presentation
local function drain(battle)
local texts = {}
for _ = 1, 400 do
local item = table.remove(battle.queue, 1)
if not item then return texts end
if item.text then texts[#texts + 1] = item.text end
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the turn queue never drained")
end
local function saidWith(texts, needle)
for _, text in ipairs(texts) do
if text:find(needle, 1, true) then return true end
end
return false
end
local escaped, sing = setup(40)
local hpBefore = escaped.player.mon.hp
escaped:resolveTurn(sing)
local texts = drain(escaped)
T.eq(escaped.result, "run", "the escape decides the battle")
T.eq(escaped.afterQueue, "finish", "the queue closes the battle")
T.check(saidWith(texts, "ran from battle"), "the foe announces the escape")
T.check(not saidWith(texts, "FIX SING"), "the player's move never announces")
T.eq(texts[#texts]:find("ran from battle", 1, true) ~= nil, true,
"the escape line is the last thing printed")
T.eq(escaped.enemy.mon.status, nil, "the foe is gone, not asleep")
T.eq(sing.pp, 15, "the unspent move keeps its PP")
T.eq(escaped.player.mon.hp, hpBefore, "no residual poison tick after the escape")
-- the guard must not swallow the ordinary turn: a failed Teleport leaves
-- result nil, so the player still moves and the residual sweep still runs
local stayed, sing2 = setup(8)
local hpBefore2 = stayed.player.mon.hp
stayed:resolveTurn(sing2)
local texts2 = drain(stayed)
T.eq(stayed.result, nil, "a failed escape decides nothing")
T.eq(stayed.afterQueue, "menu", "the turn hands back to the menu")
T.check(saidWith(texts2, "But, it failed!"), "the failed Teleport says so")
T.check(saidWith(texts2, "FIX SING"), "the player still moves")
T.eq(stayed.enemy.mon.status, "SLP", "the foe still falls asleep")
T.eq(sing2.pp, 14, "the move that ran spent its PP")
T.check(stayed.player.mon.hp < hpBefore2, "the residual sweep still ticks poison")
Data.moves.TELEPORT = nil
Data.moves.FIX_SING = nil
T.finish("teleport ends turn")
+130
View File
@@ -0,0 +1,130 @@
-- A wild mon that escapes with TELEPORT ends the turn where it lands
-- (#441). MainInBattleLoop reads wEscapedFromBattle right after
-- ExecuteEnemyMove and rets (engine/battle/core.asm:417-421, 456-460), so
-- the second mover never announces, HandlePoisonBurnLeechSeed never runs
-- the residual sweep, and CheckNumAttacksLeft never releases a trapping
-- counter. resolveTurn queues both movers plus endOfTurn up front, so the
-- escape has to be observed by the rows themselves.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.TELEPORT = {
id = "TELEPORT", index = 100, name = "TELEPORT",
type = "PSYCHIC", power = 0, accuracy = 100, pp = 20,
effect = "SWITCH_AND_TELEPORT_EFFECT",
}
local function newGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
return { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
end
-- enemy first: the escaping mon outspeeds, and it holds TELEPORT alone so
-- the wild success path (level 40 >= level 30) cannot roll a failure
local function setup(enemyMove)
local battle = BattleState.newWild(newGame(), "FIXMON_C", 40)
battle.player.mon.stats.speed = 1
battle.enemy.mon.stats.speed = 200
battle.enemy.mon.moves = { { id = enemyMove, pp = 20 } }
battle.enemy.curMoves = battle.enemy.mon.moves
battle.enemyAction = function() return battle.enemy.curMoves[1] end
battle.rng = function(lo) return lo end -- accuracy roll hits, damage roll floors
return battle
end
-- drain the queue the way updateQueue does (fn rows run with nextInsert
-- reset so sayNext/actNext land right behind the current row), collecting
-- every message the turn would have printed
local function pump(battle)
local said = {}
local guard = 0
while battle.queue[1] and guard < 400 do
guard = guard + 1
local item = table.remove(battle.queue, 1)
if item.fn then
battle.nextInsert = 0
item.fn()
elseif item.text then
said[#said + 1] = item.text
end
end
T.check(guard < 400, "the turn queue drained")
return said
end
local function saidWith(said, needle)
for _, line in ipairs(said) do
if line:find(needle, 1, true) then return line end
end
return nil
end
-- --- the escape turn -------------------------------------------------
local battle = setup("TELEPORT")
battle.player.mon.status = "PSN"
local tackle = battle.player.curMoves[1]
local startEnemyHP, startPlayerHP = battle.enemy.mon.hp, battle.player.mon.hp
local startPP = tackle.pp
battle:resolveTurn(tackle)
local said = pump(battle)
T.check(saidWith(said, "ran from battle!"), "the wild mon announces the escape")
T.eq(battle.result, "run", "the escape settles the battle as a run")
T.eq(battle.afterQueue, "finish", "the queue finishes instead of reopening the menu")
T.check(not saidWith(said, "FIX TACKLE"),
"the second mover never announces its move after the escape")
T.eq(battle.enemy.mon.hp, startEnemyHP, "the escaped mon takes no damage")
T.eq(tackle.pp, startPP, "the lost turn costs the second mover no PP")
T.check(not saidWith(said, "hurt by"), "the residual sweep is skipped")
T.eq(battle.player.mon.hp, startPlayerHP, "no poison tick on the escape turn")
-- --- the trapping counter, on its own -------------------------------
-- a counter sitting at 0 holds its victim until CheckNumAttacksLeft clears
-- it, so it rides a separate turn: the held player must not be what keeps
-- the second mover from moving above.
local trap = setup("TELEPORT")
trap.enemy.trappingTurns = 0
trap:resolveTurn(trap.player.curMoves[1])
pump(trap)
T.eq(trap.result, "run", "the escape still settles the trapped turn")
T.eq(trap.enemy.trappingTurns, 0,
"CheckNumAttacksLeft does not release the counter on the escape turn")
local trapOk = setup("FIX_TACKLE")
trapOk.enemy.trappingTurns = 0
trapOk:resolveTurn(trapOk.player.curMoves[1])
pump(trapOk)
T.eq(trapOk.enemy.trappingTurns, nil,
"an ordinary turn releases the spent counter")
-- --- a plain turn still does all of it ------------------------------
local ok = setup("FIX_TACKLE")
ok.player.mon.status = "PSN"
local scratch = ok.player.curMoves[1]
local okEnemyHP, okPlayerHP = ok.enemy.mon.hp, ok.player.mon.hp
local okPP = scratch.pp
ok:resolveTurn(scratch)
local okSaid = pump(ok)
T.eq(ok.result, nil, "an ordinary turn settles nothing")
T.check(saidWith(okSaid, "FIX TACKLE"), "both movers announce")
T.check(ok.enemy.mon.hp < okEnemyHP, "the second mover's move lands")
T.eq(scratch.pp, okPP - 1, "the second mover spends PP")
T.check(ok.player.mon.hp < okPlayerHP, "the poison tick runs")
T.check(saidWith(okSaid, "hurt by"), "the residual sweep prints")
Data.moves.TELEPORT = nil
T.finish("teleport escape ends turn")
+139
View File
@@ -0,0 +1,139 @@
-- The shipped per-channel mix must be authentic, and the wave channel must
-- sit exactly one octave below a pulse at the same note (#429). On hardware
-- ch3 counts 65536/(2048-x) against the pulses' 131072/(2048-x), and pokered
-- writes the frequency register for CHAN3 unmodified
-- (audio/engine_1.asm:904-944 Audio1_ApplyWavePatternAndFrequency loads only
-- the wave pattern), with no attenuation either
-- (Audio1_ApplyDutyCycleAndSoundLength:887 skips just the duty nibble). A
-- CHANNEL_VOLUME/CHANNEL_PITCH of 0.25/0.5 on ch3 therefore buried every Ch3
-- countermelody at quarter amplitude and a second octave down.
-- ROM-free: ChipAsm blobs, no data/generated/.
-- luajit tests/engine/wave_channel_mix_bug429.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
-- requiring ChipAudio is the point of the first block: the module pushes its
-- shipped CHANNEL_VOLUME / CHANNEL_PITCH into ChipSynth at load
local ChipAudio = require("src.core.ChipAudio")
-- ------- the shipped defaults
local vols = ChipSynth.getChannelVolumes()
local pitches = ChipSynth.getChannelPitches()
for hw = 1, 4 do
check(vols[hw] == 1,
("channel %d ships at volume 1, not %s"):format(hw, tostring(vols[hw])))
check(pitches[hw] == 1,
("channel %d ships at pitch 1, not %s"):format(hw, tostring(pitches[hw])))
end
-- ChipAudio.playMusic forwards these to the worker with every play command
-- (ChipAudio.lua:190-191 -> chip_worker.lua:50-54), so a non-unity default
-- reaches the threaded path as well as the synchronous one
local forwarded = ChipAudio.getChannelVolumes()
check(forwarded[3] == 1, "the wave gain handed to the worker is unity")
check(ChipAudio.getChannelPitches()[3] == 1,
"the wave pitch handed to the worker is unity")
-- ------- fixtures: one note, once on a pulse and once on the wave channel
local data = { audio = {} }
-- +1 for the first half of the table and -1 for the second, so the wave
-- crosses zero twice per cycle exactly like a 50% duty pulse
local squareWave = {}
for index = 1, 32 do squareWave[index] = index <= 16 and 1 or -1 end
local function pulseSong(note, octave)
return ChipAsm.song{
channels = { { hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 15, fade = 0 } },
{ octave = octave },
{ note = note, len = 15 },
} } },
}
end
local function waveSong(note, octave)
return ChipAsm.song{
channels = { { hw = 3, program = {
{ notetype = { speed = 12, waveLevel = 1, waveInstrument = 0 } },
{ octave = octave },
{ note = note, len = 15 },
} } },
waves = { squareWave },
}
end
local function crossings(sd)
local count, prev = 0, sd:getSample(0)
for index = 1, sd:getSampleCount() - 1 do
local sample = sd:getSample(index)
if prev * sample < 0 then count = count + 1 end
prev = sample
end
return count
end
local function renderPulse(note, octave)
return crossings(ChipAudio._renderMusicChannelForTest(
data, pulseSong(note, octave), 0.25, 1))
end
local function renderWave(note, octave)
return crossings(ChipAudio._renderMusicChannelForTest(
data, waveSong(note, octave), 0.25, 3))
end
-- ------- one octave down, and only one
for _, spec in ipairs({ { "C", 4 }, { "G", 4 }, { "C", 5 } }) do
local note, octave = spec[1], spec[2]
local pulse = renderPulse(note, octave)
local wave = renderWave(note, octave)
check(pulse > 40, ("%s%d on the pulse channel sounds (%d crossings)")
:format(note, octave, pulse))
check(wave > 20 and math.abs(wave - pulse / 2) <= math.max(4, pulse * 0.06),
("%s%d on the wave channel is one octave down (%d vs %d crossings)")
:format(note, octave, wave, pulse))
end
-- the wave channel one octave up from a pulse note lands on that pulse note,
-- which is the same statement from the other side
local waveUp = renderWave("C", 5)
local pulseAt = renderPulse("C", 4)
check(math.abs(waveUp - pulseAt) <= math.max(4, pulseAt * 0.06),
("wave C5 matches pulse C4 (%d vs %d crossings)"):format(waveUp, pulseAt))
-- ------- the 0.1.38 mix, so the guard above is known to bite
local brokenPitch, brokenVol
local baseValue = ChipAudio._traceFirstMusicSampleForTest(
data, waveSong("C", 4))[1].value
ChipAudio.setChannelPitch(3, 0.5)
brokenPitch = renderWave("C", 4)
ChipAudio.setChannelVolume(3, 0.25)
brokenVol = ChipAudio._traceFirstMusicSampleForTest(
data, waveSong("C", 4))[1].value
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
local authentic = renderWave("C", 4)
check(brokenPitch > 0 and math.abs(brokenPitch - authentic / 2)
<= math.max(4, authentic * 0.06),
("pitch 0.5 on ch3 drops a second octave (%d vs %d crossings)")
:format(brokenPitch, authentic))
check(math.abs(baseValue) > 0 and math.abs(brokenVol - baseValue * 0.25) < 1e-9,
"volume 0.25 on ch3 quarters the wave sample")
check(ChipSynth.getChannelVolumes()[3] == 1
and ChipSynth.getChannelPitches()[3] == 1,
"the mix is back at unity for the suites that follow")
T.finish("wave channel mix")
+163
View File
@@ -0,0 +1,163 @@
-- Parity: starting a battle with no useable POKeMON blacks the player out,
-- it does not cancel the battle (#425). pokered's .checkAnyPartyAlive
-- (engine/battle/core.asm:158-162) runs after EnemySendOutFirstMon and the
-- 40-frame delay -- the battle has already begun -- and jumps to
-- HandlePlayerBlackOut (core.asm:1145-1166), which prints
-- PlayerBlackedOutText2 (data/text/text_2.asm:896) and drops through to the
-- overworld blackout warp (home/overworld.asm:335-360). Cancelling instead
-- left the battle theme looping, the party at 0 HP and no warp, so every
-- later encounter took the same exit.
-- Self-contained; run via `luajit tests/parity_blackout_no_party.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity blackout no party")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local OW = require("src.world.OverworldController")
local MAP = "ROUTE_1"
local MAP_SONG = Data.audio.mapSongs[MAP]
local WILD_SONG = Data.audio.battle.wild
-- every song request in order: "the theme was never restored" and "the theme
-- was never started" have to read differently. Music.playMap still sets the
-- state Music.restoreMap reads, so the real restore path is under test.
local songs = {}
Music.play = function(_, song) songs[#songs + 1] = song end
local function lastSong() return songs[#songs] end
-- run_tests.lua dofiles the parity files into one process and other suites
-- leave Music.playBattle a no-op, so stand in for its body here: one
-- Music.play of audio.battle[kind] (src/core/Music.lua:350-357)
Music.playBattle = function(data, kind)
local b = data.audio.battle
Music.play(data, b[kind] or b.wild)
end
local events = { listeners = {} }
function events:emit(name, payload)
self.listeners[name] = payload
end
function events:removeOwner() end
Runtime.install(events, Runtime.hooks)
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
local warp
local function newWorld(mapId)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, mapId, 5, 5, "down")
local ow = Game.stack:top()
Game.overworld = ow
-- the real one runs a Transition; only the destination matters here
ow.startWarpTo = function(self, map, x, y, facing)
warp = { map = map, x = x, y = y, facing = facing }
self.transitioning = false
end
Music.playMap(Data, mapId)
return ow
end
-- a wild encounter as OverworldState:checkEncounter builds it (:3182-3194)
local function encounter(ow)
local battle = BattleState.newWild(Game, "PIDGEY", 3)
local got
battle.onFinish = function(result)
got = result
ow:afterBattle(result, battle)
end
return battle, function() return got end
end
Game.save = SaveData.newGame()
Game.save.player.name = "RED"
Game.save.money = 3000
Game.save.party = { Pokemon.new(Data, "MAGIKARP", 10) }
Game.save.party[1].hp = 0
local full = Game.save.party[1].stats.hp
Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
outdoor = { id = "VIRIDIAN_CITY", x = 17, y = 8 } }
local ow = newWorld(MAP)
eq(lastSong(), MAP_SONG, MAP .. " is playing its own theme before the step")
local battle, result = encounter(ow)
eq(battle.dead, true, "the constructor still flags a partyless wild battle")
eq(battle.player, nil, "and installs no player battler (Party.firstHealthy)")
-- pushBattle starts the theme with the wipe, before enter() ever runs
-- (:660-679, audio/play_battle_music.asm), which is why a cancelled battle
-- left the battle music looping over the map
ow:pushBattle(battle)
eq(lastSong(), WILD_SONG, "the wipe has already started the battle theme")
Game.stack:pop() -- the transition; its callback pushes the battle below
Game.stack:push(battle)
eq(Game.stack:top() ~= battle, true, "enter() takes the battle back off the stack")
eq(lastSong(), MAP_SONG, "and restores the map theme instead of looping the battle one")
eq(battle.result, "lose", "the battle resolves as a loss, not a skip")
local ended = events.listeners["battle.ended"]
check(ended ~= nil and ended.result == "lose",
"battle.ended reports a loss for mods too")
local box = Game.stack:top()
check(getmetatable(box) == TextBox, "the blackout text is on screen over the map")
local lines = {}
for _, page in ipairs(box and box.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
local text = table.concat(lines, " / ")
check(text:find("RED", 1, true) ~= nil, "the text names the player")
check(text:find("out of", 1, true) ~= nil and text:find("POK", 1, true) ~= nil,
"PlayerBlackedOutText2 line 1: <PLAYER> is out of useable POKeMON!")
check(text:find("blacked", 1, true) ~= nil,
"PlayerBlackedOutText2 line 2: <PLAYER> blacked out!")
-- the cancel path called onFinish itself, with no box to dismiss
if box and box.onDone then box.onDone() end
eq(result(), "lose", "onFinish gets \"lose\", so afterBattle blacks out")
eq(Game.save.party[1].hp, full, "the party is revived at the heal point")
eq(Game.save.money, 1500, "half the money is lost, as on any blackout")
check(warp ~= nil and warp.map == "VIRIDIAN_POKECENTER",
"and the player is warped to the last heal point")
-- the brick: before #425 the party was still at 0 HP here, so this second
-- encounter took the same exit, and so did every one after it
local ow2 = newWorld(MAP)
local live = BattleState.newWild(Game, "PIDGEY", 3)
eq(live.dead, nil, "the next encounter is a real battle again")
check(live.player ~= nil, "with the revived MAGIKARP leading it")
-- Oak's Lab starter rival: HandlePlayerBlackOut rets above
-- PlayerBlackedOutText2 (core.asm:1147-1149) and the lab script heals, so
-- there is no text, no money loss and no warp.
Game.save.money = 3000
Game.save.party[1].hp = 0
local lab = newWorld("OAKS_LAB")
warp = nil
local labResult
local rival = BattleState.newTrainer(Game, "OPP_RIVAL1", 1)
rival.onFinish = function(r) labResult = r lab:afterBattle(r, rival) end
eq(rival.dead, true, "a partyless lab rival battle is flagged the same way")
Game.stack:push(rival)
check(getmetatable(Game.stack:top()) ~= TextBox,
"the lab rival prints no blackout text (core.asm:1147-1149)")
eq(labResult, "lose", "it still finishes as a loss")
eq(Game.save.money, 3000, "no money is lost in the lab")
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
S.finish()
+185
View File
@@ -0,0 +1,185 @@
-- Parity test, gift atomicity: a mon handed over by give_pokemon and the
-- event that closes its offer must land in the same script step, so a
-- script torn down between the two cannot hand the gift out twice (#426).
--
-- asm sources:
-- pokeyellow scripts/Route24.asm (Route24CooltrainerM4Text: CheckEvent
-- EVENT_54F -> YesNoChoice -> GivePokemon -> `jp nc, TextScriptEnd`
-- (party + box full leaves the event clear so the offer repeats) ->
-- PrintText Route24Text_515e3 -> SetEvent EVENT_54F)
-- pokeyellow scripts/CeruleanMelaniesHouse.asm (same shape plus predef
-- HideObject TOGGLE_CERULEAN_BULBASAUR, then SetEvent
-- EVENT_GOT_BULBASAUR_IN_CERULEAN)
-- pokeyellow scripts/VermilionCity_2.asm (CheckEvent / SetEvent
-- EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY)
-- scripts/CeladonMansionRoofHouse.asm (Eevee ball: GivePokemon with no
-- confirm, HideObject on success)
-- On hardware the event write trails the received text because no step in
-- between can abort. The port yields there (AskName, NamingScreen, the
-- text box) and wraps every row in the script.command mod hook, so the
-- write is hoisted ahead of the text: the event is only read at script
-- entry, and the failed-give path still leaves it clear.
--
-- Self-contained: run via `luajit tests/parity_gift_atomicity.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity gift atomicity")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Events = require("src.mods.Events")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local gifts = require("data.scripts.yellow_gifts")
local eevee = require("data.scripts.celadon_eevee")
-- === 1) row-order audit: on every gift site the carry guard follows
-- give_pokemon immediately and the bookkeeping (event, and the
-- HideObject that clears a ball or a pen mon) comes before any
-- received text ===
local function audit(label, rows)
local give
for i, row in ipairs(rows) do
if row[1] == "give_pokemon" then give = i break end
end
if not give then
check(false, label .. ": has a give_pokemon row")
return
end
eq(rows[give + 1] and rows[give + 1][1], "jump_if_false",
label .. ": carry guard sits right after give_pokemon")
local flag, text, hide
for i = give + 2, #rows do
local name = rows[i][1]
if name == "set_flag" and not flag then flag = i end
if name == "hide_object" and not hide then hide = i end
if (name == "show_text" or name == "ask") and not text then text = i end
if name == "jump" and rows[i][2] ~= nil and text then break end
end
eq(flag, give + 2, label .. ": event write is the first row past the guard")
check(text and flag < text,
label .. ": event write precedes the received text")
if hide then
check(hide < text, label .. ": HideObject precedes the received text")
end
end
-- the two function-form scripts build their rows per talk; run them with
-- the gift branch's preconditions and keep what they hand the runner
local function capture(fn, save)
local rows
local ow = { runner = { run = function(_, r) rows = r end } }
fn({ save = save }, ow, { def = {}, facePlayer = function() end },
function() end)
return rows or {}
end
audit("Route 24 Damian",
gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4)
audit("Melanie's BULBASAUR",
capture(gifts.CERULEAN_MELANIES_HOUSE.talk
.TEXT_CERULEANMELANIESHOUSE_MELANIE,
{ flags = {}, pikachuHappiness = 200 }))
audit("Officer Jenny's SQUIRTLE",
capture(gifts.VERMILION_CITY.talk.TEXT_VERMILIONCITY_OFFICER_JENNY,
{ flags = {}, inventory = { THUNDERBADGE = 1 } }))
audit("Celadon EEVEE ball",
eevee.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL)
-- === harness: run a row list headless, A-mashing through the yes/no,
-- the nickname prompt and every text box, recording show_text ids
-- (Yellow's gift text is not in a Red cache, so show_text takes
-- its literal-id fallback: the ids are still what we assert on) ===
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
end
local function runRows(rows)
shown = {}
StateStack:init()
local ow = { map = { id = "ROUTE_24", def = { label = "ROUTE_24" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(rows, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local DAMIAN = gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4
-- === 2) plain accept: one CHARMANDER, EVENT_54F set, and the next talk
-- is Damian's after-text only ===
Game.save = SaveData.newGame()
check(runRows(DAMIAN), "Damian gift script completes")
eq(#Game.save.party, 1, "CHARMANDER joins the party")
eq(Game.save.party[1].species, "CHARMANDER", "gift species is CHARMANDER")
check(Flags.get(Game.save, "EVENT_54F"), "accepting sets EVENT_54F")
check(runRows(DAMIAN), "post-gift talk completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"a closed offer shows only the after-text")
eq(#Game.save.party, 1, "no second CHARMANDER")
-- === 3) the regression itself: every row runs inside the script.command
-- hook, and a mod that mishandles the row after the give (the
-- reporter was running a third-party UI mod) tears the coroutine
-- down mid-gift -- here by sending the pc at a label that is not
-- there. The mon is already in the party, so EVENT_54F has to be
-- set by then or the next talk re-runs the whole offer ===
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local hooks = Hooks.new()
Runtime.install(Events.new(), hooks, {})
local remove = hooks:wrap("script.command", function(nextFn, _, name, args)
if name == "show_text" and args[1] == "_Route24DamianText2" then
return "no_such_label"
end
return nextFn()
end, 0, "t")
Game.save = SaveData.newGame()
local origError = Logger.error -- the tear-down logs; the test expects it
Logger.error = function() end
runRows(DAMIAN)
Logger.error = origError
eq(#Game.save.party, 1, "the killed script still handed the CHARMANDER over")
check(Flags.get(Game.save, "EVENT_54F"),
"EVENT_54F survives a tear-down after the give")
remove()
Runtime.install(savedEvents, savedHooks, savedErrors)
check(runRows(DAMIAN), "talk after the tear-down completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"the interrupted gift is not offered again")
eq(#Game.save.party, 1, "still exactly one CHARMANDER")
S.finish()
+184
View File
@@ -0,0 +1,184 @@
-- Parity test: a ball thrown at the POKEMON_TOWER_6F RESTLESS SOUL is
-- always dodged, scope or no scope.
--
-- ItemUseBall reaches the $10 "can't be caught" anim data by TWO
-- independent routes (engine/items/item_effects.asm):
--
-- :149-153 callfar IsGhostBattle / ld b, $10 / jp z, .setAnimData
-- :166-175 .notOldManBattle -- wCurMap == POKEMON_TOWER_6F and
-- wEnemyMonSpecies2 == RESTLESS_SOUL -> the same $10
--
-- The port only had the first, as the scope-less disguise flag
-- self.ghost. Once the SILPH_SCOPE revealed the MAROWAK the battle was
-- an ordinary wild one, so throwBall ran the capture roll and a MASTER
-- BALL caught it outright. That result is "caught", not "win" or the
-- POKE DOLL escape, so PokemonTower6F's script never set
-- EVENT_BEAT_GHOST_MAROWAK and the (10,16) trigger re-fired forever
-- (#444). The map+species half sits BEFORE .loop, hence before the
-- MASTER_BALL shortcut, so even a Master Ball is dodged.
--
-- Run-away parity is the other side of this: only IsGhostBattle grants
-- the free escape (engine/battle/core.asm TryRunningFromBattle), so a
-- revealed MAROWAK keeps normal flee rolls and self.ghost stays the sole
-- gate there.
--
-- Self-contained; run via `luajit tests/parity_marowak_ball.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity marowak ball")
local check, eq = S.check, S.eq
local BattleState = require("src.battle.BattleState")
-- ---- 1. the 6F script arms noCatch with and without the scope -----------
do
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local made = {}
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = { species = species, level = level, ghost = false }
b.makeGhost = function(self) self.ghost = true end
made[#made + 1] = b
return b
end,
}
local tower = dofile("data/scripts/story3.lua").POKEMON_TOWER_6F
local function trigger(inventory)
local pushed = {}
local game = {
save = { inventory = inventory, flags = {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local ow = {
player = {},
scriptMove = function() end,
afterBattle = function() end,
}
check(tower.onStep(game, ow, 10, 16), "the trigger fires on (10,16)")
pushed[1].done()
return made[#made]
end
local noScope = trigger({})
check(noScope.ghost, "without the scope the battle is still disguised")
check(noScope.noCatch, "and noCatch is set")
local withScope = trigger({ SILPH_SCOPE = 1 })
check(not withScope.ghost, "with the scope the disguise is gone")
check(withScope.noCatch,
"but noCatch survives it -- balls are dodged either way")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
end
-- ---- 2. throwBall takes the dodge branch on noCatch alone ---------------
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = { play = function() end }
-- A real BattleState minus the pieces the decision does not touch: the
-- capture roll and the ball chain record that they were reached, which is
-- exactly the bug (a MASTER BALL catching the revealed MAROWAK).
local function throw(flags, ball)
local self = setmetatable({
kind = "wild",
ghost = flags.ghost or false,
noCatch = flags.noCatch or false,
queue = {},
rolled = false,
chained = false,
enemyMoved = false,
turnEnded = false,
data = { items = { MASTER_BALL = { name = "MASTER BALL" },
POKE_BALL = { name = "POKé BALL" } },
text = {} },
game = { save = { player = { name = "RED" } } },
player = {},
enemy = {},
}, BattleState)
self.ballDef = function() return nil end
self.catchAttempt = function(s) s.rolled = true return false, 3 end
self.ballChain = function(s) s.chained = true end
self.enemyAction = function() return {} end
self.executeAction = function(s) s.enemyMoved = true end
self.endOfTurn = function(s) s.turnEnded = true end
self:throwBall(ball)
-- the whole outcome lives in the act() closure throwBall queues, and
-- that closure queues more rows, so drain like updateQueue does: run
-- each fn row once, with nextInsert pointing at it.
local ran = {}
local more = true
while more do
more = false
for i, row in ipairs(self.queue) do
if row.fn and not ran[row] then
ran[row] = true
self.nextInsert = i
row.fn()
more = true
break
end
end
end
local texts = {}
for _, row in ipairs(self.queue) do
if row.text then texts[#texts + 1] = tostring(row.text) end
end
self.texts = table.concat(texts, "|")
return self
end
local function assertDodge(b, label)
check(not b.rolled, label .. ": no capture roll")
check(not b.chained, label .. ": no wobble chain")
check(b.texts:find("It dodged the", 1, true) ~= nil,
label .. ": ItemUseBallText00 line 1")
check(b.texts:find("can't be caught", 1, true) ~= nil,
label .. ": ItemUseBallText00 line 2")
check(b.enemyMoved, label .. ": the turn is spent, the foe moves")
check(b.turnEnded, label .. ": and the turn ends")
end
assertDodge(throw({ ghost = true }, "POKE_BALL"), "IsGhostBattle exit")
assertDodge(throw({ noCatch = true }, "POKE_BALL"), ".notOldManBattle exit")
-- the regression itself: revealed by the scope, so ghost is false
assertDodge(throw({ noCatch = true }, "MASTER_BALL"), "MASTER BALL")
do
local plain = throw({}, "MASTER_BALL")
check(plain.rolled,
"an ordinary wild mon still rolls -- the guard is not global")
end
-- The dodged toss keeps the arc the thrown ball picked (TossBallAnimation
-- reads wCurItem), so the Master Ball flicker is not lost.
do
local b = throw({ noCatch = true }, "MASTER_BALL")
local anim
for _, row in ipairs(b.queue) do
if row.anim then anim = row.anim break end
end
eq("ULTRATOSS_ANIM", anim, "a dodged MASTER BALL still tosses as ULTRATOSS")
end
package.loaded["src.core.Sound"] = realSound
-- ---- 3. noCatch grants no free escape ----------------------------------
do
local function roll(flags)
local b = { ghost = flags.ghost or false, noCatch = flags.noCatch or false,
runAttempts = 1, rng = function() return 255 end }
return BattleState.runRollVanilla(b, 10, 100)
end
check(roll({ ghost = true }), "IsGhostBattle still always escapes")
check(not roll({ noCatch = true }),
"a revealed MAROWAK takes the normal flee roll")
end
S.finish()
+101
View File
@@ -0,0 +1,101 @@
-- Parity test: the status screen's mon pic has to sit out the SGB recolor
-- when the sprite record is true-color (#430). SetPal_StatusScreen puts a
-- monPal zone over the pic rect (status_screen.asm, mirrored by
-- SummaryMenu:sgbPalettes), so a full-color pic drawn there was run through
-- the 4-shade remap the way battle and the Pokedex entry page (#350) already
-- avoid. Run with `luajit tests/parity_status_true_color.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity status true color")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
require("src.render.Font").load(Data)
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local Sound = require("src.core.Sound")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local SummaryMenu = require("src.ui.SummaryMenu")
local savedCry = Sound.playCry
Sound.playCry = function() end
-- the rects the frame reported on the UI canvas, the pass the status screen
-- draws into (Renderer appends them to that pass's zone list)
local function uiRects(draw)
PaletteFX.clearTrueColor()
PaletteFX.setPass("ui")
draw()
local rects = PaletteFX.trueColorRects("ui")
PaletteFX.setPass(nil)
return rects
end
local def = Data.pokemon.PIKACHU
local savedTrueColor = def.trueColor
local save = SaveData.newGame()
local mon = Pokemon.new(Data, "PIKACHU", 20)
local game = { data = Data, save = save, stack = { pop = function() end } }
-- the premise: this screen colorizes the pic through a species zone, so a
-- true-color pic needs an unshaded rect on top of it
do
local pals = SummaryMenu.sgbPalettes({ mon = mon }, game)
local monPal = PaletteFX.monPal(Data, "PIKACHU")
local zone
for _, z in ipairs(pals or {}) do
if z.colors == monPal and z.w < 160 then zone = z end
end
check(zone ~= nil, "SetPal_StatusScreen's monPal zone covers the pic")
if zone then
eq(zone.x .. "," .. zone.y, "8,0", "and it starts at the pic's corner")
end
end
def.trueColor = true
local _, pathTrueColor = Sprites.path(Data, "PIKACHU", "front",
{ mon = mon, kind = "summary" })
check(pathTrueColor == true,
"Sprites.path reports trueColor for the summary pic")
local screen = SummaryMenu.new(game, mon)
check(screen.spriteTrueColor == true,
"SummaryMenu keeps the sprite's trueColor flag (#430)")
local pw, ph = screen.sprite:getDimensions()
local py = math.max(0, 56 - ph)
-- LoadFlippedFrontSpriteByMonIndex: the draw is a negative x scale anchored
-- at 8 + pw, so the covered rect still starts at x = 8
local page1 = uiRects(function() screen:draw() end)
eq(#page1, 1, "page 1 reports one true-color rect")
if page1[1] then
eq(("%d,%d,%d,%d"):format(page1[1].x, page1[1].y, page1[1].w, page1[1].h),
("%d,%d,%d,%d"):format(8, py, pw, ph),
"and it is the mirrored draw's rect, not the unflipped one")
check(page1[1].colors == false,
"and it is an unshaded zone, not a palette")
end
screen.page = 2
local page2 = uiRects(function() screen:draw() end)
eq(#page2, 1, "page 2 (StatusScreen2 keeps the pic) reports it too")
-- vanilla records set no flag, so the zone list stays exactly what
-- sgbPalettes returned
def.trueColor = nil
local plain = SummaryMenu.new(game, mon)
check(plain.spriteTrueColor == false, "a plain pic reports no flag")
eq(#uiRects(function() plain:draw() end), 0,
"and marks no rect, leaving the SGB pass untouched")
def.trueColor = savedTrueColor
Sound.playCry = savedCry
PaletteFX.clearTrueColor()
S.finish()