-- The Gen 2 battle screen against the cart's own HUD and pacing rules: -- minimum damage out of BattleCommand_DamageCalc's tail, the -6/+6 stat stage -- wall, the HP bar chase (engine/battle/anim_hp_bar.asm), the status tag in -- the level's spot (PlaceNonFaintStatus), BattleMenu's empty textbox, the -- intro slide's one-piece back pic, and ItemRestoreHP's pick-a-mon flow. -- -- GOLD_CACHE="..." luajit tests/gen2_battle_ui_test.lua -- -- ROM-free: fixtures below are the extractor's shapes; the one cache-fed -- section (the GROWL animation's cry) skips cleanly when there is no cache. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 battle ui") local check, eq = S.check, S.eq love = require("tests.love_stub") local Battle = require("src.battle.gen2.Battle") local BattleAnimView = require("src.ui.gen2.BattleAnimView") local BattleState = require("src.ui.gen2.BattleState") local Damage = require("src.battle.gen2.Damage") local Input = require("src.core.Input") local Mon = require("src.battle.gen2.Mon") local PackMenu = require("src.ui.gen2.PackMenu") local PartyMenu = require("src.ui.gen2.PartyMenu") local Sound = require("src.core.Sound") local Typer = require("src.ui.gen2.Typer") -- ---------------------------------------------------------------- fixtures local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" }, FLYING = { id = "FLYING", index = 2, category = "physical" }, GROUND = { id = "GROUND", index = 4, category = "physical" }, ROCK = { id = "ROCK", index = 5, category = "physical" }, FIRE = { id = "FIRE", index = 20, category = "special" }, WATER = { id = "WATER", index = 21, category = "special" }, ELECTRIC = { id = "ELECTRIC", index = 23, category = "special" }, } local MATCHUPS = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, { attacker = "FIRE", defender = "WATER", multiplier = 5 }, { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, } local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDERWAVE", power = 0, type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" }, } local GROWTH = { GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, linear = 100, constant = 140 }, } local POKEMON = { growthRates = GROWTH, CYNDAQUIL = { id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, specialAttack = 60, specialDefense = 50 }, types = { "FIRE", "FIRE" }, catchRate = 45, baseExp = 65, growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, TOTODILE = { id = "TOTODILE", index = 158, name = "TOTODILE", baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, specialAttack = 44, specialDefense = 48 }, types = { "WATER", "WATER" }, catchRate = 45, baseExp = 66, growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, PIDGEY = { id = "PIDGEY", index = 16, name = "PIDGEY", baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, specialAttack = 35, specialDefense = 35 }, types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, GEODUDE = { id = "GEODUDE", index = 74, name = "GEODUDE", baseStats = { hp = 40, attack = 80, defense = 100, speed = 20, specialAttack = 30, specialDefense = 30 }, types = { "ROCK", "GROUND" }, catchRate = 255, baseExp = 73, growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, } local DATA = { pokemon = POKEMON, moves = MOVES, type_chart = { types = TYPES, matchups = MATCHUPS }, items = { -- `index` is the ROM item id, which is what wBattleAnimParam carries. MASTER_BALL = { id = "MASTER_BALL", pocket = "BALL", name = "MASTER BALL", index = 1 }, POKE_BALL = { id = "POKE_BALL", pocket = "BALL", name = "POKe BALL", index = 5 }, FAST_BALL = { id = "FAST_BALL", pocket = "BALL", name = "FAST BALL", index = 160 }, POTION = { id = "POTION", pocket = "ITEM", name = "POTION" }, }, } local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } perfect.hp = Mon.hpDV(perfect) -- Deterministic and range-respecting: the smallest roll that is neither a -- critical hit (rollCritical wants 0) nor a miss. local function detRandom(n) if (n or 1) <= 1 then return 0 end return 1 end -- ---- DamageCalc's minimum (engine/battle/effect_commands.asm) ------------- do -- Stats weak enough that the raw formula floors to 0: the cart still walks -- out of DamageCalc with MIN_DAMAGE (2). local weak = Damage.calc({ level = 5, power = 35, moveType = "NORMAL", attacker = { attack = 6, types = { "FIRE" } }, defender = { defense = 40, types = { "WATER" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) eq(weak, Damage.MIN_DAMAGE, "a hit whose stat math floors to nothing still deals MIN_DAMAGE") -- The same hit into a resist: BattleCommand_Stab's zero-quotient arm forces -- the row's floor back up to 1, so not-very-effective never reads 0. local resisted = Damage.calc({ level = 5, power = 35, moveType = "NORMAL", attacker = { attack = 6, types = { "FIRE" } }, defender = { defense = 40, types = { "ROCK" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) check(resisted >= 1, "a resisted hit that lands deals at least 1") local _, info = Damage.calc({ level = 5, power = 35, moveType = "NORMAL", attacker = { attack = 6, types = { "FIRE" } }, defender = { defense = 40, types = { "ROCK" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) eq(info.effectiveness, 5, "and still reports not-very-effective") -- Immunity is the one zero left: the row's multiplier IS 0. local immune = Damage.calc({ level = 50, power = 95, moveType = "ELECTRIC", attacker = { specialAttack = 100, types = { "ELECTRIC" } }, defender = { specialDefense = 50, types = { "GROUND" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) eq(immune, 0, "an immune matchup still deals nothing at all") end -- ---- the same minimum through the real turn engine ------------------------ do local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local wild = Mon.new(DATA, "GEODUDE", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local battle = Battle.new({ data = DATA, party = { player }, wild = wild, random = detRandom }) local before = wild.hp local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) local dealt, sawNve = nil, false for _, event in ipairs(events) do if event.kind == "damage" and event.side == "enemy" and not dealt then dealt = event.amount end if event.kind == "message" and event.text == "It's not very\neffective…" then sawNve = true end end check(sawNve, "TACKLE into GEODUDE reads as not very effective") check((dealt or 0) >= 1, "and the landed hit dealt at least 1 HP, not 0") eq(wild.hp, before - dealt, "with the HP moving by exactly that much") end -- ---- the -6 stage wall (BattleCommand_StatDown's .CantLower) -------------- do local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) player.moves = { { id = "GROWL", pp = 40, maxPp = 40 } } player.hp = 200 player.maxHp = 200 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local battle = Battle.new({ data = DATA, party = { player }, wild = wild, random = detRandom }) for turn = 1, 6 do battle:takeTurn({ kind = "move", move = "GROWL" }) eq(battle.stages.enemy.attack, -turn, "GROWL " .. turn .. " lands one stage") end local events = battle:takeTurn({ kind = "move", move = "GROWL" }) eq(battle.stages.enemy.attack, -6, "the seventh GROWL moves nothing") local refused = false for _, event in ipairs(events) do if event.kind == "message" and event.text == "PIDGEY's ATTACK won't drop anymore!" then refused = true end end check(refused, "and the cart's refusal line is emitted instead") -- The stage -6 multiplier is 25/100 with a floor of 1: a stat can shrink to -- a quarter but never to zero. eq(Damage.applyStage(11, -6), math.max(1, math.floor(11 * 25 / 100)), "stage -6 is a quarter of the stat") eq(Damage.applyStage(1, -6), 1, "and never zero") eq(Damage.applyStage(11, -9), Damage.applyStage(11, -6), "stages below -6 read as -6") end -- ---- BattleState with a stub game ----------------------------------------- local function newScreen(opts) opts = opts or {} Input:init() local pushed = {} local player = opts.player or Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) if not player.moves or #player.moves == 0 then player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, } end local party = opts.party or { player } local wild = opts.wild or Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = wild.moves or { { id = "TACKLE", pp = 35, maxPp = 35 } } local save = { party = party, inventory = opts.inventory or { POTION = 2, POKE_BALL = 3 } } local game = { data = DATA, save = save, input = Input, options = {}, stack = { push = function(_, screen) pushed[#pushed + 1] = screen end, pop = function() table.remove(pushed) end, top = function() return pushed[#pushed] end, }, } local battle = Battle.new({ data = DATA, party = party, wild = wild, random = detRandom }) local screen = BattleState.new(game, { battle = battle, save = save }) return screen, battle, player, wild, save, pushed end local function run(screen, frames) for _ = 1, frames do Input:step() screen:update(1 / 60) end end -- One frame of a drain. Battle lines end in `prompt` and PromptButton waits -- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits -- the same way (engine/battle/core.asm:7069), so a drain presses rather than -- idling for a timer that never runs out. local function drainStep(screen) local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box" if waiting then Input:overlayPressed("a") end Input:step() screen:update(1 / 60) if waiting then Input:overlayReleased("a") end end local function runToMenu(screen, cap) for _ = 1, (cap or 3000) do drainStep(screen) if screen.phase == "menu" then return true end end return false end -- engine/items/item_effects.asm:1748 local function drainItemResult(picker) for _ = 1, 400 do if not picker.itemResult then return true end Input:overlayPressed("a") Input:step() picker:update(1 / 60) Input:overlayReleased("a") end return not picker.itemResult end do local screen = newScreen({ inventory = { MASTER_BALL = 1, POKE_BALL = 1, } }) eq(screen:catchChance("MASTER_BALL"), 100, "the battle screen exposes a certain Master Ball preview") check(type(screen:catchChance("POKE_BALL")) == "number", "the battle screen exposes the live wild catch preview") end -- ---- BattleMenu empties the textbox --------------------------------------- do local screen = newScreen() eq(screen.shownHp.enemy, screen.battle.enemy.hp, "the bar opens on the real HP") check(runToMenu(screen), "the intro drains to the battle menu") eq(screen.message, nil, "BattleMenu runs EmptyBattleTextbox: no prompt beside the 2x2 menu") end -- ---- the HP bar chase ----------------------------------------------------- do local screen, battle, player, wild = newScreen() check(runToMenu(screen), "reached the menu") local shown0 = screen.shownHp.enemy eq(shown0, wild.hp, "the enemy bar sits on the wild mon's real HP") screen:submit({ kind = "move", move = "TACKLE" }) check(wild.hp < shown0, "the engine has already resolved the whole turn") eq(screen.shownHp.enemy, shown0, "but the drawn bar has not spoiled it: it still shows the pre-turn HP") local sawIntermediate = false for _ = 1, 3000 do Input:step() screen:update(1 / 60) local shown = screen.shownHp.enemy if shown < shown0 and shown > wild.hp then sawIntermediate = true end if screen.phase == "menu" then break end end eq(screen.phase, "menu", "the turn drains back to the menu") check(sawIntermediate, "the bar walked down through the middle values, one pixel per two frames") eq(screen.shownHp.enemy, wild.hp, "and settled on the real enemy HP") eq(screen.shownHp.player, player.hp, "the player's bar caught its own hit too") end -- ---- the drain is sized by the mon whose bar is ON SCREEN ----------------- do -- wCurHPAnimMaxHP is loaded from the battle struct of the mon being drawn -- (wEnemyMonMaxHP -> wHPBuffer1, engine/battle/effect_commands.asm:3399-3404), -- and after a faint that is still the OUTGOING mon: the engine rebinds -- battle.enemy inside takeTurn, a whole queue before the replacement's -- `send` event is dequeued. local screen, battle = newScreen() check(runToMenu(screen), "reached the menu") local outgoing = { maxHp = 240, hp = 240 } local replacement = { maxHp = 24, hp = 24 } screen.shownMon.enemy = outgoing battle.enemy = replacement screen.shownHp.enemy = 240 screen.hpAnim = { side = "enemy", to = 0 } check(screen:stepHpAnim(), "the first tick steps") eq(screen.hpAnim.bar.maxHp, 240, "the chase is sized off the OUTGOING mon's 240 max HP, not the " .. "replacement's 24") eq(screen.shownHp.enemy, 239, "LongAnim_UpdateVariables walks the number one hit point at a time") local held = 1 while screen.hpAnim and held < 400 do if not screen:stepHpAnim() then break end held = held + 1 end eq(screen.shownHp.enemy, 0, "the outgoing mon's bar drained to zero") eq(held, 48 * 2, "48 pixels, two frames apiece (HPBarAnim_BGMapUpdate)") end do -- _AnimateHPBar (engine/battle/anim_hp_bar.asm:1-40): under 48 max HP local function drain(maxHp, from, to) local screen = newScreen() check(runToMenu(screen), "reached the menu") screen.shownMon.enemy = { maxHp = maxHp, hp = from } screen.shownHp.enemy = from screen.hpAnim = { side = "enemy", to = to } local mon = screen.shownMon.enemy local trace = { { hp = from, px = screen:hudHpPixels(mon, "enemy") } } local held = 0 while held < 400 do if not screen:stepHpAnim() then break end held = held + 1 trace[#trace + 1] = { hp = screen.shownHp.enemy, px = screen:hudHpPixels(mon, "enemy") } end local changes, everySecond, onePixel = 0, true, true for i = 2, #trace do local moved = trace[i].px ~= trace[i - 1].px if moved then changes = changes + 1 if math.abs(trace[i].px - trace[i - 1].px) ~= 1 then onePixel = false end end if moved ~= (i % 2 == 0) then everySecond = false end end return screen, held, changes, everySecond, onePixel, trace end local screen, held, changes, everySecond, onePixel, trace = drain(15, 15, 0) eq(held, 96, "15 max HP, 15 -> 0: 48 pixels x 2 frames = 96 held ticks") eq(changes, 48, "the short loop moves every pixel of the bar") check(everySecond, "and the bar changes on every second tick only") check(onePixel, "one pixel at a time") eq(trace[2].px, 47, "first step: 48 -> 47 pixels") eq(trace[2].hp, 15, "CalcPixelFrame at 47px of 15: floor(705/48)+1 = 15") eq(trace[88].hp, 2, "at 4px: floor(60/48)+1 = 2") eq(trace[90].hp, 1, "at 3px: floor(45/48)+1 = 1") eq(trace[95].hp, 1, "the number holds at 1 until the bar is empty") eq(trace[96].hp, 0, "and drops to 0 on the last pixel") eq(screen.shownHp.enemy, 0, "and lands on 0 when it is") eq(screen.hpAnim, nil, "the chase is over") local long long, held, changes, everySecond, onePixel, trace = drain(100, 100, 0) eq(held, 96, "100 max HP, 100 -> 0: 48 pixels x 2 frames = 96 held ticks") eq(changes, 48, "the long loop spends a step per pixel change") check(everySecond, "every second tick") check(onePixel, "one pixel at a time") eq(trace[2].hp, 99, "the first pixel falls at 99 (floor(99*48/100) = 47)") eq(trace[4].hp, 97, "the next at 97 (floor(97*48/100) = 46)") eq(long.shownHp.enemy, 0, "and the number lands on 0") local up up, held, changes = drain(15, 3, 15) eq(up.shownHp.enemy, 15, "a heal climbs the same way") eq(held, (48 - 9) * 2, "9px -> 48px is 39 pixels, two frames apiece") eq(changes, 39, "one change per pixel on the way up too") local live = newScreen() check(runToMenu(live), "reached the menu") live.shownMon.enemy = { maxHp = 15, hp = 15 } live.shownHp.enemy = 15 live.hpAnim = { side = "enemy", to = 0 } local mon = live.shownMon.enemy local seen = {} for i = 1, 6 do Input:step() live:update(1 / 60) seen[i] = live:hudHpPixels(mon, "enemy") end eq(table.concat(seen, ","), "47,47,46,46,45,45", "update() moves the bar one pixel every second tick") end -- ---- the exp bar crawls, and the level waits for it ----------------------- do -- AnimateExpBar (engine/battle/core.asm:7191) runs BEFORE the exp is -- committed and walks the bar one pixel at a time, topping out at 64 and -- restarting at 0 for each level crossed, with wBattleMonLevel advanced only -- as each segment fills (:7267-7274). local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW -- One point short of level 6, so whatever the kill pays levels the mon. player.experience = Mon.experienceForLevel(growth, 6) - 1 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } wild.hp = 1 local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") eq(screen.shownLevel, 5, "the HUD opens on the mon's real level") local startExp = screen.shownExp check(startExp > 0 and startExp < 64, "and on its real place along the bar (CalcExpBar's 64 pixels)") screen:submit({ kind = "move", move = "TACKLE" }) eq(wild.hp, 0, "the wild mon went down on the first TACKLE") eq(player.level, 6, "and the engine has already levelled the player") eq(screen.shownLevel, 5, "but the HUD still prints the pre-kill level, several boxes before the " .. "grew-to-level line") eq(screen.shownExp, startExp, "and the bar has not moved yet either") local crawled, restarted = false, false for _ = 1, 3000 do drainStep(screen) if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then crawled = true end if screen.shownLevel == 6 and (screen.shownExp or 0) == 0 then restarted = true end if screen.phase == "done" then break end end eq(screen.phase, "done", "the win drains out") check(crawled, "the bar walked up through the middle pixels") check(restarted, "topped out, took the level with it and restarted the segment at 0") eq(screen.shownLevel, player.level, "the HUD level caught up") eq(screen.shownExp, screen:expPixels(player, player.level, player.experience), "and the bar landed on the mon's real place in level 6") end do -- sparks have gone (../pokecrystal/engine/battle/core.asm:7529-7541). local EXP_FULL = require("src.ui.gen2.BattleHud").EXP_LENGTH_PX local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW player.experience = Mon.experienceForLevel(growth, 6) - 1 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } wild.hp = 1 local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") screen:submit({ kind = "move", move = "TACKLE" }) -- ../pokecrystal/engine/battle/core.asm:7121-7128, :7516-7545 local trace = {} local startExp for _ = 1, 3000 do drainStep(screen) if screen.expAnim and not startExp then startExp = screen.shownExp end trace[#trace + 1] = { exp = screen.shownExp, level = screen.shownLevel, burst = screen.expBurst and screen.expBurst.frame, message = tostring(screen.message or ""), typing = screen:syncTyper(), timer = screen.messageTimer or 0, phase = screen.phase, } if screen.phase == "done" then break end end eq(screen.phase, "done", "the win drains out") check(startExp ~= nil and startExp < EXP_FULL, "the crawl armed short of 64") local promptAt, crawlAt, armAt, lineAt, zeroAt, boxAt local drawn, full, quiet, prompted = {}, true, true, false for i, t in ipairs(trace) do if not promptAt and t.message:find("gained") and t.timer > 0 then promptAt = i end if not crawlAt and startExp and t.exp > startExp then crawlAt = i end if t.burst then if t.burst == 0 and not armAt then armAt = i end if t.burst >= 1 and t.burst <= 8 then drawn[#drawn + 1] = t.burst end if t.exp ~= EXP_FULL then full = false end if t.message:find("grew to level") then quiet = false end end if t.message:find("grew to level") then if not lineAt then lineAt = i end if t.timer > 0 then prompted = true end if not zeroAt and t.exp == 0 then zeroAt = i end end if not boxAt and t.phase == "stats-box" then boxAt = i end end check(promptAt and crawlAt and promptAt < crawlAt, "the A on \"gained N EXP. Points!\" comes before the bar moves") check(armAt ~= nil, "the burst arms at frame 0 on the tick the bar tops out") if armAt then eq(trace[armAt].exp, EXP_FULL, "with the bar at 64 on that tick") eq(trace[armAt].level, 6, "and the level already ticked over") check(trace[armAt - 1].burst == nil and trace[armAt - 1].exp < EXP_FULL, "one tick after the last pixel") end eq(#drawn, 8, "eight drawn spark frames") for i = 1, 8 do eq(drawn[i], i, "spark frame " .. i .. " in order") end check(full, "with the bar left full under them") check(quiet, "and no grew-to-level line while any spark is up") check(lineAt ~= nil, "the grew-to-level line prints inside the crawl") if lineAt then check(trace[lineAt - 1].burst ~= nil, "on the tick after the last spark frame") eq(trace[lineAt].exp, EXP_FULL, "with the bar still full") local typedAt = lineAt while trace[typedAt] and trace[typedAt].typing do typedAt = typedAt + 1 end for i = lineAt, typedAt + 9 do if trace[i].exp ~= EXP_FULL then full = false end end check(full, "and full through .PlayExpBarSound's ten frames") eq(zeroAt, typedAt + 10, "then zero at the first PlaceExpBar of segment 2") eq(trace[typedAt + 13].exp, 1, "one pixel three frames later") end check(not prompted, "the level line takes no A") check(boxAt and lineAt and boxAt > lineAt, "the stats box follows") if boxAt then eq(trace[boxAt - 1].message, trace[lineAt].message, "with the level line still up when it opens") eq(trace[boxAt].exp, screen:expPixels(player, player.level, player.experience), "after segment 2 has landed on the mon's real place") end local lines = 0 for i = 2, #trace do if trace[i].message:find("grew to level") and not trace[i - 1].message:find("grew to level") then lines = lines + 1 end end eq(lines, 1, "and the level event prints no second copy of the line") end do -- ../pokecrystal/engine/battle/core.asm:7568 local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW player.experience = Mon.experienceForLevel(growth, 6) - 1 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } wild.hp = 1 local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") screen:submit({ kind = "move", move = "TACKLE" }) local requested = {} screen.playSfx = function(_, name) requested[#requested + 1] = name end local busy = false local realBusy = Sound.sfxBusy Sound.sfxBusy = function() return busy end local armed = false for _ = 1, 3000 do drainStep(screen) if screen.expAnim then armed = true break end end check(armed, "the crawl armed") local startExp = screen.shownExp busy = true for _ = 1, 40 do drainStep(screen) end local asked = false for _, name in ipairs(requested) do if name == "Sfx_ExpBar" then asked = true end end check(not asked, "SFX_EXP_BAR is not requested while the channels are busy") eq(screen.shownExp, startExp, "and the bar has not moved") busy = false for _ = 1, 3 do drainStep(screen) end asked = false for _, name in ipairs(requested) do if name == "Sfx_ExpBar" then asked = true end end check(asked, "and is requested once they are free") Sound.sfxBusy = realBusy end do -- ../pokecrystal/engine/sprite_anims/core.asm:572 local Chrome = require("src.ui.gen2.Chrome") local screen = newScreen() check(runToMenu(screen), "reached the menu") screen.phase = "resolving" screen.message = "CYNDAQUIL gained 54 EXP. Points!" screen.expBurst = { frame = 8 } local order = {} local realBox = Chrome.box Chrome.box = function(x, y, w, h) order[#order + 1] = ("box %d,%d,%d,%d"):format(x, y, w, h) end local realEnd = screen.hud.drawExpBarEnd screen.hud.drawExpBarEnd = function(_, x, y) order[#order + 1] = ("spark %d,%d"):format(x, y) return true end local ok, err = pcall(function() screen:drawSceneBody() end) Chrome.box = realBox screen.hud.drawExpBarEnd = realEnd check(ok, "the scene draws with the burst up: " .. tostring(err)) local lastBox, firstSpark, sparks = 0, nil, 0 for i, entry in ipairs(order) do if entry:find("^box") then lastBox = i end if entry:find("^spark") then sparks = sparks + 1 firstSpark = firstSpark or i end end eq(sparks, 8, "eight sparks drawn") check(lastBox > 0, "the message box was drawn") check(firstSpark and firstSpark > lastBox, "every spark comes after the last box of the frame") end do -- OAM bias (../pokecrystal/engine/sprite_anims/core.asm:558-608). local screen = newScreen() local drawn = {} screen.hud = { drawExpBarEnd = function(_, x, y) drawn[#drawn + 1] = ("%d,%d"):format(x, y) end } screen.expBurst = { frame = 1 } screen:drawExpBurst() eq(#drawn, 8, "eight objects a frame") local collapsed = true for _, at in ipairs(drawn) do if at ~= "76,88" then collapsed = false end end check(collapsed, "all on the bar's left end while the radius is still 0") drawn = {} screen.expBurst = { frame = 8 } screen:drawExpBurst() local seen = {} for _, at in ipairs(drawn) do seen[at] = (seen[at] or 0) + 1 end local ring = { "90,88", "85,97", "76,102", "67,97", "62,88", "67,79", "76,74", "85,79" } for _, at in ipairs(ring) do eq(seen[at], 1, "a spark at " .. at .. " on the last frame") end drawn = {} screen.expBurst = { frame = 9 } screen:drawExpBurst() eq(#drawn, 0, "and ClearSprites past the loop leaves nothing behind") end -- ---- a level-up REDRAWS the HP bar, it does not animate it ---------------- do -- GiveExperiencePoints' `.skip_active_mon_update` guard -- (engine/battle/core.asm:6999-7003) copies the recalculated HP and max HP -- into the battle struct for the mon that is OUT and calls UpdatePlayerHUD -- (:7034), which DRAWS the bar rather than chasing it. local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW player.experience = Mon.experienceForLevel(growth, 6) - 1 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } wild.hp = 1 local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") local maxBefore = player.maxHp screen:submit({ kind = "move", move = "TACKLE" }) for _ = 1, 3000 do drainStep(screen) if screen.phase == "done" then break end end check(player.maxHp > maxBefore, "the level-up raised the maximum") eq(screen.shownHp.player, player.hp, "and the bar carries the HP the level-up added, without waiting for the " .. "next damage or heal event") end -- ---- the status tag takes the level's spot -------------------------------- do local screen, battle, _, wild = newScreen() check(runToMenu(screen), "reached the menu") screen:submit({ kind = "move", move = "THUNDER_WAVE" }) eq(wild.status, "paralyze", "THUNDER WAVE paralyzed the wild mon") eq(screen:statusTag(wild), "PAR", "so the enemy HUD wears PAR where the level was") eq(screen:statusTag({ status = "poison" }), "PSN", "PSN for poison") eq(screen:statusTag({ status = "toxic" }), "PSN", "toxic wears the same PSN bit") eq(screen:statusTag({ status = "burn" }), "BRN", "BRN for burn") eq(screen:statusTag({ status = "freeze" }), "FRZ", "FRZ for freeze") eq(screen:statusTag({ status = "sleep" }), "SLP", "SLP for sleep") eq(screen:statusTag({ status = "confuse" }), nil, "confusion is a substatus on the cart and shows no tag") eq(screen:statusTag({}), nil, "no status, no tag: the level prints") end -- ---- the intro slide's one-piece back pic --------------------------------- do -- CopyBackpic's OAM copy crosses 144px in the 72 frames, 2 a frame. eq(BattleAnimView.slideBackpicOffset(0), 144, "the back pic starts just off the right edge") eq(BattleAnimView.slideBackpicOffset(36), 72, "halfway at frame 36") eq(BattleAnimView.slideBackpicOffset(72), 0, "and lands on its column") local top, middle = BattleAnimView.slideOffsets(72) eq(top, 0, "the enemy band finishes in place") eq(middle, 0, "so does the player band") -- While the bands are baked, the player-side pic is withheld from them and -- drawn only by the overlay, so it cannot tear at the $40 boundary. local screen = newScreen() screen.slidingBackpic = true local ok = pcall(screen.drawPic, screen, screen.battle.player, true) check(ok, "drawPic returns cleanly while the overlay owns the back pic") end -- ---- ItemRestoreHP: pick a mon, heal that mon ----------------------------- do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } bench.hp = bench.hp - 15 local screen, battle, _, _, save, pushed = newScreen({ player = lead, party = { lead, bench } }) check(runToMenu(screen), "reached the menu") local turn0 = battle.turn screen:useItem("POTION") eq(screen.phase, "submenu", "a healing item opens a submenu, not a heal") local picker = pushed[#pushed] eq(getmetatable(picker), PartyMenu, "and the submenu is the party screen (UseItem_SelectMon)") eq(picker.prompt, PartyMenu.PROMPTS.useItem, "asking the cart's own question") local benchBefore = bench.hp picker.onChoose(2, bench) eq(picker.itemResult and picker.itemResult.shown, benchBefore, "the picked row's bar starts at the pre-heal HP (wWhichHPBar = $2)") check(drainItemResult(picker), "and the button drops the list") eq(bench.hp, math.min(bench.maxHp or bench.stats.hp, benchBefore + 20), "the POTION landed on the BENCHED mon") eq(save.inventory.POTION, 1, "and one POTION left the bag") eq(battle.turn, turn0 + 1, "a heal that lands spends the turn") check(runToMenu(screen), "the enemy's answer drains out") -- A full-HP target refuses without costing the item or the turn -- (IsMonAtFullHealth's arm of ItemRestoreHP). local turn1 = battle.turn screen:useItem("POTION") pushed[#pushed].onChoose(2, bench) eq(screen.message, "It won't have any\neffect.", "a full-HP target answers the cart's refusal") eq(save.inventory.POTION, 1, "with the POTION still in the bag") eq(battle.turn, turn1, "and the turn not spent") check(runToMenu(screen), "back to the menu") -- An egg is refused before the health check (UseItem_SelectMon .not_egg). screen:useItem("POTION") pushed[#pushed].onChoose(3, { isEgg = true, hp = 1, maxHp = 12 }) eq(screen.message, "That can't be used\non an EGG.", "an EGG answers CantUseOnEggMessage") eq(save.inventory.POTION, 1, "still nothing spent") check(runToMenu(screen), "back to the menu") -- Backing out of the picker returns to the pack with nothing spent. screen:useItem("POTION") pushed[#pushed].onCancel() eq(getmetatable(pushed[#pushed]), PackMenu, "cancelling the picker reopens the PACK (.SelectMon's carry path)") eq(save.inventory.POTION, 1, "with the POTION untouched") end -- ---- BattleMenu_PKMN opens BattleMonMenu over the list -------------------- do -- `callfar BattleMonMenu`, then SWITCH -> TryPlayerSwitch, CANCEL -> -- .Cancel -> BattleMenu (engine/battle/core.asm:4810-4816); the voluntary -- list is PARTYMENUACTION_CHOOSE_POKEMON and the forced one -- PARTYMENUACTION_SWITCH (:4795, :2702). local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen, battle, _, _, _, pushed = newScreen({ player = lead, party = { lead, bench } }) check(runToMenu(screen), "reached the menu") check(screen:openParty(), "the voluntary list opens") local list = pushed[#pushed] eq(list.prompt, PartyMenu.PROMPTS.choose, "asking ChooseAMonString") check(list.wantsBattleSubmenu, "and it carries BattleMonMenu") list.index = 2 list:openSubmenu() eq(#list.submenu.items, 3, "BattleMonMenu has three rows") eq(list.submenu.items[1].id, "SWITCH", "SWITCH first") eq(list.submenu.items[2].id, "STATS", "then STATS") eq(list.submenu.items[3].id, "CANCEL", "then CANCEL") -- CANCEL leaves the list entirely and lands back on the battle menu. list.submenu.index = 3 list:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) eq(screen.phase, "menu", ".Cancel returns to BattleMenu") -- SWITCH takes the pick through to the engine. check(screen:openParty(), "the list reopens") local list2 = pushed[#pushed] list2.index = 2 list2:openSubmenu() list2:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) check(runToMenu(screen), "the switch drains back to the menu") eq(battle.player, bench, "SWITCH reached TryPlayerSwitch") -- BattleText_MonIsAlreadyOut, and the turn is NOT spent (:4863-4870). local turn0 = battle.turn check(screen:openParty(), "the list opens again") local list3 = pushed[#pushed] list3.index = 2 list3:openSubmenu() list3:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) eq(screen.phase, "submenu", "the mon already out is refused in the list") eq(pushed[#pushed], list3, "with the party list still up") eq(list3.submenu, nil, "and the SWITCH/STATS/CANCEL box gone") local refused = (list3.itemResult or {}).text or "" check(refused:find("already"), "with BattleText_MonIsAlreadyOut") -- SpeechTextbox's 18-tile interior (home/text.asm:124) for line in refused:gmatch("[^\n]+") do check(#line <= 18, "the refusal wraps inside the textbox") end eq(battle.turn, turn0, "and no turn is spent on it") list3:updateItemResult({ wasPressed = function(_, b) return b == "a" end }) eq(list3.itemResult, nil, "A dismisses it back to a live list") eq(pushed[#pushed], list3, "which is still the top of the stack") list3.onCancel() -- The forced list is PickPartyMonInBattle: WhichPKMNString, no submenu. check(screen:openParty(true), "the forced list opens") local forced = pushed[#pushed] eq(forced.prompt, PartyMenu.PROMPTS.which, "asking WhichPKMNString") eq(forced.wantsBattleSubmenu, false, "with no BattleMonMenu over it") end -- ---- SHIFT offers a free swap when the trainer sends its next mon --------- do -- EnemySwitch's shift arm: OfferSwitch, then the enemy's send-out, then -- `jp PlayerSwitch` with both participant bitfields zeroed -- (engine/battle/core.asm:2941-2963); CheckWhetherToAskSwitch reads the -- BATTLE_SHIFT bit CLEAR as SHIFT (:3269-3295). local function shiftScreen(style) Input:init() local pushed = {} local lead = Mon.new(DATA, "CYNDAQUIL", 30, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local bench = Mon.new(DATA, "TOTODILE", 30, { dvs = perfect }) bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local foe1 = Mon.new(DATA, "PIDGEY", 3, { dvs = perfect }) foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local foe2 = Mon.new(DATA, "GEODUDE", 3, { dvs = perfect }) foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local party = { lead, bench } local save = { party = party, inventory = {} } local game = { data = DATA, save = save, input = Input, options = { battleStyle = style }, stack = { push = function(_, screen) pushed[#pushed + 1] = screen end, pop = function() table.remove(pushed) end, top = function() return pushed[#pushed] end, }, } local battle = Battle.new({ data = DATA, party = party, random = detRandom, trainer = { class = "FALKNER", name = "FALKNER", party = { foe1, foe2 } } }) local screen = BattleState.new(game, { battle = battle, save = save }) return screen, battle, party, pushed end local function runToPhase(screen, phase, cap) for _ = 1, (cap or 4000) do drainStep(screen) if screen.phase == phase then return true end end return false end local function settleText(screen, cap) for _ = 1, (cap or 400) do if not screen:syncTyper() then return true end Input:step() screen:update(1 / 60) end return false end -- SET never asks (`bit BATTLE_SHIFT, a / jr nz, .return_nc`, :3280-3282). local setScreen, setBattle = shiftScreen("SET") check(runToMenu(setScreen), "the SET battle reaches its menu") setBattle.enemy.hp = 1 setScreen:submit({ kind = "move", move = "TACKLE" }) check(runToMenu(setScreen), "the KO drains without stopping") eq(setScreen.phase, "menu", "SET is never offered a shift") -- SHIFT stops on the prompt before the enemy's send-out line. local noScreen, noBattle, noParty = shiftScreen("SHIFT") check(runToMenu(noScreen), "the SHIFT battle reaches its menu") noBattle.enemy.hp = 1 local noTurn = noBattle.turn noScreen:submit({ kind = "move", move = "TACKLE" }) -- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the -- offer (data/text/battle.asm:222-231): the incoming mon is named on an -- earlier page, and only the last one carries the yes/no box (#1158). check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch") check((noScreen.message or ""):find("is about to use"), "with BattleText_EnemyIsAboutToUseWillPlayerChangeMon") check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question") check((noScreen.message or ""):find("change POK"), "whose last page is the one YesNoBox opens over") -- NO falls through to the send-out with nothing switched and nothing spent. check(settleText(noScreen), "the question finishes printing") noScreen.messageTimer = 0 noScreen.shiftIndex = 2 Input:overlayPressed("a") Input:step() noScreen:update(1 / 60) Input:overlayReleased("a") check(runToMenu(noScreen), "NO drains back to the menu") eq(noBattle.player, noParty[1], "the lead is still out") eq(noBattle.turn, noTurn + 1, "and only the attacking turn was spent") -- YES picks a mon, and the switch lands AFTER the enemy's send-out. local yesScreen, yesBattle, yesParty, pushed = shiftScreen("SHIFT") check(runToMenu(yesScreen), "the second SHIFT battle reaches its menu") yesBattle.enemy.hp = 1 yesScreen:submit({ kind = "move", move = "TACKLE" }) check(runToPhase(yesScreen, "ask-shift"), "it stops on OfferSwitch too") check(settleText(yesScreen), "and its question finishes printing") yesScreen.messageTimer = 0 yesScreen.shiftIndex = 1 Input:overlayPressed("a") Input:step() yesScreen:update(1 / 60) Input:overlayReleased("a") eq(yesScreen.phase, "submenu", "YES opens PickSwitchMonInBattle") local picker = pushed[#pushed] eq(picker.prompt, PartyMenu.PROMPTS.which, "asking WhichPKMNString") eq(picker.wantsBattleSubmenu, false, "with no BattleMonMenu over it") picker.onChoose(2, yesParty[2]) check(runToMenu(yesScreen), "the shift drains back to the menu") eq(yesBattle.player, yesParty[2], "the picked mon is out") eq(yesBattle.participants[1], nil, "and both participant bitfields were zeroed first") eq(yesBattle.participants[2], true, "leaving only the incoming slot") end -- ---- healing the active mon refills its bar on screen --------------------- do local screen, battle, player = newScreen() check(runToMenu(screen), "reached the menu") -- Take a hit so there is something to refill. screen:submit({ kind = "move", move = "TACKLE" }) check(runToMenu(screen), "first exchange drains") check(player.hp < (player.maxHp or player.stats.hp), "the player took damage") eq(screen.shownHp.player, player.hp, "and the bar followed it down") local screenSave = screen.save screenSave.inventory.POTION = 1 screen:useItem("POTION") local picker = screen.game.stack.top() local hurt = player.hp picker.onChoose(1, player) -- engine/items/item_effects.asm:1671 check(picker.itemResult ~= nil and picker.itemResult.shown == hurt and picker.itemResult.target == player.hp, "healing the ACTIVE mon climbs the party row's bar") eq(screen.hpAnim, nil, "with no HUD chase armed behind the list") check(drainItemResult(picker), "the button drops the list") check(runToMenu(screen), "the heal turn drains") eq(screen.shownHp.player, player.hp, "and the HUD came back at the new HP") end -- ---- GROWL's sound comes from its own anim script (cache-fed) ------------- do local cache = os.getenv("GOLD_CACHE") if not cache then local home = os.getenv("HOME") or "" cache = home .. "/Library/Application Support/LOVE/gold-dev/gold" end local path = cache .. "/data/generated/battle_anims.lua" local file = io.open(path, "r") if not file then check(true, "no Gold cache at " .. path .. " (SKIP)") else file:close() local anims = assert(loadfile(path))() if not (anims.scripts and anims.moves and anims.moves.GROWL) then check(true, "battle_anims.lua has no GROWL script (SKIP)") else local AnimRunner = require("src.battle.gen2.AnimRunner") local constants = assert(loadfile(cache .. "/data/generated/constants.lua"))() local cried, sounds = nil, 0 local runner = AnimRunner.new({ data = anims, constants = constants, battleTurn = 0, animId = "GROWL", hooks = { cry = function(side, pitch, length) cried = side end, sound = function() sounds = sounds + 1 end, }, }) runner:start(anims.moves.GROWL) local frames = 0 while runner:step() and frames < 600 do frames = frames + 1 end -- BattleAnim_Growl (data/moves/animations.asm): `anim_cry $0` plays the -- USER's cry, which is the move's whole sound. eq(cried, "player", "GROWL's script asks for the user's own cry") check(frames > 0 and frames < 600, "and the script terminates") -- The chain the SCREEN's cry hook walks, end to end: the same GROWL -- script through BattleState:animForMove must reach Sound.playCry with -- the user's SPECIES, because audio.cries is keyed by species (the -- def.cry field the hook once read does not exist in a Gen 2 cache). local audio = assert(loadfile(cache .. "/data/generated/audio.lua"))() check(audio.cries and audio.cries.CYNDAQUIL ~= nil, "audio.lua keys the cries by species") local screen = newScreen() screen.game.data.gen2BattleAnims = anims screen.game.data.gen2Constants = constants screen.game.data.audio = audio screen.anims = anims screen.animConstants = constants check(runToMenu(screen), "reached the menu with the anim runtime live") local Sound = require("src.core.Sound") local realPlayCry = Sound.playCry local played = nil Sound.playCry = function(_, species) played = species end check(screen:animForMove("GROWL", "player"), "the extracted GROWL animation starts") for _ = 1, 600 do if not screen.anim then break end Input:step() screen:update(1 / 60) end Sound.playCry = realPlayCry eq(screen.anim, nil, "the animation ran out") eq(played, "CYNDAQUIL", "and GROWL played the user's cry through Sound.playCry") end end end -- ---- the cursor bytes that live across menu openings ---------------------- -- -- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1 -- (engine/battle/core.asm:5111) and the A-press writes the picked row back, so -- the FIGHT list reopens on the move used last turn. SendOutPlayerMon -- (core.asm:3809) is what zeroes it, together with wBattleMenuCursorPosition. do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } } local screen = newScreen({ player = lead }) check(runToMenu(screen), "reached the menu") local function tap(button) Input:overlayPressed(button) Input:step() screen:update(1 / 60) Input:overlayReleased(button) Input:step() screen:update(1 / 60) end tap("a") -- FIGHT eq(screen.phase, "moves", "A on FIGHT opens the move list") tap("down") eq(screen.moveIndex, 2, "down moves onto the second move") tap("a") check(runToMenu(screen), "the turn drains back to the menu") eq(screen.moveIndex, 2, "wCurMoveNum survived the turn") tap("a") eq(screen.phase, "moves", "FIGHT reopens") eq(screen.moveIndex, 2, "on the move used last turn, not on the first") -- A moveset that shrank clamps rather than pointing past the end. tap("b") screen.moveIndex = 4 screen.battle.player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } tap("a") eq(screen.phase, "moves", "FIGHT opens again") eq(screen.moveIndex, 1, "and the row is clamped to the moves that are left") end -- SendOutPlayerMon zeroes both cursor bytes, so a switched-in mon opens on -- FIGHT and on its first move. do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GROWL", pp = 40, maxPp = 40 } } local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen = newScreen({ player = lead, party = { lead, bench } }) check(runToMenu(screen), "reached the menu") screen.menuIndex = 3 screen.moveIndex = 2 screen:submit({ kind = "switch", index = 2 }) check(runToMenu(screen), "the switch resolves back to the menu") eq(screen.menuIndex, 1, "wBattleMenuCursorPosition is back on FIGHT") eq(screen.moveIndex, 1, "and wCurMoveNum on the first move") end -- ---- no HUD rides in with the intro bands --------------------------------- -- -- InitBattleDisplay blanks the whole tilemap before BattleIntroSlidingPics -- (core.asm:8554/8564); UpdateEnemyHUD runs only after BattleStartMessage -- returns (:7815) and UpdatePlayerHUD at the tail of SendOutPlayerMon (:3838). do local screen = newScreen() eq(screen.showEnemyHud, false, "the enemy HUD does not exist at construction") eq(screen.showPlayerHud, false, "nor the player's") run(screen, BattleAnimView.SLIDE_FRAMES) eq(screen.showEnemyHud, false, "and neither rode in with the bands") eq(screen.showPlayerHud, false, "on either side") -- The appeared line is read against an empty HUD area. for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.message == "Wild PIDGEY appeared!" then break end end eq(screen.message, "Wild PIDGEY appeared!", "BattleStartMessage's own line") eq(screen.showEnemyHud, false, "with no enemy HUD under it yet") check(runToMenu(screen), "the intro drains to the menu") eq(screen.showEnemyHud, true, "UpdateEnemyHUD has run by the menu") eq(screen.showPlayerHud, true, "and UpdatePlayerHUD after the send-out") end -- ../pokecrystal/engine/battle/core.asm:8063 do local screen = newScreen() check(screen:introGrayscale(), "PREDEFPAL_BLACKOUT covers frame 0") run(screen, BattleAnimView.SLIDE_FRAMES - 1) check(screen:introGrayscale(), "and the last frame of the slide") run(screen, 1) check(not screen:introGrayscale(), "SCGB_BATTLE_COLORS at the tail of InitBattleDisplay") end -- engine/battle/core.asm:8733 do local screen = newScreen() eq(screen.ballRows.player, false, "no player ball row at construction") eq(screen.ballRows.enemy, false, "nor an OT row") run(screen, BattleAnimView.SLIDE_FRAMES) eq(screen.ballRows.player, false, "and neither rode in with the bands") eq(screen.ballRows.enemy, false, "on either side") for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.message == "Wild PIDGEY appeared!" then break end end eq(screen.message, "Wild PIDGEY appeared!", "BattleStartMessage's own line") eq(screen.ballRows.player, true, "ShowPlayerMonsRemaining rides that line") eq(screen.ballRows.enemy, false, "and wBattleMode skips ShowOTTrainerMonsRemaining in the wild") end -- ---- the wild mon cries with BattleStartMessage ---------------------------- -- -- `.not_shiny`: `ld a, [wTempEnemyMonSpecies] / call PlayStereoCry` runs before -- WildPokemonAppearedText (engine/battle/core.asm:8718-8721). do local screen = newScreen() local realAudio = screen.game.data.audio screen.game.data.audio = { cries = { PIDGEY = { pitch = 0 } } } local realPlayCry = Sound.playCry local played Sound.playCry = function(_, species) played = species end for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.message == "Wild PIDGEY appeared!" then break end end Sound.playCry = realPlayCry screen.game.data.audio = realAudio eq(played, "PIDGEY", "the wild mon cries with its appeared line") end -- ---- the message box's two fixed rows ------------------------------------- -- -- LineChar reloads the cursor at TEXTBOX_INNERY + 2 (home/text.asm:397), so a -- two-line battle string sits on rows 14 and 16 with row 15 blank. do local Chrome = require("src.ui.gen2.Chrome") local screen = newScreen() local realPrint = Chrome.print local rows = {} Chrome.print = function(text, tx, ty) rows[#rows + 1] = { text, tx, ty } end screen.message = "CYNDAQUIL used TACKLE on the wild PIDGEY!" screen:printMessage() Chrome.print = realPrint check(#rows >= 2, "a long line wraps onto a second row") eq(rows[1][2], 1, "TEXTBOX_INNERX is column 1") eq(rows[1][3], 14, "TEXTBOX_INNERY is row 14") eq(rows[2][3], 16, "and LineChar's row is 16, not 15") eq(#rows, 2, "with no third row: Paragraph clears only 14-16") end -- ---- PokeBallEffect's throw -------------------------------------------- do local screen = newScreen() -- `cp POKE_BALL + 1 / jr c, .not_kurt_ball / ld a, POKE_BALL`. eq(screen:ballAnimParam("MASTER_BALL"), 1, "MASTER BALL throws as itself") eq(screen:ballAnimParam("POKE_BALL"), 5, "so does a POKe BALL") eq(screen:ballAnimParam("FAST_BALL"), 5, "and a Kurt ball throws with POKE_BALL's param") -- data/battle_anims/ball_colors.asm. eq(screen:ballPalette("MASTER_BALL"), "PAL_BATTLE_OB_GREEN", "green") eq(screen:ballPalette("POKE_BALL"), "PAL_BATTLE_OB_RED", "red") eq(screen:ballPalette("FAST_BALL"), "PAL_BATTLE_OB_BLUE", "blue") eq(screen:ballPalette("NEST_BALL"), "PAL_BATTLE_OB_GRAY", "and anything off the table takes the terminator row's gray") -- GetPokeBallWobble: three wobbles, then the verdict. screen.battle.random = function() return 0 end screen.ballThrow = { caught = true, rate = 255, wobble = 0 } eq(screen:pokeballWobble(), 0, "a caught mon wobbles once") eq(screen:pokeballWobble(), 0, "twice") eq(screen:pokeballWobble(), 0, "three times") eq(screen:pokeballWobble(), 1, "and the fourth call is the click") screen.ballThrow = { caught = false, rate = 255, wobble = 0 } eq(screen:pokeballWobble(), 0, "a doomed throw can still wobble") eq(screen:pokeballWobble(), 0, "and again") eq(screen:pokeballWobble(), 0, "and again") eq(screen:pokeballWobble(), 2, "before it breaks free on the fourth") eq(screen:ballFailureText(), "Shoot! It was so close too!", "four wobbles is BallSoCloseText") -- A roll that never comes in under the probability breaks free at once. screen.battle.random = function(n) return (n or 256) - 1 end screen.ballThrow = { caught = false, rate = 1, wobble = 0 } eq(screen:pokeballWobble(), 2, "the first wobble already fails") eq(screen:ballFailureText(), "Oh no! The POKéMON broke free!", "one wobble is BallBrokeFreeText") screen.ballThrow = { caught = false, rate = 1, wobble = 3 } eq(screen:ballFailureText(), "Aargh! Almost had it!", "three is BallAlmostHadItText") end -- ---- a full party sends the catch to the PC, and a full box refuses ------- do -- `.SendToPC` / `predef SendMonIntoBox` (engine/items/item_effects.asm:548). local party = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party[i] = mon end local screen, battle, _, wild, save = newScreen({ player = party[1], party = party, inventory = { MASTER_BALL = 1 } }) check(runToMenu(screen), "reached the menu") screen:useItem("MASTER_BALL") eq(battle.outcome, "caught", "the Master Ball catch lands") eq(#save.party, 6, "the party is untouched") eq((save.boxes and save.boxes[1] or {})[1], wild, "and the catch went into the current box, not into nothing") -- Ball_BoxIsFullMessage: party full AND box full refuses the throw outright -- and reports the item unused, so neither the ball nor the turn goes -- (item_effects.asm:217-226). local party2 = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party2[i] = mon end local screen2, battle2, _, _, save2 = newScreen({ player = party2[1], party = party2, inventory = { MASTER_BALL = 1 } }) save2.boxes = { {} } for i = 1, 20 do save2.boxes[1][i] = { species = "PIDGEY" } end check(runToMenu(screen2), "reached the menu") local turn0 = battle2.turn screen2:useItem("MASTER_BALL") eq(battle2.outcome, nil, "the throw is refused") eq(save2.inventory.MASTER_BALL, 1, "the ball stays in the bag") eq(battle2.turn, turn0, "and the turn is not spent") check((screen2.message or ""):find("BOX"), "BallBoxFullText is what shows") end -- ---- SendMonIntoBox inserts at the HEAD, and reports a box it filled ------ do -- The species loop cascades every entry one slot down and ShiftBoxMon does -- the same for the OT names, nicknames and mon structs -- (engine/pokemon/move_mon.asm:954-968), so the catch is slot 1 -- which is -- what lets the FRIEND_BALL arm write sBoxMon1Happiness unconditionally -- ("The captured mon is now first in the box", item_effects.asm:624). local party = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party[i] = mon end local screen, battle, _, wild, save = newScreen({ player = party[1], party = party, inventory = { MASTER_BALL = 1 } }) local resident = { species = "PIDGEY" } save.boxes = { { resident } } check(runToMenu(screen), "reached the menu") screen:useItem("MASTER_BALL") eq(battle.outcome, "caught", "the Master Ball catch lands") eq(save.boxes[1][1], wild, "the catch is FIRST in the box") eq(save.boxes[1][2], resident, "and the mon already there shifted down one") eq(#save.boxes[1], 2, "with nothing lost off the end") eq(battle.boxFilled, nil, "a box with room left flags nothing") -- `.SendToPC` re-reads sBoxCount after the insert and sets -- BATTLERESULT_BOX_FULL when the catch is the one that filled it -- (item_effects.asm:612-619), which is what rings Bill on the first step -- back in the overworld. local party2 = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party2[i] = mon end local screen2, battle2, _, _, save2 = newScreen({ player = party2[1], party = party2, inventory = { MASTER_BALL = 1 } }) save2.boxes = { {} } for i = 1, 19 do save2.boxes[1][i] = { species = "PIDGEY" } end check(runToMenu(screen2), "reached the menu") screen2:useItem("MASTER_BALL") eq(battle2.outcome, "caught", "the twentieth catch still goes in") eq(#save2.boxes[1], 20, "and fills the box") eq(battle2.boxFilled, true, "which is flagged on the battle for the reload") end -- ---- a new species opens NewPokedexEntry, gated on CheckReceivedDex ------- do -- `predef NewPokedexEntry` sits between NewDexDataText and the party add -- (engine/items/item_effects.asm:535-542, :570). local function findEvent(screen, kind) for index, event in ipairs(screen.queue or {}) do if event.kind == kind then return index, event end end end local screen, _, _, _, save = newScreen({ inventory = { MASTER_BALL = 1 } }) save.engineFlags = { [11] = true } save.pokedex = { seen = {}, caught = {} } check(runToMenu(screen), "reached the menu") screen:useItem("MASTER_BALL") local dexAt, dexEvent = findEvent(screen, "dex-entry") check(dexEvent ~= nil, "the catch queues a dex-entry event") eq(dexEvent and dexEvent.species, "PIDGEY", "on the species just caught") local nickAt = findEvent(screen, "ask-nickname") check(nickAt and dexAt and dexAt < nickAt, "and it runs ahead of AskGiveNicknameText") -- CheckReceivedDex: no dex, no line and no screen (item_effects.asm:532-533). local screen2, _, _, _, save2 = newScreen({ inventory = { MASTER_BALL = 1 } }) save2.engineFlags = {} save2.pokedex = { seen = {}, caught = {} } check(runToMenu(screen2), "reached the menu") screen2:useItem("MASTER_BALL") eq(findEvent(screen2, "dex-entry"), nil, "a dexless player gets no entry") -- `jr nz, .skip_pokedex`: a row already owned skips both (:528-530). local screen3, _, _, _, save3 = newScreen({ inventory = { MASTER_BALL = 1 } }) save3.engineFlags = { [11] = true } save3.pokedex = { seen = {}, caught = { PIDGEY = true } } check(runToMenu(screen3), "reached the menu") screen3:useItem("MASTER_BALL") eq(findEvent(screen3, "dex-entry"), nil, "an owned row opens nothing") end -- ---- the low-HP siren dies with the enemy, and with a healing item -------- do -- StopDangerSound plus the wBattleLowHealthAlarm latch on every wild enemy -- faint (engine/battle/core.asm:2071-2074), read first by CheckDanger -- (:4396-4399); and wLowHealthAlarm zeroed by the healing items before the -- HP even moves (engine/items/item_effects.asm:1657-1658). local siren = false local realStart, realStop = Sound.startLoop, Sound.stopLoop Sound.startLoop = function(_, name) if name == "Low_Health_Alarm" then siren = true end end Sound.stopLoop = function(name) if name == "Low_Health_Alarm" then siren = false end end local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") player.hp = 2 screen.shownHp.player = 2 run(screen, 1) check(siren, "a red bar raises the siren") wild.hp = 1 screen:submit({ kind = "move", move = "TACKLE" }) -- The drain to zero still rings: AnimateHPBar runs before -- UpdateBattleStateAndExperienceAfterEnemyFaint is even reached. What may -- not ring is anything from the faint onward. local latched, rangAfterFaint, sawExpLine = false, false, false for _ = 1, 3000 do drainStep(screen) if screen.lowHealthAlarmDisabled then latched = true end if latched and siren then rangAfterFaint = true end if latched and (screen.message or ""):find("EXP") then sawExpLine = true end if screen.phase == "done" then break end end eq(screen.phase, "done", "the win drains out") check(latched, "the enemy's faint latched wBattleLowHealthAlarm") check(sawExpLine, "and the exp lines ran after it with the bar still red") check(not rangAfterFaint, "and the siren is cut the instant the enemy goes down, not left blaring " .. "under the exp bar and the level-up lines") -- The item exception: silent from the frame it is used, all through the -- climb, with no latch behind it. local player2 = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) player2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen2 = newScreen({ player = player2, inventory = { POTION = 1 } }) check(runToMenu(screen2), "reached the menu") player2.hp = 2 screen2.shownHp.player = 2 run(screen2, 1) check(siren, "the second battle's siren is up too") screen2:useItem("POTION") local picker2 = screen2.game.stack.top() picker2.onChoose(1, player2) check(not siren, "the POTION silences it where UseDisposableItem sits") check(picker2.itemResult and picker2.itemResult.shown == 2, "with the party row's bar only starting to climb") local rangDuringClimb = false for _ = 1, 600 do if not picker2.itemResult then break end Input:overlayPressed("a") Input:step() picker2:update(1 / 60) Input:overlayReleased("a") if picker2.itemResult and siren then rangDuringClimb = true end end for _ = 1, 600 do Input:step() screen2:update(1 / 60) if screen2.phase == "menu" then break end end check(not rangDuringClimb, "and it stays silent for the whole climb") Sound.startLoop, Sound.stopLoop = realStart, realStop end -- ---- the move list refuses a dry or disabled row -------------------------- -- -- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen` -- (engine/battle/core.asm:5213-5246), so neither spends the turn. local function tapper(screen) return function(button) for _ = 1, 400 do if not screen:syncTyper() then break end Input:step() screen:update(1 / 60) end Input:overlayPressed(button) Input:step() screen:update(1 / 60) Input:overlayReleased(button) Input:step() screen:update(1 / 60) end end do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "GROWL", pp = 40, maxPp = 40 }, { id = "TACKLE", pp = 5, maxPp = 35 } } local screen, _, _, wild = newScreen({ player = lead }) check(runToMenu(screen), "reached the menu") screen.battle:volatile(screen.battle.player).disabled = "GROWL" local tap = tapper(screen) tap("a") eq(screen.phase, "moves", "A on FIGHT still opens the list") local before = wild.hp tap("a") eq(screen.phase, "refuse-move", "the disabled row is refused") eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled") eq(wild.hp, before, "and the enemy got no free turn") -- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the -- list comes back on a press, not on a timer. tap("a") eq(screen.phase, "moves", "the list comes back") -- The same for a spent row. screen.battle:volatile(screen.battle.player).disabled = nil screen.battle.player.moves[2].pp = 0 tap("down") eq(screen.moveIndex, 2, "onto the spent move") tap("a") eq(screen.phase, "refuse-move", "a 0 PP row is refused too") eq(screen.message, "There's no PP left for this move!", "with BattleText_TheresNoPPLeftForThisMove") eq(wild.hp, before, "and still no enemy turn") tap("a") eq(screen.phase, "moves", "and the list comes back again") end -- .CheckPlayerHasUsableMoves runs at MoveSelectionScreen's head -- (engine/battle/core.asm:5058-5059): nothing usable never opens the list. do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "GROWL", pp = 40, maxPp = 40 }, { id = "TACKLE", pp = 0, maxPp = 35 } } local screen = newScreen({ player = lead }) check(runToMenu(screen), "reached the menu") screen.battle:volatile(screen.battle.player).disabled = "GROWL" local messages = {} local realPush = screen.pushAll screen.pushAll = function(self, events) for _, event in ipairs(events or {}) do if event.kind == "message" then messages[#messages + 1] = event.text end end return realPush(self, events) end tapper(screen)("a") check(screen.phase ~= "moves", "the list never opened") local struggled = false for _, text in ipairs(messages) do if text:find("has no moves left!", 1, true) then struggled = true end end check(struggled, "and the turn resolved as STRUGGLE") end -- ---- the move-learn prompts (engine/pokemon/learn.asm) ------------------- -- -- ForgetMove asks first (:123-127), the picker's B is LearnMove's .cancel -- (:104-108) and a NO there is `jp c, .loop`, back to the ask. local function learnScreen() local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GROWL", pp = 40, maxPp = 40 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, { id = "SURF", pp = 15, maxPp = 15 } } local screen = newScreen({ player = lead, party = { lead } }) check(runToMenu(screen), "reached the menu") screen:push({ kind = "choose-forget", index = 1, move = { id = "EMBER", pp = 25, maxPp = 25 }, moveName = "EMBER", text = "CYNDAQUIL wants to learn EMBER!" }) screen:advanceQueue() return screen, lead end -- AskForgetMoveText's `para`/`cont` each want a press (home/text.asm:403-448), -- so the question is reached by pages rather than by one button. local function runToPhase(screen, phase, cap) for _ = 1, (cap or 900) do drainStep(screen) if screen.phase == phase then return true end end return false end do local screen, lead = learnScreen() eq(screen.phase, "learn-intro", "AskForgetMoveText's first page holds") eq(screen.message, "CYNDAQUIL is\ntrying to learn", "with _AskForgetMoveText's own wording (data/text/common_3.asm:141-165)") check(runToPhase(screen, "ask-forget"), "and its pages run into the question") eq(screen.message, "move to make room\nfor EMBER?", "whose last page is the one YesNoBox opens over") local tap = tapper(screen) -- The question's own `prompt` is read first and YesNoBox opens after it, -- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the -- answer is the second press, not the first. tap("a") eq(screen.phase, "ask-forget", "the last page holds until it is read") tap("b") eq(screen.phase, "stop-learning", "NO there asks whether to stop learning") tap("a") tap("b") eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask") check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit") eq(lead.moves[1].id, "TACKLE", "and the moveset is untouched throughout") eq(#lead.moves, 4, "with nothing added") end do local screen, lead = learnScreen() check(runToPhase(screen, "ask-forget"), "the pages reach the question") local tap = tapper(screen) tap("a") -- read the question tap("a") -- YES eq(screen.phase, "choose-forget", "YES opens the picker") tap("a") -- slot 1 eq(lead.moves[1].id, "EMBER", "and A on slot 1 learns the move there") end -- IsHMMove refuses the slot and `jr .loop` redraws the list (learn.asm:193-197). do local screen, lead = learnScreen() check(runToPhase(screen, "ask-forget"), "the pages reach the question") local tap = tapper(screen) tap("a") -- read the question tap("a") -- YES eq(screen.phase, "choose-forget", "the picker is up") screen.forgetIndex = 4 tap("a") eq(screen.phase, "choose-forget", "A on the HM row keeps the list") eq(screen.message, "HM moves can't be\nforgotten now.", "with MoveCantForgetHMText") eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1") tap("a") eq(screen.phase, "choose-forget", "the picker is still up after the line") eq(lead.moves[4].id, "SURF", "and SURF is still there") end -- ---- GetMovePriority's Vital Throw carve-out (#1475) ---------------------- -- engine/battle/core.asm:787-789 do local screen = newScreen() check(runToMenu(screen), "reached the menu") local battle = screen.battle local moves = battle.data.moves moves.VITAL_THROW = { id = "VITAL_THROW", name = "VITALTHROW", power = 70, type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_ALWAYS_HIT" } moves.SWIFT = { id = "SWIFT", name = "SWIFT", power = 60, type = "NORMAL", accuracy = 100, pp = 20, effect = "EFFECT_ALWAYS_HIT" } moves.QUICK_ATTACK = { id = "QUICK_ATTACK", name = "QUICKATTACK", power = 40, type = "NORMAL", accuracy = 100, pp = 30, effect = "EFFECT_PRIORITY_HIT" } eq(battle:movePriority("VITAL_THROW"), -1, "VITAL_THROW goes last") 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) ------------------------- -- SendOutPlayerMon's tail (engine/battle/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.kind, "send", "the switch emits a send-out") eq(send.hp, bench.hp, "carrying a numeric HP snapshot") bench.hp = bench.hp - 7 check(send.hp ~= bench.hp, "which the rest of the turn's damage cannot walk back") screen.shownHp.player = 0 screen:push(send) screen:advanceQueue() eq(screen.shownHp.player, send.hp, "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 local screen, lead = learnScreen() screen:push({ kind = "send", side = "enemy", mon = { hp = 1 }, hp = 1, text = Battle.sentOutText("JOE", "PIDGEY") }) check(runToPhase(screen, "ask-forget"), "the pages reach the question") local tap = tapper(screen) tap("a") -- read the question tap("a") -- YES eq(screen.phase, "choose-forget", "YES opens the picker") tap("a") -- slot 1 eq(lead.moves[1].id, "EMBER", "the move is learned") eq(screen.message, "1, 2 and…", "the count line prints first") -- ../pokecrystal/home/text.asm:887-896 eq(screen.messageTimer, 0, "and text_pause holds it for no button") local poofed = false for _ = 1, 400 do Input:step() screen:update(1 / 60) if screen.message and screen.message:find("forgot", 1, true) then poofed = true break end end check(poofed, "the forgot line follows the pause with no press between") check(screen.message and screen.message:find("forgot", 1, true) ~= nil, "and its line prints ahead of the send-out that was already queued") end -- ../pokecrystal/data/text/battle.asm:240-246 -- ../pokecrystal/engine/battle/core.asm:3146-3147 -- ../pokecrystal/home/text.asm:502-526 do local function typed(screen) for _ = 1, 400 do if not screen:syncTyper() then break end Input:step() screen:update(1 / 60) end return table.concat(screen:messageLines(), "|") end local function sendOutScreen(trainer, monName) local screen, battle = newScreen() check(runToMenu(screen), "reached the menu") battle:takeEvents() local mon = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) mon.nickname = monName screen.phase = "resolving" screen.showEnemyHud = false screen:push({ kind = "send", side = "enemy", mon = mon, hp = mon.hp, text = Battle.sentOutText(trainer, monName) }) screen:advanceQueue() return screen end local function sendStarted(screen) return screen.afterSendOut ~= nil or screen.showEnemyHud == true end local screen = sendOutScreen("CHAMPION LANCE", "DRAGONITE") eq(typed(screen), "CHAMPION LANCE|sent out", "the trainer and 'sent out' are the first page, no name on it") check(not sendStarted(screen) and screen.pendingSendOut ~= nil, "the ball stays shut while the first page waits for A") local tap = tapper(screen) tap("a") Input:step() screen:update(1 / 60) local lines = screen:messageLines() eq(lines[1], "sent out", "cont scrolls 'sent out' up already printed") check(lines[2] ~= "DRAGONITE!", "and only the name types") eq(typed(screen), "sent out|DRAGONITE!", "a nine-letter name lands whole on the scrolled row") check(sendStarted(screen) and screen.pendingSendOut == nil, "and ANIM_SEND_OUT_MON starts with the name page") local short = sendOutScreen("CHAMPION LANCE", "PIDGEY") eq(typed(short), "CHAMPION LANCE|sent out", "a short name still gets the cart's unconditional third row") tapper(short)("a") eq(typed(short), "sent out|PIDGEY!", "and lands on the scrolled page") local long = newScreen() check(runToMenu(long), "reached the menu") long.battle:takeEvents() long.phase = "resolving" long:push({ kind = "message", text = "SOMELONGNAME gained 12345 EXP. Points!" }) long:advanceQueue() eq(typed(long), "SOMELONGNAME|gained 12345 EXP.", "an overlong line shows its first two rows") tapper(long)("a") eq(typed(long), "gained 12345 EXP.|Points!", "and the third row scrolls in instead of being cut") end -- ---- MoveSelectionScreen's two boxes (#1478) ------------------------------ -- engine/battle/core.asm:5074-5094, MoveInfoBox :5403-5478 do local Chrome = require("src.ui.gen2.Chrome") local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 30, maxPp = 35 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } } local screen = newScreen({ player = lead, party = { lead } }) check(runToMenu(screen), "reached the menu") screen.phase = "moves" screen.moveIndex = 1 local boxes, prints = {}, {} local saved = { box = Chrome.box, print = Chrome.print, printRight = Chrome.printRight, cursor = Chrome.cursor } Chrome.box = function(x, y, w, h) boxes[#boxes + 1] = ("%d,%d,%d,%d"):format(x, y, w, h) end Chrome.print = function(text, x, y) prints[#prints + 1] = ("%s@%d,%d"):format(tostring(text), x, y) end Chrome.printRight = function(text, x, y) prints[#prints + 1] = ("R:%s@%d,%d"):format(tostring(text), x, y) end Chrome.cursor = function(x, y) prints[#prints + 1] = ("cursor@%d,%d"):format(x, y) end local ok, err = pcall(function() screen:drawPanel() end) Chrome.box, Chrome.print = saved.box, saved.print Chrome.printRight, Chrome.cursor = saved.printRight, saved.cursor check(ok, "the move menu draws: " .. tostring(err)) 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") -- 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") check(text:find("TYPE/@1,9", 1, true) ~= nil, "TYPE/ at (1,9)") check(text:find("NORMAL@2,10", 1, true) ~= nil, "the type name at (2,10)") check(text:find("30/35@5,11", 1, true) ~= nil, "and only the highlighted move's PP, at (5,11)") check(text:match("R:%d+/%d+@19,1%d") == nil, "no PP is printed per row") end -- data/text/battle.asm:484 InLoveWithText, :490 InfatuationText: three rows do local Chrome = require("src.ui.gen2.Chrome") local screen, battle = newScreen() check(runToMenu(screen), "reached the menu") battle:volatile(battle.player).attract = true battle.random = function(n) return (n or 1) - 1 end screen:submit({ kind = "move", move = "TACKLE" }) local seen, printed, widest, tallest = {}, {}, 0, 0 for _ = 1, 3000 do local msg = screen.message if msg and not seen[msg] then seen[msg] = true local rows = Chrome.wrap(msg, 18) tallest = math.max(tallest, #rows) for i = 1, math.min(#rows, 2) do widest = math.max(widest, #rows[i]) printed[#printed + 1] = rows[i] end printed[#printed + 1] = "|" end drainStep(screen) if screen.phase == "menu" then break end end local shown = table.concat(printed, "\n") check(shown:find(battle:monName(battle.player) .. "\nis in love with", 1, true) ~= nil, "the in-love line opens on the user") check(shown:find("is in love with\n" .. battle:monName(battle.enemy) .. "!", 1, true) ~= nil, "and scrolls onto the target it never used to reach") check(shown:find("infatuation kept", 1, true) ~= nil, "InfatuationText prints") check(shown:find("it from attacking!", 1, true) ~= nil, "and so does the row the two-row box used to cut") eq(tallest, 2, "no drawn message runs past the box's two rows") check(widest <= 18, "and no row past its 18 tiles") end -- ---- SendOutPlayerMon (../pokecrystal/engine/battle/core.asm:82-93, :4027) -- do -- ../pokecrystal/data/moves/animations.asm:414-427 local SEND_OUT = { ids = { ANIM_SEND_OUT_MON = "sendout" }, scripts = { sendout = { { "wait", 7 }, { "bgeffect", "BATTLE_BG_EFFECT_ENTER_MON", 0, 1, 0 }, { "wait", 12 }, { "bgeffect", "BATTLE_BG_EFFECT_SHOW_MON", 0, 1, 0 }, { "wait", 6 }, { "ret" }, }, }, } local function animScreen() local screen, battle, player, wild = newScreen() screen.anims = SEND_OUT screen.animConstants = {} return screen, battle, player, wild end local screen, _, _, wild = animScreen() local sawSlide, trainerDuringSlide, hudDuringSlide = false, true, false local sentOut = false for _ = 1, 3000 do drainStep(screen) if screen.backpicSlide then sawSlide = true trainerDuringSlide = trainerDuringSlide and screen.showPlayerTrainer hudDuringSlide = hudDuringSlide or screen.showPlayerHud end if screen.afterSendOut then sentOut = true break end end check(sentOut, "the intro reaches the player's send-out") check(sawSlide, "the trainer's back pic slides out first (core.asm:82-84)") check(trainerDuringSlide, "and it is the TRAINER that slides, not the mon") check(not hudDuringSlide, "with no player HUD yet") check(not screen.showPlayerTrainer, "the back pic is gone once the slide ends") eq(screen.message, "Go! CYNDAQUIL!", "the Go! line is up") check(screen:picBoxCleared("player"), "and the box is EMPTY while it types: the mon is not drawn before " .. "ANIM_SEND_OUT_MON reveals it") check(not screen.showPlayerHud, "and the player HUD is not up yet") local revealFrame, sizeAtReveal, typedAtReveal local hudBeforeReveal = false for frame = 1, 400 do run(screen, 1) hudBeforeReveal = hudBeforeReveal or screen.showPlayerHud if not screen:picBoxCleared("player") then revealFrame = frame local anim = screen:animPicState("player") sizeAtReveal = anim and anim.size typedAtReveal = screen.typer == nil or screen.typer:done() break end end check(revealFrame ~= nil, "the mon does appear") check(typedAtReveal, "only after the Go! line has finished typing") check(not hudBeforeReveal, "and before the HUD, never after it") check(screen.afterSendOut ~= nil and screen.anim ~= nil, "while ANIM_SEND_OUT_MON is still running") eq(sizeAtReveal, 2, "on BattleBGEffect_EnterMon's first row, the 2x2 square " .. "(bg_effects.asm:664-666)") -- ../pokecrystal/data/text/common_2.asm:137-140 local menuFrames for frame = 1, 400 do run(screen, 1) if screen.phase == "menu" then menuFrames = frame break end end check(menuFrames ~= nil, "the menu comes up with no A press after Go!") check(screen.showPlayerHud, "with the player HUD up") check(not screen:picBoxCleared("player"), "and the mon on the field") check(screen.afterSendOut == nil, "and the send-out retired") -- ../pokecrystal/engine/battle/core.asm:3549-3600 screen:startSendOut("enemy", wild) check(screen:picBoxCleared("enemy"), "an enemy send-out starts with its box empty") local enemySize for _ = 1, 400 do run(screen, 1) if not screen:picBoxCleared("enemy") then local anim = screen:animPicState("enemy") enemySize = anim and anim.size break end end eq(enemySize, 5, "and the enemy's first square is the 5x5 (bg_effects.asm:669)") for _ = 1, 400 do if not screen.afterSendOut then break end run(screen, 1) end check(screen.afterSendOut == nil and not screen:picBoxCleared("enemy"), "and it ends with the enemy on the field") local bare = newScreen() bare.anims = nil bare.showPlayerTrainer = false bare:startSendOut("player", bare.battle.player) check(not bare:picBoxCleared("player") and bare.showPlayerHud, "without scripts the mon and the HUD come up together") end -- SlideBattlePicOut + EmptyBattleTextbox (core.asm:3221) do local screen, battle = newScreen() check(runToMenu(screen), "the intro drains") screen.showEnemyTrainer = true screen.picHidden.enemy = false screen.trainerSlide = 0 for _ = 1, 400 do run(screen, 1) if not screen.trainerSlide then break end end eq(screen.showEnemyTrainer, false, "the trainer pic slides off") eq(screen.picHidden.enemy, true, "and the box it emptied stays empty") check(screen:picBoxCleared("enemy"), "so drawPic paints nothing there") screen:startSendOut("enemy", battle.enemy) for _ = 1, 400 do if not screen.afterSendOut then break end run(screen, 1) end check(not screen:picBoxCleared("enemy"), "the send-out animation is the only thing that brings the mon back") end -- home/text.asm:630 -- (home/joypad.asm:428) do local screen = newScreen() local held = false for _ = 1, 600 do run(screen, 1) if (screen.messageTimer or 0) > 0 and not Typer.typing(screen) then held = true break end end check(held, "WildPokemonAppearedText holds for PromptButton") screen.arrowBlink = 0 check(screen:messageArrowVisible(), "the cursor is on at phase 0") screen.arrowBlink = 15 check(screen:messageArrowVisible(), "and through phase 15") screen.arrowBlink = 16 check(not screen:messageArrowVisible(), "UnloadBlinkingCursor at phase 16") screen.arrowBlink = 31 check(not screen:messageArrowVisible(), "and through phase 31") -- byte (home/text.asm:887-902) screen.arrowBlink = 0 screen.waitSfx = "SFX_TACKLE" check(not screen:messageArrowVisible(), "no cursor under a held SFX") screen.waitSfx = nil screen.messageDelay = 10 check(not screen:messageArrowVisible(), "none through a text_pause") screen.messageDelay = 0 check(screen:messageArrowVisible(), "back on once the pause is done") -- DoneText / text_end lines never load it (home/text.asm:566) screen.messageTimer = 0 check(not screen:messageArrowVisible(), "and never on a `done` line") local sliding = newScreen() local before = sliding.arrowBlink run(sliding, 1) check(sliding.slideFrame > 0 and sliding.arrowBlink ~= before, "the blink phase advances through update's early returns") end S.finish()