mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user