From dbecc345e365be592f0e58b937dfa8c5424f0455 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 20 Aug 2026 17:30:18 -0400 Subject: [PATCH] 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 --- data/scripts/gyms.lua | 12 +-- data/scripts/story.lua | 9 +- data/scripts/story4.lua | 5 +- data/scripts/story5.lua | 2 +- src/battle/BattleSafety.lua | 1 + src/battle/BattleState.lua | 59 +++++++++---- src/battle/EffectRegistry.lua | 8 +- src/battle/gen2/Battle.lua | 16 +++- src/core/Game.lua | 7 +- src/core/gen2/Save.lua | 9 ++ src/pokemon/Evolution.lua | 32 +++++-- src/render/TextBox.lua | 15 +++- src/script/gen2/Specials.lua | 3 +- src/script/gen2/Vm.lua | 11 ++- src/ui/BagMenu.lua | 6 +- src/ui/EvolutionState.lua | 7 +- src/ui/ListMenu.lua | 15 +++- src/ui/Menu.lua | 8 +- src/ui/NamingScreen.lua | 8 +- src/ui/OakSpeech.lua | 52 ++++++++++-- src/ui/StartMenu.lua | 84 +++++++++++-------- src/ui/Theme.lua | 3 + src/ui/TitleState.lua | 67 ++++++++++++--- src/ui/gen2/BattleState.lua | 27 +++--- src/ui/gen2/Pokegear.lua | 3 +- src/ui/gen2/SummaryMenu.lua | 4 +- src/world/OverworldController.lua | 49 ++++++----- src/world/gen2/World.lua | 10 ++- tests/drivers/evolution_flip_bug1412_test.lua | 9 +- .../evolution_true_color_bug494_test.lua | 10 ++- tests/drivers/gym_leader_victory_test.lua | 67 +++++++++++---- tests/engine/bag_item_box_bug1521.lua | 16 ++++ .../engine/battle_retreat_switch_bug1563.lua | 65 ++++++++++++++ tests/engine/evo_stone_cancel_bug883_test.lua | 18 ++-- .../evolution_dialogue_bug1596_test.lua | 28 +++---- .../engine/gen2_dex_mode_persist_bug1474.lua | 13 +++ tests/engine/gen2_kanto_no_roof_bug1479.lua | 26 +++++- tests/engine/gen2_stats_tiles_bug1558.lua | 51 +++++++++++ tests/engine/gen2_win_loss_text_bug1512.lua | 9 +- .../gym_leader_end_battle_text_bug1606.lua | 14 +++- .../intro_title_naming_bug1510_1511.lua | 38 ++++++++- tests/engine/pc_league_row_bug1566.lua | 11 ++- tests/engine/rare_candy_bag_open_bug796.lua | 41 +++++++++ tests/engine/save_confirm_layout_bug1522.lua | 8 +- tests/engine/thrash_setup_anim_bug1532.lua | 25 +++++- tests/engine/traded_exp_otid_bug1488.lua | 5 ++ tests/gen2_battle_end_test.lua | 8 +- tests/gen2_battle_ui_test.lua | 45 +++++++++- tests/gen2_time_routing_test.lua | 21 +++++ tests/gen2_vm_test.lua | 13 ++- tests/gen2_world_test.lua | 15 +++- tests/mod_world_tests.lua | 15 ++-- tools/make_gold_manifest.py | 3 + 53 files changed, 881 insertions(+), 225 deletions(-) create mode 100644 tests/engine/gen2_stats_tiles_bug1558.lua diff --git a/data/scripts/gyms.lua b/data/scripts/gyms.lua index dd45aeda..1050caef 100644 --- a/data/scripts/gyms.lua +++ b/data/scripts/gyms.lua @@ -38,8 +38,8 @@ local function retryTmGive(game, ow, victoryKey, done) return true end --- The leader's badge line, armed for the battle screen the way --- scripts/PewterGym.asm:117 / CeruleanGym.asm:111 SaveEndBattleTextPointers do +-- The badge line + its jingle, armed for the battle screen the way +-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606) local function badgeEndBattleText(game, victoryKey) local reward = victoryKey and require("data.scripts.victories")[victoryKey] if not (reward and reward.dialogue) then return nil end @@ -51,7 +51,7 @@ local function badgeEndBattleText(game, victoryKey) end end if #pages == 0 then return nil end - return table.concat(pages, "\f") + return table.concat(pages, "\f"), reward.badgeSound end -- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent @@ -74,7 +74,8 @@ M.PEWTER_GYM.talk = { game.data.text._PewterGymBrockPostBattleAdviceText or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done)) else - ow:engageTrainer(npc, done, badgeEndBattleText(game, "OPP_BROCK#1")) + local text, sound = badgeEndBattleText(game, "OPP_BROCK#1") + ow:engageTrainer(npc, done, text, nil, sound) end end, } @@ -107,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK game.stack:push(TextBox.new(game, game.data.text[adviceLabel] or fallback, finish)) else - ow:engageTrainer(npc, done, badgeEndBattleText(game, victoryKey)) + local text, sound = badgeEndBattleText(game, victoryKey) + ow:engageTrainer(npc, done, text, nil, sound) end end end diff --git a/data/scripts/story.lua b/data/scripts/story.lua index a4ad40ec..818d8281 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -837,13 +837,14 @@ M.SILPH_CO_11F = { -- every Silph rocket leaves off-screen (the street rockets are -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not -- run here: the battle's own callbacks are still unwinding, so - -- queueScript starts it on the first idle overworld frame -- - -- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2 - -- pushes (#722). + -- queueScript starts it on the first idle overworld frame (#722). if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then ow:queueScript(silphAftermathRows()) end - end, nil, true) + end, + -- "Arrgh!!" is armed for the battle screen, not the map + -- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606 + game.data.text._SilphCo10FGiovanniILostAgainText, true) end) end)) return true diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 3536dd8f..5a7a8bda 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y) if not master or ow:trainerDefeated(master) then return false end ow.player.facing = "right" master:facePlayer(ow.player) - ow:engageTrainer(master) + -- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606) + ow:engageTrainer(master, nil, + ((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText, + nil, nil, false) return true end diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index cf01c0a7..94221532 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -651,7 +651,7 @@ local rocketRows = { { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4 { "jump_if_true", 10 }, -- 5 { "show_text", "_CeruleanCityRocketText" }, -- 6 - -- scripts/CeruleanCity.asm:295 SaveEndBattleTextPointers + -- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers { "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7 { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8 { "jump_if_false", "end" }, -- 9 diff --git a/src/battle/BattleSafety.lua b/src/battle/BattleSafety.lua index fb4881f3..89cf6836 100644 --- a/src/battle/BattleSafety.lua +++ b/src/battle/BattleSafety.lua @@ -8,6 +8,7 @@ local BattleSafety = {} local BATTLE_BUSY_FIELDS = { "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", + "shrinkOut", "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 7cc4cec9..4a61ef0a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2495,9 +2495,12 @@ function BattleState:openOldManBag() -- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by -- the Viridian tutorial and Oak's catch, has one. local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50" + -- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle + -- screen (engine/battle/core.asm:2210) list = ListMenu.new(game, "ITEMS", { { value = "POKE_BALL", label = Strings("POKé BALL"), right = qty }, }, { + itemBox = true, script = function(l) l.scriptTimer = (l.scriptTimer or 0) + 1 if l.scriptTimer == 81 then @@ -3329,15 +3332,18 @@ end function BattleState:queueRetreatAnim() if self:starterPikachuSendOut() then self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end) - self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { wait = 24 }) - self:actNext(function() self:slidePic("playerMon") end) + self:waitNext(24) + self:actNext(function() + -- .clearScreenArea keeps the 7x7 area blank until the swap + -- (pokeyellow core.asm:1867-1871) (#1545) + self.sendingOut = true + self:slidePic("playerMon") + end) else self:actNext(function() self.shrinkOut = { battler = self.player, frame = 0 } end) - self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { wait = 7 }) + self:waitNext(7) end end @@ -3558,10 +3564,10 @@ function BattleState:updateFx() if self.growIn.frame >= 12 then self.growIn = nil end end -- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the - -- 7x7 area is cleared for good + -- 7x7 area holds cleared (scale 0) until the swap replaces the battler if self.shrinkOut then self.shrinkOut.frame = self.shrinkOut.frame + 1 - if self.shrinkOut.frame >= 7 then self.shrinkOut = nil end + if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end end -- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren -- loops while the player's bar is red; see lowHealthAlarmActive @@ -3939,7 +3945,9 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- = false) queues it from applyMimic after a successful copy if not (record and record.announceAnim == false) then self.nextInsert = (self.nextInsert or 0) + 1 - self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer } + -- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577 + self.moveAnimRow = { anim = thrashing and "THRASH" or move.id, + attackerIsPlayer = user.isPlayer } table.insert(self.queue, self.nextInsert, self.moveAnimRow) end Runtime.emit("battle.move_used", { @@ -4225,7 +4233,9 @@ function BattleState:awardExp() local playerId = self.game.save.player and self.game.save.player.id -- GainExperience (engine/battle/experience.asm:69-88) compares the -- stored MON_OTID against wPlayerID every award; no persistent flag - local traded = mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId + -- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488 + local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId) + or (mon.otId == nil and mon.traded == true)) local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, traded) @@ -4397,7 +4407,8 @@ function BattleState:enemyMonFainted() forceSwitch = true, onSwitch = function(mon) if mon == self.player.mon then - reopenShift(Strings("%s is\nalready out!", self.player.name)) + reopenShift(self:romText("_AlreadyOutText", + "%s is\nalready out!", self.player.name)) elseif mon.hp <= 0 then reopenShift(self:romText("_NoWillText", "There's no will\nto fight!")) else @@ -4524,9 +4535,22 @@ function BattleState:enemyMonFainted() -- TrainerNamePointers aims those entries at wTrainerName). The tag -- prints once, so a `para` page carries no second copy (#566). local tag = self.trainer and self.trainer.name + -- the badge jingle (sound_get_item_1 and friends) rides the armed + -- line's first page, as the script's text command would (#1606) + local sfx = self.endBattleSound + local data = self.data for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do if page ~= "" then - self:sayNext(tag and (tag .. ": " .. page) or page) + local line = tag and (tag .. ": " .. page) or page + if sfx then + local id = sfx + self:sayNextWaitSfx(line, function() + return require("src.core.Sound").play(data, id) + end) + sfx = nil + else + self:sayNext(line) + end tag = nil end end @@ -5144,7 +5168,8 @@ function BattleState:openParty() onSwitch = function(mon) -- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408) if mon == self.player.mon then - self:say(Strings("%s is\nalready out!", self.player.name)) + self:say(self:romText("_AlreadyOutText", + "%s is\nalready out!", self.player.name)) self:act(function() self:openParty() end) elseif mon.hp <= 0 then self:say(self:romText("_NoWillText", "There's no will\nto fight!")) @@ -5297,7 +5322,9 @@ end function BattleState:shrinkOutScale(battler) local shrink = self.shrinkOut if not shrink or shrink.battler ~= battler then return nil end - return shrink.frame < 4 and 5 / 7 or 3 / 7 + -- scale 0 past Delay3: the area stays cleared until the swap + -- (core.asm:1790-1796) (#1563) + return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0 end -- battler hidden this frame? (damage blink) @@ -5942,8 +5969,12 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796) local eff = s * gs if eff > 0 then + -- the retreat stages sit one tile right of the grow-in's + -- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563) + local shrinkX = self.shrinkOut + and self.shrinkOut.battler == self.player and 8 or 0 love.graphics.draw(img, - 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, + 8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) end else diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index db01c7ed..525a6c58 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -108,13 +108,17 @@ end -- The damaging pipeline, extracted from the performMove monolith: every -- stage keeps the original's exact check order and rng consumption --- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> +-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy -> -- damage choice -> hits -> messages -> after-damage -> secondary run). function EffectRegistry.runDamaging(battle, ctx, record) local user, target = ctx.user, ctx.target local move, moveInst = ctx.move, ctx.moveInst local neverMiss = record and record.neverMiss + -- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before + -- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565) + if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end + -- Swift ignores semi-invulnerability (MoveHitTest returns hit for -- SWIFT_EFFECT before the INVULNERABLE check) if target.invulnerable and not neverMiss then @@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record) local hits = hitCount(ctx, record) - if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end - if not neverMiss then if not battle:accuracyRoll(move, user, target) then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 6f95b06e..06d57035 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -892,10 +892,12 @@ Battle.PRIORITY = { EFFECT_ENDURE = 3, EFFECT_COUNTER = -1, EFFECT_MIRROR_COAT = -1, + EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE } function Battle:movePriority(moveId) - -- engine/battle/core.asm:786 GetMovePriority, `cp VITAL_THROW / ld a, 0`. + -- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z` + -- (engine/battle/core.asm:787-789). if moveId == "VITAL_THROW" then return -1 end local def = self:moveDef(moveId) return (def and Battle.PRIORITY[def.effect]) or 0 @@ -2410,6 +2412,7 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker) local sent = side == "player" and self.player or self.enemy self:emit({ kind = "send", side = side, mon = sent, hp = sent.hp or 0, status = sent.status or false, + level = sent.level, experience = sent.experience, text = "Go! " .. self:monName(sent) .. "!" }) end @@ -2670,6 +2673,7 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender, end self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming, hp = incoming.hp or 0, status = incoming.status or false, + level = incoming.level, experience = incoming.experience, text = self:monName(incoming) .. " was dragged out!" }) self:breakTrapsOnSend(incoming) self:spikesDamage(incoming) @@ -3117,6 +3121,7 @@ function Battle:resolveFaints() self:emit({ kind = "send", side = "enemy", mon = self.enemy, replacement = true, hp = self.enemy.hp or 0, status = self.enemy.status or false, + level = self.enemy.level, experience = self.enemy.experience, text = (self.trainer and self.trainer.name or "Foe") .. " sent out " .. self:monName(self.enemy) .. "!" }) Runtime.emit("battle.battler_switched", { @@ -3196,11 +3201,12 @@ function Battle:printWinLossText(result) if not trainer then return end -- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind -- the slide (engine/battle/core.asm:2310, :2320-2323). - self:emit({ kind = "trainer-return" }) + -- The CANLOSE loss arm runs ClearBox first (:2770-2773). + self:emit({ kind = "trainer-return", cleared = result == "lose" or nil }) local text = (result == "lose") and trainer.lossText or trainer.winText if type(text) ~= "string" or text == "" then return end - -- FarPrintText prints the pointer alone: no trainer-name tag in front of it, - -- unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:341). + -- FarPrintText prints the pointer alone: no trainer-name tag in front of + -- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355). self:emit({ kind = "win-text", text = text }) end @@ -3526,6 +3532,7 @@ function Battle:switch(index) self.stages.player = Battle.newStages() self:emit({ kind = "send", side = "player", mon = mon, hp = mon.hp or 0, status = mon.status or false, + level = mon.level, experience = mon.experience, text = "Go! " .. self:monName(mon) .. "!" }) -- battle.battler_switched, the payload BattleState:resolveSwitch emits on -- Gen 1: the side record, whoever walked in, and whoever walked out. @@ -3991,6 +3998,7 @@ function Battle:enemyTrySwitchOrItem() self.stages.enemy = Battle.newStages() self:emit({ kind = "send", side = "enemy", mon = self.enemy, hp = self.enemy.hp or 0, status = self.enemy.status or false, + level = self.enemy.level, experience = self.enemy.experience, text = (self.trainer.name or "TRAINER") .. " sent out " .. self:monName(self.enemy) .. "!" }) Runtime.emit("battle.battler_switched", { diff --git a/src/core/Game.lua b/src/core/Game.lua index b4bb8d99..389c8aa1 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -609,9 +609,10 @@ function Game:draw() -- ...and for the same reason the UI's own scale has to know the world is -- still the backdrop while an opaque menu covers it. Renderer:uiScale -- steps the UI down with the survey zoom only while a world is behind it, - -- gated on this frame's world pass -- which the party menu and the bag end - -- by being opaque. Without this hold they lose the step-down and blit at - -- full fit scale over a battle drawn at the zoomed-out one. + -- gated on this frame's world pass -- which the party menu ends by being + -- opaque (the bag's item box shows the map around it, #1521). Without + -- this hold it loses the step-down and blits at full fit scale over a + -- battle drawn at the zoomed-out one. Renderer.uiWorldHold = Renderer.battleDim ~= nil -- ...and a battle keeps its dialogue box and YES/NO inside its own screen -- instead of letting them dock to the window edge. diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 945144d6..edc7b862 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -220,6 +220,9 @@ function Save.newGame(opts) phoneContacts = {}, tradeFlags = {}, pokedex = { seen = {}, caught = {} }, + -- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the + -- #DEX reopens in. NEW_MODE is the cart's zero byte. + lastDexMode = "NEW", -- wUnownDex: the distinct Unown FORMS caught, in catching order. A second -- record beside the #DEX because the #DEX knows only the species -- (src/core/gen2/Unown.lua). @@ -685,6 +688,12 @@ function Save.validate(save) scrubEvents(save, report) scrubMapScenes(save, report) scrubPlayerState(save, report) + -- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES); + -- a hand-edited value falls back to NEW_MODE, the cart's zero byte + if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD" + and save.lastDexMode ~= "A-Z" then + save.lastDexMode = "NEW" + end -- The `mailmsg` structs get the same treatment for the same reason: their -- `type` byte is an item id nothing else in the save vouches for, and a -- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index dbd47f77..9dd64d69 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -161,16 +161,34 @@ end -- Headless (no real graphics) falls back to the plain text flow. function Evolution.evolve(game, mon, newSpecies, onDone, via) local oldName = mon.nickname or game.data.pokemon[mon.species].name - -- IsEvolvingText, DelayFrames 50, then ClearScreenArea before EvolveMon - -- (engine/pokemon/evos_moves.asm:120-134) + -- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11 + -- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134) local isEvolving = romText(game.data, "_IsEvolvingText", "What?\n%s is\nevolving!", oldName) if love.image and love.image.newImageData then - game.stack:push(TextBox.new(game, isEvolving, function() - -- forward `via` so EvolutionState can keep trade evolutions - -- non-cancelable (LINK_STATE_TRADING) while others accept B (#213) - Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via) - end, { auto = { delay = EVOLVING_TEXT_FRAMES } })) + local intro + intro = TextBox.new(game, isEvolving, nil, { stay = { + onShown = function() + -- DelayFrames 50 with the box and the old screen still up + -- (evos_moves.asm:122-123) + local hold = { t = 0 } + hold.update = function() + hold.t = hold.t + 1 + if hold.t < EVOLVING_TEXT_FRAMES then return end + game.stack:pop() -- this hold + -- forward `via` so trade evolutions stay non-cancelable while + -- others accept B (evos_moves.asm:72-75) (#213) + Screens.push(game, "EvolutionState", mon, newSpecies, function() + -- the result/cancel box owns the intro box's pop (#1596) + if game.stack:top() == intro then game.stack:pop() end + if onDone then onDone() end + end, via) + end + hold.draw = function() end + game.stack:push(hold) + end, + } }) + game.stack:push(intro) return end Music.play(game.data, Music.special(game.data, "evolution")) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index a3dea27b..fc324825 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 } -- waits for nothing, shows no blinking arrow, and never pops itself -- -- whoever pushed it owns the pop. stay.onShown fires once, on the frame -- the last page finishes typing, which is where the caller pushes whatever --- goes on top of it (#591). +-- goes on top of it (#591). stay.prompt waits out one arrowed A/B press +-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511). function TextBox.new(game, text, onDone, opts) local self = setmetatable({}, TextBox) self.game = game @@ -296,6 +297,15 @@ function TextBox:update(dt) -- exactly once (#591) if self.stay then if not self.stayShown then + -- stay.prompt: arrowed A/B wait, then the box stays up + -- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) + if self.stay.prompt + and not (input:wasPressed("a") or input:wasPressed("b")) then + return + end + if self.stay.prompt then + require("src.core.Sound").play(self.game.data, "Press_AB") + end self.stayShown = true if self.stay.onShown then self.stay.onShown() end end @@ -494,7 +504,8 @@ function TextBox:draw() Font.draw(money, 152 - Font.width(money), 8) end if (self.waiting or (self.done and not self.choice and not self.auto - and not self.stay)) + and (not self.stay + or (self.stay.prompt and not self.stayShown)))) and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 3fc9b7dd..6a29da98 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm) local entry = contact and contact.map and grass and grass[contact.map] local slots = entry and entry.slots if not slots then return end - local daytime = (w and w.daytime) or "DAY" + -- wTimeOfDay, not the palette pin (wildmons.asm:861) + local daytime = (w and (w.tod or w.daytime)) or "DAY" if daytime == "DARK" then daytime = "NITE" end local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)] if slot and slot.species then nameSpecies(vm, slot.species) end diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 86a79c51..4fe546fa 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -1118,7 +1118,9 @@ local function runCmd(self, cmd, op) -- really does run here. if self.reloadMapFn then self.reloadMapFn(true) end elseif op == "winlosstext" then - -- Overrides the struct's win/loss text for this battle only. + -- Overrides the struct's win/loss text for this battle only; a 0 + -- argument zeroes that pointer (engine/overworld/scripting.asm:651) + self.winLossArmed = true self.winTextOverride = cmd.winText self.lossTextOverride = cmd.lossText elseif op == "trainertext" then @@ -1126,9 +1128,11 @@ local function runCmd(self, cmd, op) local obj = self.trainerObject or {} local key if which == 1 then - key = self.winTextOverride or obj.winText + key = self.winLossArmed and self.winTextOverride + or (not self.winLossArmed and obj.winText or nil) elseif which == 2 then - key = self.lossTextOverride or obj.lossText + key = self.winLossArmed and self.lossTextOverride + or (not self.winLossArmed and obj.lossText or nil) else key = obj.seenText end @@ -2429,6 +2433,7 @@ function Vm:start(scriptKey) self.battleOutcome = nil self.winTextOverride = nil self.lossTextOverride = nil + self.winLossArmed = nil -- The whiteout abort is per-run too: a script that ended because the player -- was wiped must not stop the next one before it starts. self.aborted = false diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4aaa36cd..a59adc2e 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -339,9 +339,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) { kind = "levelup" }) -- the party menu stays up through TryEvolvingMon and only -- comes down at RemoveUsedItem (item_effects.asm:1392-1418) - closePicker() if evoTo then - Evolution.evolve(game, target, evoTo, nil, evo and evo.method) + Evolution.evolve(game, target, evoTo, closePicker, + evo and evo.method) + else + closePicker() end return end diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index bb8f0433..5f4fc75f 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -13,9 +13,10 @@ local Music = require("src.core.Music") local romText = require("src.core.RomText") +-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128), +-- so the "is evolving!" box beneath stays visible through the flash (#1596). local EvolutionState = {} EvolutionState.__index = EvolutionState -EvolutionState.isOpaque = true -- SGB: SetPal_PokemonWholeScreen for the mon on display function EvolutionState:sgbPalettes(game) @@ -147,7 +148,8 @@ end function EvolutionState:draw() love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, 0, 160, 144) + -- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128) + love.graphics.rectangle("fill", 0, 0, 160, 96) -- accelerating flash between the two forms local sprite, spriteTrueColor @@ -172,7 +174,6 @@ function EvolutionState:draw() require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions()) end end - love.graphics.setColor(1, 1, 1, 1) end return EvolutionState diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index aa110d41..01d0a24c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -94,7 +94,15 @@ function ListMenu.new(game, title, items, opts) -- the bag's item list: a partial box the map stays visible around, not a -- screen of its own (home/list_menu.asm:29-31) self.itemBox = opts.itemBox or false - if self.itemBox then self.isOpaque = false end + if self.itemBox then + self.isOpaque = false + -- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets + -- its own (engine/menus/start_sub_menus.asm:300) + self.sgbPalettes = false + -- wMaxMenuItem is 2 for item lists; the fourth printed row is a + -- look-ahead the cursor cannot reach (home/list_menu.asm:46-48) + self.cursorRows = 3 + end self.rows = opts.rows or (self.itemBox and ITEM_ROWS) or ((opts.dialogue or opts.messageBox) and 4 or ROWS) return self @@ -113,8 +121,9 @@ local function moveIndex(self, delta) end local function syncScroll(self) - if self.index - self.scroll > self.rows then - self.scroll = self.index - self.rows + local maxRow = self.cursorRows or self.rows + if self.index - self.scroll > maxRow then + self.scroll = self.index - maxRow end if self.index - self.scroll < 1 then self.scroll = self.index - 1 end end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index de2d9387..00079f18 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -119,8 +119,14 @@ function Menu:draw() self.tw * 8, self.th * 8, self.anchor) end Font.drawBox(self.tx, self.ty, self.tw, self.th) + -- PlaceString at hlcoord 3,0 writes over the border row it was just + -- drawn on (oak_speech2.asm:162-170) + if self.title then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8, + #Font.split(self.title) * 8, 8) + end love.graphics.setColor(0, 0, 0, 1) - -- PlaceString at hlcoord 3,0 writes over the border row it was just drawn on if self.title then Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8) end diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index ff44b5dd..342b88ca 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -96,15 +96,17 @@ function NamingScreen:enter() onSelect = function() -- the menu already popped itself; pop the naming screen too self.game.stack:pop() - if self.onDone then self.onDone(preset) end + if self.onDone then self.onDone(preset, false) end end, }) end if self.introBox then -- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at -- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2 + -- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset + -- list's length (oak_speech2.asm:163-166) self.game.stack:push(Menu.new(self.game, items, { - tx = 0, ty = 0, tw = 11, th = #items * 2 + 4, + tx = 0, ty = 0, tw = 11, th = 12, itemY = 2, title = Strings("NAME"), cancelable = false, })) else @@ -135,7 +137,7 @@ function NamingScreen:confirm() end Sound.play(self.game.data, "Press_AB") self.game.stack:pop() - if self.onDone then self.onDone(name) end + if self.onDone then self.onDone(name, true) end end function NamingScreen:grid() diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 4c2bdfdc..b02bb2e9 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -43,6 +43,12 @@ local PicSlide = {} PicSlide.__index = PicSlide function PicSlide:update(dt) + -- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3 + -- before the first slide step (oak_speech2.asm:69-78) + if (self.delay or 0) > 0 then + self.delay = self.delay - 1 + return + end self.t = self.t + 1 local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES)) self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8 @@ -172,8 +178,8 @@ function OakSpeech.defaultSteps(speech) kind = "say", textKey = "_IntroducePlayerText", pic = "player", - -- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText ends in - -- text_end, so PrintText returns with the box still up under the names + -- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's + -- `prompt` (text_2.asm:1730) waits for A and leaves the box up reveal = "wipe", stay = true, }, @@ -192,6 +198,9 @@ function OakSpeech.defaultSteps(speech) id = "confirm_player_name", kind = "say", textKey = "_YourNameIsText", + -- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite + -- / ClearScreen with the box still up (oak_speech.asm:93-94) + fadeOut = true, }, { id = "ask_rival_name", @@ -199,7 +208,7 @@ function OakSpeech.defaultSteps(speech) textKey = "_IntroduceRivalText", pic = "rival", -- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's - -- text_end leaves the box up for ChooseRivalName + -- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName reveal = "fade", stay = true, }, @@ -216,6 +225,9 @@ function OakSpeech.defaultSteps(speech) id = "confirm_rival_name", kind = "say", textKey = "_HisNameIsText", + -- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech + -- fade with the box up (oak_speech.asm:103-104) + fadeOut = true, }, { id = "legend", @@ -403,9 +415,20 @@ function OakSpeech:runStep(step) self:applyPic(step) self:afterReveal(step, function() self:runCry(step) - if step.stay then + if step.stay or step.fadeOut then local box = TextBox.new(self.game, self:stepText(step), nil, - { stay = { onShown = function() self:advance() end } }) + { stay = { prompt = true, onShown = function() + if step.fadeOut then + -- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94) + self.game.stack:push(require("src.render.Transition") + .whiteFlash(self.game, nil, function() + self:closeHoldBox() + self:advance() + end)) + else + self:advance() + end + end } }) self.holdBox = box self.game.stack:push(box) else @@ -432,16 +455,26 @@ function OakSpeech:runStep(step) presets = presets, introBox = true, maxLen = step.maxLen or self.nameLen, - onDone = function(name) + onDone = function(name, custom) if who == "rival" then self.game.save.player.rival = name else self.game.save.player.name = name end self:recordAnswer(step, 1, name, name) - -- YourNameIsText/HisNameIsText print into the box this one held + -- YourNameIsText / HisNameIsText print into the box this one + -- held (oak_speech2.asm:26-28, :59-61) self:closeHoldBox() - self:slidePic(-1, function() self:advance() end) + if custom then + -- .customName: ClearScreen / Delay3 / pic recentered, no + -- slide-back (oak_speech2.asm:21-25) + self.picSlide = 0 + self:advance() + else + -- OakSpeechSlidePicLeft's 13-frame pre-slide beat + -- (oak_speech2.asm:69-78) + self:slidePic(-1, function() self:advance() end, 13) + end end, }) end @@ -557,10 +590,11 @@ function OakSpeech:revealPic(kind, next) end -- ..(engine/movie/oak_speech/oak_speech2.asm ln 67) -function OakSpeech:slidePic(dir, onDone) +function OakSpeech:slidePic(dir, onDone, delay) self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8) self.game.stack:push(setmetatable({ game = self.game, speech = self, dir = dir, t = 0, onDone = onDone, + delay = delay, }, PicSlide)) end diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 16a9506a..f4c6d5c8 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +local Theme = require("src.ui.Theme") local StartMenu = {} @@ -19,6 +20,7 @@ local function sameItems(_, items) return items end function StartMenu.new(game) local flags = game.save.flags or {} local items = {} + local menu -- vanilla start submenus return here on B (RedisplayStartMenu): the -- generic Menu pops the start menu when a row is selected, so each @@ -51,20 +53,31 @@ function StartMenu.new(game) end }) -- SAVE shows the player/badges/dex/time panel then asks to confirm - -- (PrintSaveScreenText) - table.insert(items, { label = Strings("SAVE"), onSelect = function() + -- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu + -- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647) + table.insert(items, { label = Strings("SAVE"), keepOpen = true, + onSelect = function() local TextBox = require("src.render.TextBox") local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do owned = owned + 1 end + -- the panel is a static snapshot; the cart prints it once + -- (main_menu.asm:390-401) + local t = math.floor(game.save.playTime or 0) -- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and -- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405 - local panel = { - update = function() end, + local panel + panel = { + delay = 0, + update = function() + -- ld c, 30 / jp DelayFrames: the bare panel holds before the + -- prompt (main_menu.asm:404-405) + panel.delay = panel.delay + 1 + if panel.delay == 30 then panel.openPrompt() end + end, draw = function() - local t = math.floor(game.save.playTime or 0) Font.drawBox(4, 0, 16, 10) love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8) @@ -81,35 +94,40 @@ function StartMenu.new(game) } local function closePanel() if game.stack:top() == panel then game.stack:pop() end + -- SaveMenu returns into HoldTextDisplayOpen, not RedisplayStartMenu + -- (start_sub_menus.asm:645-647): the kept-open START menu goes too + if menu and game.stack:top() == menu then game.stack:pop() end + end + panel.openPrompt = function() + game.stack:push(TextBox.new(game, + Strings("Would you like to\nSAVE the game?"), nil, { + -- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather + -- than the shared right-hand one -- engine/menus/save.asm:186-192 + choiceBox = Theme.saveBox, + choice = function(yes) + if not yes then closePanel() return end + -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." + -- is a bare PlaceString held by DelayFrames 120, then GameSavedText, + -- which ends in `done` and so never reaches TX_PROMPT_BUTTON. + -- Neither page takes a button press (#765); the second waits on + -- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then + -- DelayFrames 30. The write itself is invisible either side of the + -- "Now saving..." hold, so it stays on that box's onDone. + game.stack:push(TextBox.new(game, Strings("Now saving..."), function() + game:writeSave() + game.stack:push(TextBox.new(game, + Strings("%s saved\nthe game!", game.save.player.name or "RED"), + closePanel, { auto = { + sound = function() + return require("src.core.Sound").play(game.data, "Save") + end, + delay = 30, + } })) + end, { auto = { delay = 120 } })) + end, + })) end game.stack:push(panel) - game.stack:push(TextBox.new(game, - Strings("Would you like to\nSAVE the game?"), nil, { - -- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather - -- than the shared right-hand one -- engine/menus/save.asm:186-192 - choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 }, - choice = function(yes) - if not yes then closePanel() return end - -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." - -- is a bare PlaceString held by DelayFrames 120, then GameSavedText, - -- which ends in `done` and so never reaches TX_PROMPT_BUTTON. - -- Neither page takes a button press (#765); the second waits on - -- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then - -- DelayFrames 30. The write itself is invisible either side of the - -- "Now saving..." hold, so it stays on that box's onDone. - game.stack:push(TextBox.new(game, Strings("Now saving..."), function() - game:writeSave() - game.stack:push(TextBox.new(game, - Strings("%s saved\nthe game!", game.save.player.name or "RED"), - closePanel, { auto = { - sound = function() - return require("src.core.Sound").play(game.data, "Save") - end, - delay = 30, - } })) - end, { auto = { delay = 120 } })) - end, - })) end }) table.insert(items, { label = Strings("OPTION"), onSelect = function() @@ -165,7 +183,7 @@ function StartMenu.new(game) -- with Menu's moreArrow showing while there's more below. local rowStep = 2 local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) - local menu = Menu.new(game, items, + menu = Menu.new(game, items, -- the START menu hugs the top-right corner of the SCREEN, not of a -- centred letterbox: at 9,0 x 11 it is already flush with the top and -- right of the 20x18 grid, so the anchor keeps it flush when the view diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua index 74b9b4d0..036dc9f1 100644 --- a/src/ui/Theme.lua +++ b/src/ui/Theme.lua @@ -23,6 +23,9 @@ local Theme = { -- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7 -- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384 trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 }, + -- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 too -- + -- engine/menus/save.asm:186-192 + saveBox = { tx = 0, ty = 7, tw = 6, th = 5 }, } function Theme.load(data) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 57c0dcad..6abe6074 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -392,6 +392,17 @@ function TitleState:updateSequence() self.phase = "loop" self.blinkTimer = 0 end + elseif self.phase == "exitCry" then + -- .finishedWaiting: PlayCry then WaitForSoundToFinish before the + -- white-out (engine/movie/title.asm:241-243) + self.timer = self.timer + 1 + local playing = self.exitCrySrc and self.exitCrySrc.isPlaying + and self.exitCrySrc:isPlaying() + if self.timer >= 3 and (not playing or self.timer > 180) then + self.exitCrySrc = nil + self.phase = "loop" + self:toMenu() + end end end @@ -460,8 +471,8 @@ function ContinueInfo:update(dt) self.game.stack:pop() if self.title.onContinue then self.title.onContinue() end elseif input:wasPressed("b") then + -- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92) self.game.stack:pop() - self.title:openMenu() end end @@ -497,7 +508,10 @@ function TitleState:openMenu() local game = self.game local items = {} if hasSave() then - table.insert(items, { label = Strings("CONTINUE"), onSelect = function() + -- DisplayContinueGameInfo leaves the menu box up behind the info window + -- (engine/menus/main_menu.asm:36-39, :91-92) + table.insert(items, { label = Strings("CONTINUE"), keepOpen = true, + onSelect = function() -- peek at the save for the info window; fall through if the -- file can't be read local ok, loaded = pcall(require("src.core.SaveData").load) @@ -513,8 +527,11 @@ function TitleState:openMenu() end }) -- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box -- (engine/menus/main_menu.asm ln 87-90) + local menu table.insert(items, { label = Strings("OPTION"), keepOpen = true, onSelect = function() + -- .mainMenuLoop re-zeroes wCurrentMenuItem on re-entry (main_menu.asm:56-57) + if menu then menu.index = 1 end require("src.ui.Screens").push(game, "OptionsMenu") end }) table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() @@ -532,12 +549,13 @@ function TitleState:openMenu() type(hooked)) end local th = #items * 2 + 2 - local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) + menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) -- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens - -- with GBPalWhiteOut (engine/menus/main_menu.asm:69, title.asm:29) + -- with GBPalWhiteOut and reruns the whole boot cinematic + -- (engine/menus/main_menu.asm:69-70, title.asm:29) menu.onCancel = function() game.stack:push(require("src.render.Transition").whiteFlash(game, nil, - function() self.menuOpen = false end)) + function() self:restartSequence() end)) end -- full-width title LOGO zones would recolor this box; see sgbPalettes. -- Menu.new may have grown tw for longer (e.g. localized) labels, so the @@ -546,6 +564,27 @@ function TitleState:openMenu() game.stack:push(menu) end +-- .mainMenuLoop's B branch: DisplayTitleScreen from the top +-- (main_menu.asm:70, title.asm:39-222) +function TitleState:restartSequence() + self.menuOpen = false + pcall(Music.stop) + self.scy = 0x40 + self.phase = "drop" + self.dropStep, self.dropLeft = 1, nil + self.showBubble = not self.yellowLayout + self.timer = 0 + self.blinkTimer = 0 + self.blinkAt = nil + self.cycleIndex = 1 + self.scrollPhase = "hold" + self.scrollFrame = 1 + self.monOffset = 0 + self.ballY = BALL_REST + self.ribbonOffset = nil + self.whooshSrc, self.crySrc, self.exitCrySrc = nil, nil, nil +end + -- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu, -- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26) function TitleState:toMenu() @@ -609,6 +648,11 @@ function TitleState:updateCycle() end function TitleState:update(dt) + -- an onSelect that handed control back without a new state (a failed + -- CONTINUE load, a mod row) re-runs DisplayTitleScreen (main_menu.asm:70) + if self.menuOpen and self.game.stack:top() == self then + self:restartSequence() + end if self.phase ~= "loop" then self:updateSequence() return @@ -619,10 +663,10 @@ function TitleState:update(dt) if input:wasPressed("start") or input:wasPressed("a") then -- .go_to_main_menu voices PikachuCry11 on the way out local Sound = require("src.core.Sound") - if not Sound.playPikaCry(self.game.data, 11) then - Sound.playCry(self.game.data, "PIKACHU") - end - self:toMenu() + self.exitCrySrc = Sound.playPikaCry(self.game.data, 11) + or Sound.playCry(self.game.data, "PIKACHU") + self.phase = "exitCry" + self.timer = 0 end return end @@ -633,10 +677,11 @@ function TitleState:update(dt) if input:wasPressed("start") or input:wasPressed("a") then -- the title mon cries when you leave the title (.finishedWaiting); -- Yellow's fixed Pikachu title always cries Pikachu. - require("src.core.Sound").playCry(self.game.data, + self.exitCrySrc = require("src.core.Sound").playCry(self.game.data, self.yellowLayout and "PIKACHU" or self.cycleSpecies[self.cycleIndex]) - self:toMenu() + self.phase = "exitCry" + self.timer = 0 end end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 385347e0..679de38e 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -38,6 +38,7 @@ local Sound = require("src.core.Sound") -- Only for playerPic: the player.sprite raiser both generations share. local Sprites = require("src.pokemon.Sprites") local Strings = require("src.core.Strings") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") -- Only for TextBox.substitute: the {PLAYER} / {RIVAL} markers a map text -- carries into the battle box (PrintWinLossText, home/trainers.asm:230). local TextBox = require("src.render.TextBox") @@ -161,7 +162,7 @@ local CONTEST_MENU_COL_SPACING = 12 -- PrintMoveType prints the type table's own names; only these two differ from -- the constant (data/types/names.asm). -local TYPE_NAMES = { PSYCHIC_TYPE = "PSYCHIC", CURSE_TYPE = "???" } +local TYPE_NAMES = SummaryMenu.TYPE_NAMES -- charmap.asm's quantity glyph, spelled the way MartMenu spells it. local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97" @@ -1451,12 +1452,12 @@ function BattleState:advanceQueue() -- inside SendOutPlayerMon and nothing on the enemy's path touches them. self.menuIndex = 1 self.moveIndex = 1 - -- The incoming mon's own level and exp bar: SendOutPlayerMon reloads - -- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its - -- tail (:3838), so both snap here the way shownHp does above. - self.shownLevel = event.mon.level or 1 - self.shownExp = self:expPixels(event.mon, event.mon.level, - event.mon.experience) + -- SendOutPlayerMon reloads wBattleMon* from the party slot (:3838): + -- snap from the emit-time snapshot, not the live table (#1514). + local level = event.level or event.mon.level or 1 + self.shownLevel = level + self.shownExp = self:expPixels(event.mon, level, + event.experience or event.mon.experience) self.expAnim = nil end end @@ -1470,6 +1471,12 @@ function BattleState:advanceQueue() -- BattleWinSlideInEnemyTrainerFrontpic and the DelayFrames 40 behind it -- (engine/battle/core.asm:2310-2312) if event.kind == "trainer-return" then + -- LostBattle's ClearBox wipes the live foe pic and HUD before the slide + -- (engine/battle/core.asm:2770-2773) + if event.cleared then + self.showEnemyHud = false + self.ballRows.enemy = false + end if not self.enemyTrainerImage then return self:advanceQueue() end self.showEnemyTrainer = true self.picHidden.enemy = false @@ -3577,11 +3584,11 @@ function BattleState:drawPanel() -- MoveSelectionScreen type 0 is two boxes: the name-only list -- (engine/battle/core.asm:5074-5084) and MoveInfoBox's (:5407-5410). local moveMenu = self.phase == "moves" + Chrome.box(0, 12, 20, 6) if moveMenu then - Chrome.box(0, 8, 11, 5) + -- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157). Chrome.box(4, 12, 16, 6) - else - Chrome.box(0, 12, 20, 6) + Chrome.box(0, 8, 11, 5) end if self.phase == "menu" then self:printMessage() diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 13c3fa91..4d94027e 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -1382,7 +1382,8 @@ end -- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3. function Pokegear:timeOfDayIndex() local world = self.game and self.game.world - local daytime = (world and world.daytime) + -- the unpinned clock split, not the palette pin (pokegear.asm:1456, :1957) + local daytime = (world and (world.tod or world.daytime)) or Palettes.clockDaytime(self.clock and self.clock.hour or nil) return (Palettes.DAYTIME_ID[daytime] or 2) - 1 end diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index a39ad8dc..7fdbdd74 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -886,7 +886,8 @@ function SummaryMenu:drawPageSquare(tx, ty, large, colors) local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL local sheet = self:statsTiles() if sheet and sheet.quads[first] then - -- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that order. + -- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that + -- order (engine/pokemon/stats_screen.asm:841-853). local function body() G.setColor(1, 1, 1, 1) G.draw(sheet.image, sheet.quads[first], px, py) @@ -1184,6 +1185,7 @@ end SummaryMenu.STAT_LABELS = STAT_LABELS SummaryMenu.STAT_KEYS = STAT_KEYS SummaryMenu.TYPE_NAMES = TYPE_NAMES +SummaryMenu.PAGE_PALETTES = PAGE_PALETTES SummaryMenu.levelText = levelText return SummaryMenu diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 2ca6071b..1bc821ee 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2967,25 +2967,24 @@ function OverworldState:openPC(onDone) self:openOaksPC(done) end, }) - end - -- PKMN LEAGUE sits between PROF.OAK's PC and LOG OFF once wNumHoFTeams - -- is nonzero (engine/pokemon/bills_pc.asm:5, :49) - if #(Game.save.hallOfFame or {}) > 0 then - table.insert(items, { - label = Strings("LEAGUE"), - keepOpen = true, - onSelect = function() - -- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints - -- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2) - require("src.core.Sound").play(Game.data, "Enter_PC") - Game.stack:push(TextBox.new(Game, - romText(Game.data, "_AccessedHoFPCText", - "Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."), - function() Screens.push(Game, "LeaguePC") end)) - done() - end, - }) + -- engine/pokemon/bills_pc.asm:48-60 PKMN LEAGUE row (#1566) + if #(Game.save.hallOfFame or {}) > 0 then + table.insert(items, { + label = Strings("LEAGUE"), + keepOpen = true, + onSelect = function() + -- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints + -- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2) + require("src.core.Sound").play(Game.data, "Enter_PC") + Game.stack:push(TextBox.new(Game, + romText(Game.data, "_AccessedHoFPCText", + "Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."), + function() Screens.push(Game, "LeaguePC") end)) + done() + end, + }) + end end local hooked = Runtime.call("ui.pc.items", sameItems, Game, items) @@ -3320,7 +3319,8 @@ end -- the approach walk and then EngageMapTrainer with no further text: the -- caller already showed the box, so the battle starts without a second -- one (#869). -function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText) +function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText, + endBattleSound, endBattleIsReward) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -3376,6 +3376,13 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText -- cuts (#282). Substituted here because BattleState:say takes finished -- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself. battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil + -- the badge jingle rides the armed line's first page on the battle + -- screen (sound_get_item_1 in _TX_PRE dialogue; see gyms.lua) (#1606) + battle.endBattleSound = endBattleText ~= nil and endBattleSound or nil + -- one truth for both checkVictoryRewards call sites; endBattleIsReward + -- = false marks an armed line that is NOT the victories dialogue (#1606) + battle.rewardDialogueShown = endBattleText ~= nil + and endBattleIsReward ~= false battle.onFinish = function(result) if result == "win" then Game.save.defeatedTrainers[npc.id] = true @@ -3386,7 +3393,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText -- onVictory script UNDER whatever runs next, so the player still sees -- EndBattle (now inside the battle), then the reward, then AfterBattle self:checkVictoryRewards(d.trainerClass, d.trainerParty, - endBattleText ~= nil) + battle.rewardDialogueShown) self:afterBattle(result, battle) if onDone then onDone() end else @@ -4449,7 +4456,7 @@ function OverworldState:restoreBattleContinuation(battle, origin) game.save.defeatedTrainers[origin.npcId] = true if origin.event then game.save.flags[origin.event] = true end self:checkVictoryRewards(battle.oppClass, battle.partyIndex, - battle.endBattleText ~= nil) + battle.rewardDialogueShown) end self:afterBattle(result, battle) self.engaging = false diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ce00a6c2..28ea2689 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -6017,8 +6017,14 @@ function World:trainerWinLossText() if not vm then return nil, nil end local obj = vm.trainerObject or {} local text = self.text or {} - local winKey = vm.winTextOverride or obj.winText - local lossKey = vm.lossTextOverride or obj.lossText + -- `winlosstext` writes BOTH pointers; a 0 argument destroys the struct + -- value rather than falling back to it (engine/overworld/scripting.asm:651) + local winKey, lossKey + if vm.winLossArmed then + winKey, lossKey = vm.winTextOverride, vm.lossTextOverride + else + winKey, lossKey = obj.winText, obj.lossText + end return winKey and text[winKey] or nil, lossKey and text[lossKey] or nil end diff --git a/tests/drivers/evolution_flip_bug1412_test.lua b/tests/drivers/evolution_flip_bug1412_test.lua index 7d49a6cd..dfa978c4 100644 --- a/tests/drivers/evolution_flip_bug1412_test.lua +++ b/tests/drivers/evolution_flip_bug1412_test.lua @@ -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") diff --git a/tests/drivers/evolution_true_color_bug494_test.lua b/tests/drivers/evolution_true_color_bug494_test.lua index 07ec5da7..7e9ca04d 100644 --- a/tests/drivers/evolution_true_color_bug494_test.lua +++ b/tests/drivers/evolution_true_color_bug494_test.lua @@ -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.") diff --git a/tests/drivers/gym_leader_victory_test.lua b/tests/drivers/gym_leader_victory_test.lua index 59a2ef33..075adb8e 100644 --- a/tests/drivers/gym_leader_victory_test.lua +++ b/tests/drivers/gym_leader_victory_test.lua @@ -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") diff --git a/tests/engine/bag_item_box_bug1521.lua b/tests/engine/bag_item_box_bug1521.lua index 7b1d4945..1e7afecc 100644 --- a/tests/engine/bag_item_box_bug1521.lua +++ b/tests/engine/bag_item_box_bug1521.lua @@ -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 diff --git a/tests/engine/battle_retreat_switch_bug1563.lua b/tests/engine/battle_retreat_switch_bug1563.lua index b9dde062..dc2af372 100644 --- a/tests/engine/battle_retreat_switch_bug1563.lua +++ b/tests/engine/battle_retreat_switch_bug1563.lua @@ -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)") diff --git a/tests/engine/evo_stone_cancel_bug883_test.lua b/tests/engine/evo_stone_cancel_bug883_test.lua index a76dd34f..66cd8735 100644 --- a/tests/engine/evo_stone_cancel_bug883_test.lua +++ b/tests/engine/evo_stone_cancel_bug883_test.lua @@ -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 diff --git a/tests/engine/evolution_dialogue_bug1596_test.lua b/tests/engine/evolution_dialogue_bug1596_test.lua index 7b20ca93..cf6986ab 100644 --- a/tests/engine/evolution_dialogue_bug1596_test.lua +++ b/tests/engine/evolution_dialogue_bug1596_test.lua @@ -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() diff --git a/tests/engine/gen2_dex_mode_persist_bug1474.lua b/tests/engine/gen2_dex_mode_persist_bug1474.lua index 501fb90c..3b89a858 100644 --- a/tests/engine/gen2_dex_mode_persist_bug1474.lua +++ b/tests/engine/gen2_dex_mode_persist_bug1474.lua @@ -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") diff --git a/tests/engine/gen2_kanto_no_roof_bug1479.lua b/tests/engine/gen2_kanto_no_roof_bug1479.lua index eb37547f..6a436c65 100644 --- a/tests/engine/gen2_kanto_no_roof_bug1479.lua +++ b/tests/engine/gen2_kanto_no_roof_bug1479.lua @@ -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)") diff --git a/tests/engine/gen2_stats_tiles_bug1558.lua b/tests/engine/gen2_stats_tiles_bug1558.lua new file mode 100644 index 00000000..de6fe03c --- /dev/null +++ b/tests/engine/gen2_stats_tiles_bug1558.lua @@ -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)") diff --git a/tests/engine/gen2_win_loss_text_bug1512.lua b/tests/engine/gen2_win_loss_text_bug1512.lua index e309623b..27be15a7 100644 --- a/tests/engine/gen2_win_loss_text_bug1512.lua +++ b/tests/engine/gen2_win_loss_text_bug1512.lua @@ -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") diff --git a/tests/engine/gym_leader_end_battle_text_bug1606.lua b/tests/engine/gym_leader_end_battle_text_bug1606.lua index 511aaf95..5be0ac84 100644 --- a/tests/engine/gym_leader_end_battle_text_bug1606.lua +++ b/tests/engine/gym_leader_end_battle_text_bug1606.lua @@ -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)") diff --git a/tests/engine/intro_title_naming_bug1510_1511.lua b/tests/engine/intro_title_naming_bug1510_1511.lua index 135dfa65..fe7a1703 100644 --- a/tests/engine/intro_title_naming_bug1510_1511.lua +++ b/tests/engine/intro_title_naming_bug1510_1511.lua @@ -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") diff --git a/tests/engine/pc_league_row_bug1566.lua b/tests/engine/pc_league_row_bug1566.lua index 20e4682e..510ab4ba 100644 --- a/tests/engine/pc_league_row_bug1566.lua +++ b/tests/engine/pc_league_row_bug1566.lua @@ -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, "LEAGUE"), - "the league row does not depend on EVENT_GOT_POKEDEX") +T.eq(indexOf(noDex, "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)") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua index 8a022dc6..37646a11 100644 --- a/tests/engine/rare_candy_bag_open_bug796.lua +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -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 diff --git a/tests/engine/save_confirm_layout_bug1522.lua b/tests/engine/save_confirm_layout_bug1522.lua index c04ead5b..cc2705af 100644 --- a/tests/engine/save_confirm_layout_bug1522.lua +++ b/tests/engine/save_confirm_layout_bug1522.lua @@ -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") diff --git a/tests/engine/thrash_setup_anim_bug1532.lua b/tests/engine/thrash_setup_anim_bug1532.lua index 45f6cb88..3eaee52e 100644 --- a/tests/engine/thrash_setup_anim_bug1532.lua +++ b/tests/engine/thrash_setup_anim_bug1532.lua @@ -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)") diff --git a/tests/engine/traded_exp_otid_bug1488.lua b/tests/engine/traded_exp_otid_bug1488.lua index 7eaf283d..0a571e2e 100644 --- a/tests/engine/traded_exp_otid_bug1488.lua +++ b/tests/engine/traded_exp_otid_bug1488.lua @@ -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)") diff --git a/tests/gen2_battle_end_test.lua b/tests/gen2_battle_end_test.lua index 3b98194b..529c6d09 100644 --- a/tests/gen2_battle_end_test.lua +++ b/tests/gen2_battle_end_test.lua @@ -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 diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 34189392..d88c636f 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -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") diff --git a/tests/gen2_time_routing_test.lua b/tests/gen2_time_routing_test.lua index ff79da4c..ebe40ff1 100644 --- a/tests/gen2_time_routing_test.lua +++ b/tests/gen2_time_routing_test.lua @@ -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 ------------------- diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua index b5df02e2..d0e3a6b3 100644 --- a/tests/gen2_vm_test.lua +++ b/tests/gen2_vm_test.lua @@ -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) diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index c89eec3e..7de0e454 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -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") diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 95478dee..ed4b4fbe 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -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 diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 9105a695..0176f072 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -735,6 +735,9 @@ REQUIRED_SYMBOLS = { # nine fill cells. "HP:" and the ten HP-bar cells come from # FontBattleExtra, which is already extracted. "EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX", + # gfx/stats/stats_tiles.png + gfx/stats/pages.pal, StatsScreen_LoadFont + # and _CGB_StatsScreenHPPals (#1558) + "StatsScreenPageTilesGFX", "StatsScreenPagePals", # The player's own battle back-pic (gfx/player/chris_back.2bpp.lz). It is # what stands in the player's pic box for the whole battle intro, before # SendOutPlayerMon swaps in the mon's backpic.