CLOSES #1478, CLOSES #1479, CLOSES #1488, CLOSES #1510, CLOSES #1511, CLOSES #1514, CLOSES #1521, CLOSES #1522, CLOSES #1545, CLOSES #1557, CLOSES #1563, CLOSES #1565, CLOSES #1566, CLOSES #1577, CLOSES #1594, CLOSES #1596, CLOSES #1606, CLOSES #1608

This commit is contained in:
bryanthaboi
2026-08-20 17:30:18 -04:00
parent 0f8f6d0e4f
commit dbecc345e3
53 changed files with 881 additions and 225 deletions
@@ -23,8 +23,13 @@ return function(game)
game.save.party = { mon }
-- engine/movie/evolution.asm:103
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
U.wait(12)
local top = game.stack:top()
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
local top
for _ = 1, 300 do
top = game.stack:top()
if top and top.screenId == "EvolutionState" then break end
U.wait(1)
end
check("the evolution screen opened",
top and top.screenId == "EvolutionState")
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
@@ -39,9 +39,13 @@ return function(game)
local mon = Pokemon.new(game.data, "PIKACHU", 20)
game.save.party = { mon }
Evolution.evolve(game, mon, "RAICHU")
U.wait(12)
local top = game.stack:top()
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
local top
for _ = 1, 300 do
top = game.stack:top()
if top and top.screenId == "EvolutionState" then break end
U.wait(1)
end
check("the evolution screen opened", top and top.screenId == "EvolutionState")
U.log("Issue #494: true-color sprites during evolutions and trades")
U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.")
+53 -14
View File
@@ -50,7 +50,8 @@ return function(game)
U.wait(5)
local ow = game.stack:top()
U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png")
ow:checkVictoryRewards(class, party)
-- true: the badge line rode the battle screen on the real path (#1606)
ow:checkVictoryRewards(class, party, true)
U.wait(10)
for _, s in ipairs(shots.pages) do
advancePages(s.want, shots.prefix .. "_" .. s.name)
@@ -63,26 +64,64 @@ return function(game)
game.save.player.name = game.save.player.name or "RED"
runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, {
prefix = "brock",
pages = {
{ want = "BOULDERBADGE", name = "1_badge" },
{ want = "FLASH", name = "2_flash" },
{ want = "Wait!", name = "3_wait" },
{ want = "TM34", name = "4_tm34" },
{ want = "BIDE", name = "5_bide" },
},
})
-- The REAL gym path (#1606): the badge line and jingle must ride the
-- battle it pushes (scripts/PewterGym.asm:117-119).
while game.stack:top() do game.stack:pop() end
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.flags = game.save.flags or {}
game.save.inventory = game.save.inventory or {}
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
game.stack:push(OW, "PEWTER_GYM", 4, 3, "up")
U.wait(5)
local realOw = game.stack:top()
local brock
for _, npc in ipairs(realOw.npcs or {}) do
if npc.def and npc.def.trainerClass == "OPP_BROCK" then brock = npc end
end
assert(brock, "Brock stands in PEWTER_GYM")
require("data.scripts.gyms").PEWTER_GYM.talk.TEXT_PEWTERGYM_BROCK(
game, realOw, brock, function() end)
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.oppClass == "OPP_BROCK" and top.onFinish then
battle = top
break
end
U.tap(game, "a")
U.wait(2)
end
assert(battle, "the real gym path reaches Brock's BattleState")
assert(type(battle.endBattleText) == "string" and #battle.endBattleText > 0,
"the battle carries the armed badge line (#1606)")
assert(battle.endBattleSound == "Get_Item1",
"and the badge jingle beside it (sound_level_up, PewterGym.asm)")
U.shot(game, DIR .. "/brock_real_battle.png")
U.log("force-finishing Brock's battle to run the reward chain")
battle.onFinish("win")
if game.stack:top() == battle then game.stack:pop() end
U.wait(10)
-- rewardDialogueShown: the badge line rode the battle screen, so the map
-- chain opens on the TM prelude, not on a reprint of the badge line
for _, s in ipairs({
{ want = "Wait!", name = "1_wait" },
{ want = "TM34", name = "2_tm34" },
{ want = "BIDE", name = "3_bide" },
}) do
advancePages(s.want, "brock_" .. s.name)
end
assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK")
assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE")
assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE")
-- checkVictoryRewards direct drive, as after a battle whose badge line
-- rode the battle screen (shownOnBattleScreen = true)
runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, {
prefix = "misty",
pages = {
{ want = "CASCADEBADGE", name = "1_badge" },
{ want = "CUT", name = "2_cut" },
{ want = "TM11", name = "3_tm11" },
{ want = "CUT", name = "1_cut" },
{ want = "TM11", name = "2_tm11" },
},
})
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
+16
View File
@@ -87,6 +87,22 @@ do
"and no title row: the box carries no header text")
end
-- the box keeps the palette beneath and caps the cursor at wMaxMenuItem
do
local list = newList(6)
eq(list.sgbPalettes, false,
"no SET_PAL_GENERIC: ItemMenuLoop keeps RunDefaultPaletteCommand's "
.. "palette (start_sub_menus.asm:300)")
eq(list.cursorRows, 3,
"wMaxMenuItem 2: three cursor rows (home/list_menu.asm:46-48)")
list.game = { input = { wasPressed = function(_, b) return b == "down" end,
isDown = function() return false end } }
for _ = 1, 3 do list:update(1 / 60) end
eq(list.index, 4, "three downs reach the fourth item")
eq(list.index - list.scroll, 3,
"scrolling instead of dropping the cursor onto the look-ahead row")
end
-- a short list stops at its last name, and the terminator's CANCEL row is
-- what would follow -- never the '▼'
do
@@ -40,6 +40,11 @@ do
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "wDownscaledMonSize 1 -> 3x3")
battle.shrinkOut.frame = 6
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "through Delay3")
battle.shrinkOut.frame = 7
T.eq(battle:shrinkOutScale(battle.player), 0,
"then the 7x7 area holds cleared: no full-size flash before the swap")
T.check(battle:shrinkOutScale({}) == nil,
"the swapped-in battler draws normally")
T.check(battle:shrinkOutScale(battle.enemy) == nil,
"AnimateRetreatingPlayerMon is player-side only")
end
@@ -62,6 +67,8 @@ do
T.check(battle:shrinkOutScale(battle.player) == nil, "and no downscale stage")
battle.queue[3].fn()
T.check((battle.picOff or {}).playerMon == nil, "the slot clears afterwards")
T.eq(battle.sendingOut, true,
"and stays hidden until the swap's send-out (#1545)")
end
-- ---------------------------------------------------------------------
@@ -109,4 +116,62 @@ do
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts")
end
-- ---------------------------------------------------------------------
-- the SHIFT prompt's picker reprompts on a dead or already-out pick too
-- (HasMonFainted's NoWillText, core.asm:1473-1488) (#1608)
-- ---------------------------------------------------------------------
do
local save = SaveData.newGame()
save.player.name = "RED"
save.party = { Pokemon.new(Data, "FIXMON_A", 30),
Pokemon.new(Data, "FIXMON_B", 30) }
save.options = { battleStyle = "shift" }
local game = { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.participants = {}
battle.buildScreen = function(_, _, opts) return opts end
-- capture the picker opts the SHIFT branch pushes
local captured
Data.screens = Data.screens or {}
Data.screens.PartyMenu = function(_, opts) captured = opts; return {} end
require("src.ui.Screens").invalidate()
battle.enemyParty[1].hp = 0
battle.enemy.mon = battle.enemyParty[1]
battle:enemyMonFainted()
local choiceRow
for _, row in ipairs(battle.queue) do
if row.choice then choiceRow = row end
end
T.check(choiceRow ~= nil, "SHIFT queues the change-POKeMON choice")
choiceRow.choice(true)
T.check(captured ~= nil and captured.forceSwitch == true,
"YES opens the forced party picker")
battle.queue, battle.nextInsert = {}, 0
save.party[2].hp = 0
captured.onSwitch(save.party[2])
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("no will", 1, true) ~= nil,
"a fainted SHIFT pick prints NoWillText first")
T.check(battle.queue[2] and battle.queue[2].ui ~= nil,
"then the picker goes straight back up, ahead of the send-out")
T.eq(battle.queue[2].ui(), captured, "with the same forced opts")
battle.queue, battle.nextInsert = {}, 0
captured.onSwitch(battle.player.mon)
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("already out", 1, true) ~= nil,
"an already-out SHIFT pick prints AlreadyOutText")
T.check(battle.queue[2] and battle.queue[2].ui ~= nil, "and reprompts too")
battle.queue, battle.nextInsert = {}, 0
save.party[2].hp = 10
captured.onSwitch(save.party[2])
T.eq(#battle.queue, 0, "a healthy pick queues no reprompt rows")
Data.screens.PartyMenu = nil
require("src.ui.Screens").invalidate()
end
T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)")
+12 -6
View File
@@ -30,7 +30,9 @@ package.loaded["src.core.Sound"] = {
playCry = function() end,
}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
new = function(_, text, done, opts)
return { textBox = true, text = text, done = done, opts = opts }
end,
}
-- BagMenu and PartyMenu bind TextBox at require time, so they load against the
-- stub; Screens caches its factory per id and must be told to forget.
@@ -126,17 +128,21 @@ local function useStone(game)
game.input.pressed = "a"
picker:update(1 / 60)
game.input.pressed = nil
-- IsEvolvingText hands off to the movie when it closes
-- (engine/pokemon/evos_moves.asm:120-134)
-- IsEvolvingText STAYS up; its onShown starts the DelayFrames 50 hold,
-- which then pushes the movie over it (evos_moves.asm:120-134) (#1596)
local intro = game.stack:top()
if not (intro and intro.textBox and intro.done) then
if not (intro and intro.textBox and intro.opts and intro.opts.stay) then
return nil, "the \"is evolving!\" box never opened"
end
if not tostring(intro.text):find("evolving") then
return nil, "the box before the movie is not _IsEvolvingText"
end
game.stack:pop()
intro.done()
intro.opts.stay.onShown()
local hold = game.stack:top()
for _ = 1, 60 do
if pushed then break end
if hold.update then hold.update() end
end
return list
end
@@ -1,5 +1,6 @@
-- The evolution dialogue is the cart's, in the cart's order (#1596):
-- engine/pokemon/evos_moves.asm:120-128, :136-150, :151-153
-- engine/pokemon/evos_moves.asm:120-134 (the clear is rows 0-11 only),
-- :136-150, :151-153
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -65,10 +66,9 @@ do
"_IsEvolvingText goes up in a real bordered text box first") then
check(textOf(intro):find("is evolving"),
"and it is the cart's line: " .. textOf(intro))
check(intro.auto ~= nil and not intro.auto.wait,
"which waits for no button (IsEvolvingText ends in `done`)")
eq(intro.auto.delay, 50,
"and holds DelayFrames 50 before it clears (evos_moves.asm:122)")
check(intro.stay ~= nil and not intro.stay.prompt,
"which waits for no button (IsEvolvingText ends in `done`) "
.. "and stays up under whatever follows")
end
-- it hands off to the movie on its own, with no input at all
local top
@@ -78,7 +78,15 @@ do
step(game)
end
check(getmetatable(top) == EvolutionState,
"the flash movie opens once that box has cleared itself")
"the flash movie opens once the DelayFrames 50 hold has passed")
local underneath = false
for _, s in ipairs(game.stack.states or {}) do
if s == intro then underneath = true end
end
check(underneath, "the 'is evolving!' box is still on the stack under the "
.. "flash (ClearScreenArea wipes rows 0-11 only, evos_moves.asm:126-128)")
check(not EvolutionState.isOpaque,
"and the flash screen is not opaque, so the box beneath draws")
eq(mon.species, "FIXMON_A",
"and nothing has evolved yet while the box was up")
end
@@ -122,12 +130,4 @@ do
end
end
-- The flash itself carries no text: EvolutionState draws sprites only.
do
local source = assert(io.open("src/ui/EvolutionState.lua")):read("*a")
check(not source:find("Font%.draw"),
"EvolutionState draws no bare text over the flash "
.. "(ClearScreenArea ran before EvolveMon, evos_moves.asm:128)")
end
T.finish()
@@ -35,4 +35,17 @@ local menu = PokedexMenu.new(game, { onClose = function() closed = true end })
menu:close()
check(closed, "close still runs the caller's onClose")
-- Save.newGame seeds the key and Save.validate clamps a hand-edited value
local Save = require("src.core.gen2.Save")
eq(Save.newGame().lastDexMode, "NEW",
"a brand-new save carries the key from the start (wLastDexMode's zero)")
local edited = Save.newGame()
edited.lastDexMode = "SPICY"
Save.validate(edited)
eq(edited.lastDexMode, "NEW", "validate clamps an out-of-range mode to NEW")
local kept = Save.newGame()
kept.lastDexMode = "A-Z"
Save.validate(kept)
eq(kept.lastDexMode, "A-Z", "and keeps a legal one")
T.finish("gen2 pokedex mode persistence bug 1474")
+25 -1
View File
@@ -34,4 +34,28 @@ T.check(not keys["TILESET_KANTO|ROOF_SILVER"],
T.check(keys["TILESET_JOHTO|ROOF_SILVER"],
"while TILESET_JOHTO still takes its group's roof")
print("gen2 Kanto roof gate (#1479): ok")
-- World:atlasFor is the copy the bug was filed against; it has its own
-- ROOF_TILESETS gate, so pin it separately from MapPreview's
local realAssets = package.loaded["src.render.Assets"]
package.loaded["src.render.Assets"] = setmetatable({
image = function() return { setFilter = function() end } end,
resolve = function(path) return path end,
}, { __index = realAssets or { register = function() end } })
package.loaded["src.world.gen2.World"] = nil
local World = require("src.world.gen2.World")
local world = setmetatable({
tilesets = baker.tilesets,
roofs = baker.roofs,
atlasCache = {},
}, { __index = World })
world:atlasFor({ tileset = "TILESET_KANTO", group = 19 })
world:atlasFor({ tileset = "TILESET_JOHTO", group = 19 })
T.check(world.atlasCache["TILESET_KANTO"] ~= nil
and world.atlasCache["TILESET_KANTO|ROOF_SILVER"] == nil,
"World:atlasFor bakes Kanto with no map-group roof (home/map.asm:1738-1749)")
T.check(world.atlasCache["TILESET_JOHTO|ROOF_SILVER"] ~= nil,
"and still roofs TILESET_JOHTO by group")
package.loaded["src.render.Assets"] = realAssets
package.loaded["src.world.gen2.World"] = nil
T.finish("gen2 Kanto roof gate (#1479)")
+51
View File
@@ -0,0 +1,51 @@
-- #1558: menu_gfx.stats -> $31..$41 quads (engine/gfx/load_font.asm:90-95);
-- PAGE_PALETTES is gfx/stats/pages.pal (cgb_layouts.asm:199-212)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
-- gfx/stats/pages.pal, RGB 5-bit rows in ROM order: pink, green, blue
local ROM_PALS = {
{ { 31, 31, 31 }, { 31, 19, 31 }, { 31, 15, 31 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 21, 31, 14 }, { 17, 31, 0 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 17, 31, 31 }, { 17, 31, 31 }, { 0, 0, 0 } },
}
local function up(v) return math.floor(v * 255 / 31 + 0.5) end
for p = 1, 3 do
for c = 1, 4 do
for ch = 1, 3 do
T.eq(SummaryMenu.PAGE_PALETTES[p][c][ch], up(ROM_PALS[p][c][ch]),
("pages.pal palette %d color %d channel %d"):format(p, c, ch))
end
end
end
-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet
love.graphics = love.graphics or {}
local realNewQuad = love.graphics.newQuad
love.graphics.newQuad = function(x, y, w, h)
return { x = x, y = y, w = w, h = h }
end
local menu = setmetatable({
menuGfx = { stats = { sheet = "stats", tiles = 17, firstTile = 0x31 } },
picImage = function()
return { getDimensions = function() return 136, 8 end }
end,
}, { __index = SummaryMenu })
local sheet = menu:statsTiles()
if T.check(sheet ~= nil, "menu_gfx.stats builds the tile sheet") then
for id = 0x31, 0x41 do
local q = sheet.quads[id]
T.check(q ~= nil and q.x == (id - 0x31) * 8 and q.w == 8 and q.h == 8,
("tile $%02x maps to sheet cell %d"):format(id, id - 0x31))
end
T.check(sheet.quads[0x42] == nil, "and exactly 17 tiles, no more")
end
-- a cache built before menu_gfx.stats existed keeps the hand-drawn fallback
local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu })
T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes")
love.graphics.newQuad = realNewQuad
T.finish("gen2 stats tiles and page palettes (#1558)")
+7 -2
View File
@@ -93,7 +93,7 @@ do
local money = indexOf(events, "money")
T.check(ret and win and money, "all three win rows are queued: " ..
kinds(events))
T.check(defeated < ret, "BattleText_EnemyWasDefeated comes first")
T.check(defeated < ret, "the last faint event precedes the pic's return")
T.check(ret < win, "the frontpic slides back in before the line")
T.check(win < money, "and PrintWinLossText runs before the payout")
T.eq(winEvent.text, "Th-Thank you!", "the struct's win text is printed")
@@ -153,12 +153,17 @@ do
T.eq(win, "Th-Thank you!", "the struct's win text is decoded")
T.eq(loss, "...Too weak...", "and its loss text with it")
world.vm.winLossArmed = true
world.vm.winTextOverride = "3:4200"
win = World.trainerWinLossText(world)
win, loss = World.trainerWinLossText(world)
T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer")
-- winlosstext writes BOTH pointers; its 0 loss argument destroyed the
-- struct's value (engine/overworld/scripting.asm:651)
T.eq(loss, nil, "and a 0 loss argument zeroes the loss pointer with it")
world.vm.trainerObject = nil
world.vm.winTextOverride = nil
world.vm.winLossArmed = nil
win, loss = World.trainerWinLossText(world)
T.eq(win, nil, "a battle with no trainer object has no line")
T.eq(loss, nil, "on either side")
@@ -35,13 +35,15 @@ end
local fakeGame = { data = { text = text }, save = { flags = {} } }
local armed
local armed, armedSound
local fakeOw = {
engageTrainer = function(_, _, _, endBattleText) armed = endBattleText end,
engageTrainer = function(_, _, _, endBattleText, _, endBattleSound)
armed, armedSound = endBattleText, endBattleSound
end,
}
local function armedFor(mapId, textId, victoryKey)
armed = nil
armed, armedSound = nil, nil
gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end)
local labels = victories[victoryKey].dialogue
local want = {}
@@ -63,6 +65,10 @@ local leaders = {
for _, entry in ipairs(leaders) do
local got, want = armedFor(entry[1], entry[2], entry[3])
T.eq(got, want, entry[3] .. " arms its badge line for the battle screen")
-- the dialogue's sound command rides the armed line onto the battle
-- screen too (sound_get_item_1 / sound_get_key_item) (#1606)
T.eq(armedSound, victories[entry[3]].badgeSound,
entry[3] .. " arms its badge jingle beside the line")
end
-- Brock's armed label is one text chain of two text_far pages
@@ -137,3 +143,5 @@ T.check(afterBattleScreen:find(text["_CeruleanGymMistyCascadeBadgeInfoText"],
1, true) ~= nil,
"the TM hand-over still runs on the map")
T.check(rewardGame.save.inventory.CASCADEBADGE == 1, "the badge is still given")
T.finish("gym end battle text (#1606)")
@@ -75,6 +75,25 @@ T.eq(title.menuOpen, true, "the title art stays down until the blink ends")
run(back, 60)
T.eq(title.menuOpen, false, "and comes back once the blink is over")
T.eq(game.stack:top(), title, "leaving the menu lands back on the title")
-- main_menu.asm:70 jumps back to DisplayTitleScreen: the whole cinematic reruns
T.eq(title.phase, "drop", "cancel reruns the boot cinematic from the logo drop")
-- a stranded menuOpen (an onSelect that handed control straight back) may
-- not leave a blank white title behind
title.menuOpen = true
title:update(1 / 60)
T.eq(title.menuOpen, false, "a stranded menuOpen clears on the next update")
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the white-out
-- (engine/movie/title.asm:241-243)
while title.phase ~= "loop" do title:updateSequence() end
game.input.wasPressed = function(_, b) return b == "start" end
title:update(1 / 60)
game.input.wasPressed = function() return false end
T.eq(title.phase, "exitCry", "START waits out the cry before the white-out")
T.eq(game.stack:top(), title, "no flash is pushed on the cry frame")
run(title, 10)
T.check(game.stack:top() ~= title, "the flash follows once the cry is done")
-- ------------------------------------------- #1511: the intro NAME box
@@ -144,11 +163,20 @@ local function pump(frames)
end
end
pump(400)
T.check(getmetatable(fgame.stack:top()) == Menu, "the preset list opens")
-- _IntroducePlayerText ends in `prompt` (text_2.asm:1730): arrowed A wait
T.check(getmetatable(fgame.stack:top()) ~= Menu,
"the question box waits for A before the name list")
T.check(fgame.stack:top() == flow.holdBox and flow.holdBox.done,
"the typed-out question box is on top, waiting for the press")
fgame.input.wasPressed = function(_, b) return b == "a" end
fgame.stack:top():update(1 / 60)
fgame.input.wasPressed = function() return false end
pump(400)
T.check(getmetatable(fgame.stack:top()) == Menu, "A opens the preset list")
T.eq(fgame.stack.states[2], flow.holdBox,
"IntroducePlayerText's box is still on the stack under the name list")
T.check(flow.holdBox.done and flow.holdBox.stay,
"it is a `stay` box: text_end returns from PrintText without a wait")
T.check(flow.holdBox.stayShown == true,
"prompt-then-hold: the box stays up after the press (home/text.asm:434)")
local preset = fgame.stack:top()
preset.index = 2
@@ -157,7 +185,9 @@ preset:update(1 / 60)
fgame.input.wasPressed = function() return false end
T.eq(fgame.save.player.name, "RED", "picking a preset names the player")
T.eq(flow.holdBox, nil, "and takes the question box down with the list")
run(fgame.stack:top(), 6 * 3)
-- 13-frame ClearScreenArea / DelayFrames beat, then six tiles of slide
-- (oak_speech2.asm:69-78)
run(fgame.stack:top(), 13 + 6 * 3)
T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back")
T.finish("intro_title_naming_bug1510_1511")
+7 -4
View File
@@ -87,9 +87,12 @@ T.eq(box.text, fakeGame.data.text._AccessedHoFPCText,
box.onDone()
T.same(opened, { "LeaguePC" }, "the Hall of Fame roster screen opens")
-- without the Pokedex the league row still shows (bills_pc.asm checks
-- wNumHoFTeams before EVENT_GOT_POKEDEX)
-- without the Pokedex, .noOaksPC2 skips Oak's PC and the league row alike
-- (bills_pc.asm:48-49, :68-72); only the box height ignores it (:5-7)
fakeGame.save.flags.EVENT_GOT_POKEDEX = nil
local noDex = labels()
T.check(indexOf(noDex, "<PK><MN>LEAGUE"),
"the league row does not depend on EVENT_GOT_POKEDEX")
T.eq(indexOf(noDex, "<PK><MN>LEAGUE"), nil,
"no dex, no PKMN LEAGUE row, HoF teams or not")
T.eq(indexOf(noDex, "PROF.OAK's PC"), nil, "and no PROF.OAK's PC either")
T.finish("pc league row (#1566)")
@@ -159,6 +159,47 @@ do
end
end
-- .useRareCandy: TryEvolvingMon runs over the party list
-- (item_effects.asm:1392-1418) (#1594)
do
local evolveCalls = {}
package.loaded["src.pokemon.Evolution"] = {
pendingFor = function() return "FIXMON_B", { method = "LEVEL" } end,
evolve = function(_, _, to, onDone, via)
evolveCalls[#evolveCalls + 1] = { to = to, onDone = onDone, via = via }
end,
}
package.loaded["src.battle.BattleState"] = {
StatBox = { new = function(_, _, cb) return { statBox = true, cb = cb } end },
}
local game = freshGame(3)
local list = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag reached the picker (evolution case)") then
local box = game.stack:top()
check(isBox(box), "the level line prints first")
game.stack:pop() -- a real TextBox pops itself before onDone
box.done()
local stat = game.stack:top()
if check(stat and stat.statBox, "then the stat window") then
game.stack:pop() -- as does the stat window before its callback
stat.cb()
eq(#evolveCalls, 1, "the pending evolution starts")
check(inStack(game.stack, isPicker),
"with the party picker STILL up: the evolution prints over it, "
.. "not over the bag list (#1594)")
check(type(evolveCalls[1].onDone) == "function",
"closePicker rides the evolution's completion callback")
evolveCalls[1].onDone()
check(not inStack(game.stack, isPicker),
"and the picker comes down once the evolution flow completes")
check(inStack(game.stack, function(s) return s == list end),
"while the bag list survives (#796)")
end
end
package.loaded["src.pokemon.Evolution"] = nil
package.loaded["src.battle.BattleState"] = nil
end
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
-- cursor clamps to a real row -- but the list itself still must not close.
do
+7 -1
View File
@@ -35,6 +35,10 @@ end
T.check(save ~= nil, "the start menu lists SAVE")
save.onSelect()
-- PrintSaveScreenText ends `ld c, 30 / jp DelayFrames`: the bare panel
-- holds 30 frames before the prompt (main_menu.asm:404-405)
T.eq(#game.stack.states, 1, "the panel shows alone first")
for _ = 1, 30 do game.stack:top().update() end
T.eq(#game.stack.states, 2, "the panel and the prompt are two separate states")
local panel, prompt = game.stack.states[1], game.stack.states[2]
T.check(type(panel.draw) == "function" and not panel.isTextBox,
@@ -44,9 +48,11 @@ T.eq(#prompt.pages, 1, "the prompt is one page: no \\f-merged info panel")
T.check(prompt.pages[1][1]:find("Would you like to"),
"the prompt page is WouldYouLikeToSaveText")
-- save.asm:187 hlcoord 0, 7
-- save.asm:188 hlcoord 0, 7
T.eq(prompt.choiceBox.tx, 0, "the save Yes/No box sits at column 0 (left)")
T.eq(prompt.choiceBox.ty, 7, "the save Yes/No box sits at row 7")
T.eq(prompt.choiceBox, require("src.ui.Theme").saveBox,
"the geometry routes through Theme so field.theme can restyle it")
local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox })
T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement")
+21 -4
View File
@@ -80,10 +80,11 @@ do
battle:performMove(battle.player, battle.enemy, slot)
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
"a locked-in Thrash queues no setup animation")
-- .ThrashingAboutCheck falls into PlayerCalcMoveDamage, the same
-- animation pipeline every other move uses (core.asm:3540) (#1577)
T.check(indexOf(animRows(battle), "FIX_THRASH") ~= nil,
"but the move's own animation still plays on a continuation turn")
-- .ThrashingAboutCheck (core.asm:3534-3535, enemy mirror :5909-5910) (#1577)
T.check(indexOf(animRows(battle), "THRASH") ~= nil,
"a continuation turn animates THRASH, not the locked move's own id")
T.check(indexOf(animRows(battle), "FIX_THRASH") == nil,
"so the locked move's own animation does not play")
T.check(hasText(battle, "thrashing about"),
"ThrashingAboutText prints in place of the used-move line")
T.check(not hasText(battle, "used FIX THRASH"),
@@ -128,4 +129,20 @@ do
"and the user is locked into Thrash next turn")
end
-- ---------------------------------------------------------------------
-- JumpMoveEffect (core.asm:3129-3133) before MoveHitTest INVULNERABLE (:3150) (#1565)
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.enemy.invulnerable = true
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") ~= nil,
"the setup animation plays against a mid-Fly/Dig target")
T.eq(battle.player.thrashTurns, 2,
"the 2-3 roll commits against a mid-Fly/Dig target")
T.check(battle:menuLockedAction(battle.player) ~= nil,
"and the user is locked into Thrash next turn")
T.check(hasText(battle, "attack missed"), "while the attack itself misses")
end
T.finish("thrash setup animation (#1532)")
+5
View File
@@ -36,5 +36,10 @@ T.eq(boostedText(function(mon, save)
mon.traded = true
mon.otId = save.player.id
end), false, "and a mon traded back to its original trainer loses it (#1488)")
T.eq(boostedText(function(mon)
-- repairTradedOtIds leaves traded mons with otId nil (#1265, #1461)
mon.traded = true
mon.otId = nil
end), true, "a traded mon with no stored OTID keeps the boost (#1488)")
T.finish("traded exp boost is an OTID comparison (#1488)")
+5 -3
View File
@@ -149,9 +149,11 @@ do
local events = battle:takeTurn({ kind = "move", move = "ROAR" })
check(saidSomethingLike(events, "fled in fear!"),
"FledInFearText: the wild mon is blown away")
check(not saidSomethingLike(events, "used TACKLE!"),
"and the mon that left never takes its half of the turn")
eq(player.hp, hpBefore, "so nothing came back the other way")
-- EFFECT_FORCE_SWITCH is priority 0, below BASE_PRIORITY
-- (data/moves/effects_priorities.asm:5): Roar goes last (#1475)
check(saidSomethingLike(events, "used TACKLE!"),
"so the wild mon takes its half of the turn first, Speed regardless")
check(player.hp < hpBefore, "and its hit landed before the blow-away")
eq(battle.over, true, "the battle is over")
eq(battle.outcome, "fled", "as the cart's DRAW")
end
+42 -3
View File
@@ -1307,7 +1307,7 @@ do
end
-- ---- GetMovePriority's Vital Throw carve-out (#1475) ----------------------
-- engine/battle/core.asm:786
-- engine/battle/core.asm:787-789
do
local screen = newScreen()
check(runToMenu(screen), "reached the menu")
@@ -1323,11 +1323,20 @@ do
eq(battle:movePriority("SWIFT"), 0,
"while SWIFT, which shares its effect, keeps BASE_PRIORITY")
eq(battle:movePriority("QUICK_ATTACK"), 1, "and the table still reads")
moves.ROAR_FIX = { id = "ROAR_FIX", name = "ROAR", power = 0,
type = "NORMAL", accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" }
-- MoveEffectPriorities: EFFECT_FORCE_SWITCH is 0, below BASE_PRIORITY
-- (data/moves/effects_priorities.asm:5)
eq(battle:movePriority("ROAR_FIX"), -1,
"Whirlwind and Roar sit below BASE_PRIORITY")
eq(battle:orderOf("VITAL_THROW", "ROAR_FIX"), "player",
"VITAL_THROW ties a force-switch move (0 vs 0), so Speed decides")
eq(battle:orderOf("TACKLE", "TACKLE"), "player",
"the faster mon leads on equal priority")
eq(battle:orderOf("VITAL_THROW", "TACKLE"), "enemy",
"but VITAL_THROW loses to a normal move whatever the Speed")
moves.VITAL_THROW, moves.SWIFT, moves.QUICK_ATTACK = nil, nil, nil
moves.ROAR_FIX = nil
end
-- ---- a send-out snapshots HP at send time (#1514) -------------------------
@@ -1352,6 +1361,29 @@ do
"and the HUD opens on the snapshot, not on the post-hit value")
end
-- ---- the send-out snapshots level and exp the same way (#1514) ------------
-- SendOutPlayerMon reloads wBattleMon* from the party slot (core.asm:3796-3838)
do
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
local bench = Mon.new(DATA, "TOTODILE", 10, { dvs = perfect })
local screen, battle = newScreen({ player = lead, party = { lead, bench } })
check(runToMenu(screen), "reached the menu")
battle:takeEvents()
check(battle:switch(2), "the bench mon comes in")
local send = battle:takeEvents()[1]
eq(send.level, bench.level, "the send carries a level snapshot")
eq(send.experience, bench.experience, "and an experience snapshot")
-- awardExperience mutates the live table before the UI dequeues the send
bench.level = bench.level + 3
bench.experience = (bench.experience or 0) + 5000
screen:push(send)
screen:advanceQueue()
eq(screen.shownLevel, send.level,
"the HUD opens on the send-time level, not the post-award one")
eq(screen.shownExp, screen:expPixels(bench, send.level, send.experience),
"and the exp bar fills from the send-time experience")
end
-- ---- LearnMove finishes before the queued send-out (#1516) ----------------
-- LearnMove inside GiveExperiencePoints (engine/battle/core.asm:1959-2010)
do
@@ -1404,8 +1436,15 @@ do
local drawn = table.concat(boxes, " ")
check(drawn:find("0,8,11,5", 1, true) ~= nil, "the TYPE/PP box is drawn")
check(drawn:find("4,12,16,6", 1, true) ~= nil, "over the narrow list box")
check(drawn:find("0,12,20,6", 1, true) == nil,
"and the full-width message box is not")
-- SafeLoadTempTilemapToTilemap keeps the full battle textbox under the
-- move list (core.asm:4689); Textbox then MoveInfoBox over it (:5084, :5157).
check(drawn:find("0,12,20,6", 1, true) ~= nil,
"over the restored full-width message box")
local base = drawn:find("0,12,20,6", 1, true)
local list = drawn:find("4,12,16,6", 1, true)
local info = drawn:find("0,8,11,5", 1, true)
check(base < list and list < info,
"painted base box, then list box, then info box")
local text = table.concat(prints, " ")
check(text:find("TACKLE@6,13", 1, true) ~= nil, "names sit at column 6")
check(text:find("cursor@5,13", 1, true) ~= nil, "with the cursor at 5")
+21
View File
@@ -137,6 +137,27 @@ do
end
eq(checktime(4), 1, "checktime NITE is TRUE inside the PALETTE_DAY room")
eq(checktime(2), 0, "and checktime DAY is FALSE there")
-- the two out-of-World consumers read wTimeOfDay too (#1557)
local Pokegear = require("src.ui.gen2.Pokegear")
eq(Pokegear.timeOfDayIndex({ game = { world = world } }), 2,
"the radio's program pick answers NITE, not the pin (pokegear.asm:1456)")
local Specials = require("src.script.gen2.Specials")
local buffer
local pvm = { curPhoneCaller = 1,
setStringBuffer = function(_, name) buffer = name end,
specials = { world = {
tod = world.tod, daytime = world.daytime,
encounters = { grass = { PLAYERS_HOUSE_1F = { slots = {
NITE = { { species = "NITEMON" }, { species = "NITEMON" },
{ species = "NITEMON" }, { species = "NITEMON" } },
DAY = { { species = "DAYMON" }, { species = "DAYMON" },
{ species = "DAYMON" }, { species = "DAYMON" } },
} } } },
} } }
Specials.ALL.RandomPhoneWildMon(pvm)
eq(buffer, "NITEMON",
"RandomPhoneWildMon reads the NITE column (wildmons.asm:861)")
end
-- ---- the hour-window respawn is not eaten by a busy frame -------------------
+10 -3
View File
@@ -116,9 +116,9 @@ do
end
-- Every other givepoke in the game is the flag-FALSE form: no names, and the
-- nickname prompt still runs.
-- nickname prompt still runs (engine/pokemon/move_mon.asm:1753-1757).
do
local given = nil
local given, asked = nil, false
local plainVm = Vm.new({ generation = 2,
["s:eevee"] = {
{ op = "givepoke", species = 133, level = 20, item = 0, trainer = 0 },
@@ -127,13 +127,20 @@ do
}, {}, Events.new(), {
givePoke = function(species, level, item, opts)
given = { opts = opts }
return nil
return { species = "EEVEE" }
end,
showText = function(_, onDone) onDone() end,
-- GiveANickname_YesNo (move_mon.asm:1753-1757): the prompt's yes/no
yesorno = function(onChoose)
asked = true
onChoose(false)
end,
})
plainVm:start("s:eevee")
for _ = 1, 10 do plainVm:update() end
check(given ~= nil and given.opts == nil,
"the flag-FALSE form hands givePoke no names")
check(asked, "and the nickname prompt still runs on it")
end
-- Phone + verbosegiveitem (Elm directions / aide potion)
+11 -4
View File
@@ -1709,14 +1709,21 @@ check(hw:mapSceneOf(3, 4) == nil,
"a map with NO scene_var row answers nil, which the VM turns into $ff")
check(hw:mapSceneOf(9, 9) == nil, "and an unresolvable pair is nil too")
-- hw.tod is the production read (the unpinned wTimeOfDay split, #1557);
-- hw.daytime is the palette pin it must NOT follow
hw.tod = "DAY"
eq(hw:timeOfDayId(), 1, "DAY is wTimeOfDay 1")
hw.daytime = "MORN"
hw.tod = "MORN"
eq(hw:timeOfDayId(), 0, "MORN is 0")
hw.daytime = "NITE"
hw.tod = "NITE"
eq(hw:timeOfDayId(), 2, "NITE is 2")
hw.tod = "NITE"
hw.daytime = "DARK"
eq(hw:timeOfDayId(), 3, "DARKNESS is 3")
hw.daytime = "DAY"
eq(hw:timeOfDayId(), 2, "a PALETTE_DARK pin does not leak into wTimeOfDay")
hw.tod = nil
hw.daytime = "DARK"
eq(hw:timeOfDayId(), 3, "DARKNESS is 3 only on the tod-less fallback arm")
hw.tod, hw.daytime = nil, "DAY"
eq(hw:gsVersion(), 0, "checkver: a Gold save is 0")
eq(hookWorld({ version = "silver" }):gsVersion(), 1, "and a Silver save is 1")
+9 -6
View File
@@ -978,14 +978,17 @@ do
game.stack:pop()
battle.onFinish("win")
for _ = 1, 12 do
-- the "is evolving!" box types out and holds DelayFrames 50 before the
-- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it
game.input.wasPressed = function() return false end
local evoTop
for _ = 1, 900 do
local t = game.stack:top()
if not t or t.screenId == "EvolutionState" then break end
game.stack:pop()
if t.onDone then t.onDone() end
if not t then break end
if t.screenId == "EvolutionState" then evoTop = t break end
if t.update then t:update(1 / 60) else break end
end
check(game.stack:top() and game.stack:top().screenId == "EvolutionState",
"the win reaches the evolution screen")
check(evoTop ~= nil, "the win reaches the evolution screen")
end
do