From 45519ad550b29299088bd83fb7a7d4a50bfcf8b8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 17 Aug 2026 10:15:06 -0400 Subject: [PATCH] CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1464, CLOSES #1465, CLOSES #1468, CLOSES #1469, CLOSES #1470 --- data/scripts/story2.lua | 17 ++ data/scripts/story4.lua | 57 +++--- src/battle/BattleState.lua | 119 +++++++----- src/battle/gen2/AnimRunner.lua | 5 +- src/battle/gen2/Battle.lua | 40 ++-- src/battle/gen2/Catching.lua | 15 +- src/core/Game2.lua | 3 + src/core/HostShell.lua | 22 ++- src/core/SaveData.lua | 7 +- src/core/gen2/Boxes.lua | 30 +++ src/core/gen2/NpcTrade.lua | 7 + src/core/gen2/Save.lua | 1 + src/import/LauncherSettings.lua | 10 + src/import/RomExtractorGen2.lua | 11 ++ src/import/RomImporter.lua | 12 +- src/inventory/Bag.lua | 33 +++- src/render/TextBox.lua | 4 + src/script/Commands.lua | 14 ++ src/ui/BagMenu.lua | 9 +- src/ui/ChoiceBox.lua | 14 +- src/ui/IntroMovie.lua | 23 ++- src/ui/PartyMenu.lua | 17 ++ src/ui/Theme.lua | 6 + src/ui/TouchControlsEditor.lua | 6 + src/ui/TownMap.lua | 28 ++- src/ui/YellowIntro.lua | 9 +- src/ui/gen2/BattleHud.lua | 92 +++++++-- src/ui/gen2/BattleState.lua | 116 +++++++++++- src/ui/gen2/BoxMenu.lua | 65 +++++-- src/ui/gen2/ItemPcMenu.lua | 5 +- src/ui/gen2/MagnetTrainRide.lua | 20 +- src/ui/gen2/OptionsMenu.lua | 9 + src/ui/gen2/PackMenu.lua | 120 +++++++++--- src/ui/gen2/PartyMenu.lua | 10 +- src/ui/gen2/PcMenu.lua | 129 ++++++++++++- src/ui/gen2/Pokegear.lua | 13 ++ src/ui/gen2/SaveMenu.lua | 23 ++- src/ui/gen2/SummaryMenu.lua | 16 +- src/world/NPC.lua | 10 +- src/world/OverworldController.lua | 44 ++++- src/world/Player.lua | 9 + src/world/gen2/MapPreview.lua | 2 +- src/world/gen2/World.lua | 64 ++++++- tests/drivers/fly_pikachu_bug1400_test.lua | 112 +++++++++++ tests/drivers/gold_bug1401_test.lua | 99 ++++++++++ tests/drivers/gold_bug1421_test.lua | 161 ++++++++++++++++ tests/drivers/gold_bug1425_test.lua | 97 ++++++++++ tests/drivers/gold_bug1428_test.lua | 174 +++++++++++++++++ tests/drivers/gold_bug1431_test.lua | 142 ++++++++++++++ tests/drivers/gold_bug1441_test.lua | 72 +++++++ tests/drivers/gold_radio_knob_bug1442.lua | 57 ++++++ .../drivers/pikachu_entrance_bug1429_test.lua | 89 +++++++++ .../drivers/shift_prompt_box_bug1398_test.lua | 114 ++++++++++++ tests/drivers/talk_pose_bug1435_test.lua | 73 ++++++++ tests/drivers/title_seam_bug1453_test.lua | 176 ++++++++++++++++++ .../drivers/town_map_marker_bug1437_test.lua | 72 +++++++ .../engine/gen2_battler_gfx_rows_bug1231.lua | 20 ++ tests/engine/mod_targets_tests.lua | 4 +- tests/gen2_menus_test.lua | 4 +- tools/rom_manifest.json | 2 +- tools/rom_manifest_blue.json | 2 +- 61 files changed, 2481 insertions(+), 255 deletions(-) create mode 100644 tests/drivers/fly_pikachu_bug1400_test.lua create mode 100644 tests/drivers/gold_bug1401_test.lua create mode 100644 tests/drivers/gold_bug1421_test.lua create mode 100644 tests/drivers/gold_bug1425_test.lua create mode 100644 tests/drivers/gold_bug1428_test.lua create mode 100644 tests/drivers/gold_bug1431_test.lua create mode 100644 tests/drivers/gold_bug1441_test.lua create mode 100644 tests/drivers/gold_radio_knob_bug1442.lua create mode 100644 tests/drivers/pikachu_entrance_bug1429_test.lua create mode 100644 tests/drivers/shift_prompt_box_bug1398_test.lua create mode 100644 tests/drivers/talk_pose_bug1435_test.lua create mode 100644 tests/drivers/title_seam_bug1453_test.lua create mode 100644 tests/drivers/town_map_marker_bug1437_test.lua diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index ffbfb585..094bc011 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -702,6 +702,23 @@ M.MT_MOON_B2F = { return false end, talk = { + -- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils + -- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold + TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done) + if not superNerdBeaten(ow) then + engageSuperNerd(game, ow, done) + return + end + local TextBox = require("src.render.TextBox") + local t = game.data.text + local flags = game.save.flags + local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL) + and (t._MtMoonB2FSuperNerdTheresAPokemonLabText + or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.") + or (t._MtMoonB2fSuperNerdEachTakeOneText + or "We'll each take\none!\nNo being greedy!") + game.stack:push(TextBox.new(game, line, done)) + end, TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil( "DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"), TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil( diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 769309e6..f6a759d6 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = { M.MT_MOON_POKECENTER = { talk = { - TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done) - local t = text(game) - if game.save.flags.EVENT_BOUGHT_MAGIKARP then - push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText - or "Well, I don't\ngive refunds!", done) - return - end - ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText - or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes) - if not yes then - push(game, t._MtMoonPokecenterMagikarpSalesmanNoText - or "No? I'm only\nselling today!", done) - return - end - if game.save.money < 500 then - push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText - or "You'll need more\nmoney than that!", done) - return - end - game.save.money = game.save.money - 500 - game.save.flags.EVENT_BOUGHT_MAGIKARP = true - local Commands = require("src.script.Commands") - Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, - "MAGIKARP", 5) - push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done) - end) - end, + -- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407) + TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = { + { "check_flag", "EVENT_BOUGHT_MAGIKARP" }, + { "jump_if_true", "no_refunds" }, + -- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31 + { "text_opts", { money = true } }, + { "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" }, + { "jump_if_false", "declined" }, + { "check_money", 500 }, + { "jump_if_false", "no_money" }, + { "give_pokemon", "MAGIKARP", 5 }, + -- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged + { "jump_if_false", "box_full" }, + { "take_money", 500 }, + { "set_flag", "EVENT_BOUGHT_MAGIKARP" }, + { "text_sound", "Get_Item1" }, + { "show_text", "_GotMonText", { RAM = "MAGIKARP" } }, + { "jump", "end" }, + { "label", "box_full" }, + { "show_text", "_BoxIsFullText" }, + { "jump", "end" }, + { "label", "declined" }, + { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" }, + { "jump", "end" }, + { "label", "no_money" }, + { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" }, + { "jump", "end" }, + { "label", "no_refunds" }, + { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" }, + }, }, } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index bda28b39..e43d5348 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime") local BattleSafety = require("src.battle.BattleSafety") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") +local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") @@ -983,8 +984,8 @@ end -- Message that opens YES/NO once typed out, keeping the text visible -- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice). -function BattleState:sayChoice(text, onChoose) - table.insert(self.queue, { text = text, choice = onChoose }) +function BattleState:sayChoice(text, onChoose, opts) + table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts }) end function BattleState:act(fn) @@ -1485,7 +1486,7 @@ function BattleState:updateQueue() local fn = item.choice battle.current = nil fn(yes) - end)) + end, item.choiceOpts)) return true end if item and item.auto then @@ -1843,14 +1844,7 @@ function BattleState:enter() self:say(self:sendOutText(self.player.name)) -- then the POOF plays and the mon appears with its cry -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) - table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) - self:act(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): after the poof the mon grows - -- out of the ball (AnimateSendingOutMon at hlcoord 4,11) - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(true) self:markParticipant() end self.phase = "messages" @@ -2680,13 +2674,7 @@ function BattleState:resolveSwitch(newMon) sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): poof, then the grow-in - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(false) end) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) @@ -3231,6 +3219,50 @@ function BattleState:startGrowIn(battler) table.insert(self.queue, self.nextInsert, { wait = 12 }) end +-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation: +-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819 +function BattleState:starterPikachuSendOut() + if not require("src.core.GameVersion").isYellow() then return false end + local mon = self.player and self.player.mon + return require("src.world.PikachuFollower") + .isStarterPikachu(self.game.save, mon) +end + +-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord +-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1 +function BattleState:startPikachuEntrance() + self:slidePic("playerMon", -56, 0, 8, 2) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 16 }) + self:actNext(function() + self:slidePic("playerMon") + self:waitSfxNext(self:playEntranceCry(self.player)) + end) +end + +-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762), +-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819) +function BattleState:queueSendOutAnim(append) + local pikachu = self:starterPikachuSendOut() + if not pikachu then + if append then + table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) + else + self:animNext("POOF_ANIM", false) + end + end + local fn = function() + self.sendingOut = false + if pikachu then + self:startPikachuEntrance() + else + self:startGrowIn(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) + end + end + if append then self:act(fn) else self:actNext(fn) end +end + -- Should the low-health alarm sound this frame? pokered keys it off -- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets -- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is @@ -3289,17 +3321,18 @@ end -- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm) -- brings the beaten foe back in from the right one column every 4 frames. -- picOff holds the live programs by slot -- "foe" = the enemy trainer pic, --- "back" = the player's back pic -- as a screen-pixel x offset stepped --- toward `to`; updateFx advances them, drawPicsLayer adds them, and the --- queue rows that start them park a { wait } of the matching length. Call --- with no target to clear a slot (#317, #282). -function BattleState:slidePic(slot, from, to, step) +-- "back" = the player's back pic, "playerMon" = the player's mon back pic -- +-- as a screen-pixel x offset stepped toward `to`; updateFx advances them, +-- drawPicsLayer adds them, and the queue rows that start them park a +-- { wait } of the matching length. Call with no target to clear +-- a slot (#317, #282). +function BattleState:slidePic(slot, from, to, step, hold) self.picOff = self.picOff or {} if to == nil then self.picOff[slot] = nil return end - self.picOff[slot] = { x = from or 0, to = to, step = step or 4 } + self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold } end -- the live x offset for a pic slot, 0 when nothing is sliding @@ -3317,10 +3350,18 @@ function BattleState:updateFx() -- the battle slot) until its owner clears the slot if self.picOff then for _, p in pairs(self.picOff) do - if p.x < p.to then - p.x = math.min(p.to, p.x + p.step) - elseif p.x > p.to then - p.x = math.max(p.to, p.x - p.step) + local move = true + if p.hold then + p.held = (p.held or 0) + 1 + move = p.held >= p.hold + if move then p.held = 0 end + end + if move then + if p.x < p.to then + p.x = math.min(p.to, p.x + p.step) + elseif p.x > p.to then + p.x = math.max(p.to, p.x - p.step) + end end end end @@ -4231,7 +4272,7 @@ function BattleState:enemyMonFainted() end end, }) - end) + end, { box = Theme.trainerSwitchBox }) end self:act(function() local previous = self.enemy @@ -4295,12 +4336,7 @@ function BattleState:enemyMonFainted() sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(false) end) return end @@ -4491,13 +4527,7 @@ function BattleState:openReplacementMenu() sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): poof, then the grow-in - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(false) end, }) end) @@ -5749,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) else local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), pad, padL, s) - self:drawBattlerPic(self.player, dx + sx, dy + sy, s) + -- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in + -- from the left -- engine/battle/pikachu_entrance_anim.asm:1 + self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"), + dy + sy, s) end end if clipped then diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 302b8f5c..e795b6c7 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -243,8 +243,9 @@ end C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end -C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end -C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end +-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row +C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end +C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end -- GetPokeBallWobble's answer, which the ball's own script then branches on. C.checkpokeball = function(self) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index e2d5a47e..0bb2ad52 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -3363,25 +3363,33 @@ function Battle:awardExperience(loser) -- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is -- accepted and ignored rather than changing what is printed. `recipients`, -- `holders` and `halved` are the Gen 2 additions. - if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end - local alive = {} - for _, index in ipairs(participants) do - local mon = self.party[index] - if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end - end - local function applyShare(mon, split) - for index, candidate in ipairs(self.party) do - if candidate == mon then - return self:giveExperiencePass(loser, def, { index }, - math.max(1, split or 1), halved) + if Runtime.wantsHook("battle.exp_award") then + local alive = {} + for _, index in ipairs(participants) do + local mon = self.party[index] + if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end + end + local function applyShare(mon, split) + for index, candidate in ipairs(self.party) do + if candidate == mon then + return self:giveExperiencePass(loser, def, { index }, + math.max(1, split or 1), halved) + end end end + Runtime.call("battle.exp_award", vanillaAward, { + battle = self, participants = #participants, alive = alive, + applyShare = applyShare, recipients = participants, holders = holders, + halved = halved, loser = loser, + }) + else + vanillaAward() end - Runtime.call("battle.exp_award", vanillaAward, { - battle = self, participants = #participants, alive = alive, - applyShare = applyShare, recipients = participants, holders = holders, - halved = halved, loser = loser, - }) + + -- GiveExperiencePoints .done falls through ResetBattleParticipants into + -- AddBattleParticipant (engine/battle/core.asm:7116 and :3033). + self.participants = {} + if self.playerIndex then self.participants[self.playerIndex] = true end end -- The answer to a `choose-forget`: drop the move in `slot` and put the diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index 4521d34f..bfd54b30 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -347,14 +347,13 @@ function Catching.attempt(opts) -- that edits o.catchRate or o.status changes the roll exactly as it does -- on Red, and one that returns `caught, rate` replaces it outright. -- - -- `mon` and `def` are whatever the catch site supplied. Gold's battle - -- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table - -- -- hp, maxHp, catchRate, status, species -- rather than the mon and its - -- record, so both are nil there until it passes them; nil, not a stand-in - -- a mod would read as the real mon. Passing `battle = self.battle` and - -- `mon = enemy` with them is what puts the capture tail below on the real - -- catch: the Transform reload and the battle.catch_exp hook both hang off - -- the battle, and neither can be reached from a flat table. + -- `mon` and `def` are whatever the catch site supplied, and nil rather + -- than a stand-in a mod would read as the real mon when it supplied + -- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them + -- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which + -- is what puts the capture tail below on the real catch: the Transform + -- reload and the battle.catch_exp hook both hang off the battle, and + -- neither can be reached from a flat table. caught, rate = Runtime.call("catch.rate", function(_, _, _, o) return Catching.vanillaAttempt(o) end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 7871cbdd..e1cfd40d 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -939,6 +939,8 @@ function Game2:load() self.data.gen2Maps = loadGenerated("data/generated/maps.lua") self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua") self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua") + -- engine/events/magnet_train.asm:165 DrawMagnetTrain + self.data.gen2Field = loadGenerated("data/generated/field.lua") self.data.gen2Marts = loadGenerated("data/generated/marts.lua") self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua") self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua") @@ -1965,6 +1967,7 @@ function Game2:applyOptions() haptics = options.haptics, }) require("src.core.VideoMode").applyOptions(options) + require("src.core.FrameCap").applyOptions(options) local GBCFX = require("src.render.GBCFX") if GBCFX.applyOptions(options) and self.save then -- applyOptions returns true when it had to clear an unsupported level. diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index b619ebaa..156715f2 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -6,11 +6,14 @@ local HostShell = {} -- spawn tries to link against the libraries we're shipping instead of the -- system ones. We want to unset the var so that any system tools can find -- their proper libraries. Only needed when running in an AppImage. +-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a +-- 64-bit child, so ld.so prints an error into that child's output. function HostShell.envPrefix() - if os.getenv("APPIMAGE") then - return "env -u LD_LIBRARY_PATH " - end - return "" + local unset = "" + if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end + if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end + if unset == "" then return "" end + return "env " .. unset end -- Windows: every host tool we shell out to (curl for the update and mod-index @@ -222,11 +225,20 @@ end local HTTP_MARK = "\n__gen1recomp_http__" local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}" +local function stripLoaderNoise(out) + while out:find("^ERROR: ld%.so:") do + local nl = out:find("\n", 1, true) + if not nl then return "" end + out = out:sub(nl + 1) + end + return out +end + -- Split a curl pipe's output into (body, status, noise). `status` is nil -- when curl never got far enough to have one (DNS failure, no route, a -- timeout), in which case `noise` carries curl's own complaint. local function splitCurlOutput(out) - out = tostring(out or "") + out = stripLoaderNoise(tostring(out or "")) local at = nil local from = 1 while true do diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6114385f..313af119 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -666,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version) options.modsByVersion = options.modsByVersion or {} local bucket = options.modsByVersion[version] or {} options.modsByVersion[version] = bucket - -- no shared flag reads as enabled, the same default the loader applies to a - -- missing entry, so a fresh install never fills the overlay with agreement + -- with no shared flag the default is the caller's (experimental mods read as + -- disabled), so the answer is stored outright rather than judged against one local shared = options.mods and options.mods[id] - if type(shared) ~= "boolean" then shared = true end - if shared == enabled then + if type(shared) == "boolean" and shared == enabled then bucket[id] = nil else bucket[id] = enabled diff --git a/src/core/gen2/Boxes.lua b/src/core/gen2/Boxes.lua index 9cbaf746..7a87bd93 100644 --- a/src/core/gen2/Boxes.lua +++ b/src/core/gen2/Boxes.lua @@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex) return true end +-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every +-- slot back to GetMaxPPOfMove, which already carries its own PP Up count. +function Boxes.restorePP(mon) + for _, move in ipairs((mon and mon.moves) or {}) do + if type(move) == "table" then move.pp = move.maxPp or move.pp end + end +end + function Boxes.deposit(save, partyIndex, boxIndex) local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex) if not ok then return false, reason end @@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex) Mail.removeSlot(save, partyIndex) local box = Boxes.box(save, boxIndex) box[#box + 1] = mon + -- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon + -- (engine/pokemon/move_mon.asm:633-635, :696-700). + Boxes.restorePP(mon) return true, mon end @@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot) local ok, reason = Boxes.canWithdraw(save, boxIndex, slot) if not ok then return false, reason end local mon = table.remove(Boxes.box(save, boxIndex), slot) + -- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP + -- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693). + mon.status = nil + mon.statusTurns = nil + if mon.isEgg then + mon.hp = 0 + else + mon.hp = mon.maxHp or mon.hp + end save.party = save.party or {} save.party[#save.party + 1] = mon return true, mon @@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot) return true, table.remove(box, slot) end +-- RELEASE off the DEPOSIT screen, whose list is the party: it is +-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207). +function Boxes.releaseFromParty(save, slot) + local party = (save and save.party) or {} + if not party[slot] then return false, "There is no POKéMON there." end + local mon = table.remove(party, slot) + Mail.removeSlot(save, slot) + return true, mon +end + -- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case). function Boxes.move(save, fromBox, slot, toBox) if fromBox == toBox then return false, "It's already there." end diff --git a/src/core/gen2/NpcTrade.lua b/src/core/gen2/NpcTrade.lua index 5c8d97e9..65c7eb30 100644 --- a/src/core/gen2/NpcTrade.lua +++ b/src/core/gen2/NpcTrade.lua @@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index) -- that slot inheriting it (src/core/gen2/Mail.lua). Mail.removeSlot(save, index) party[#party + 1] = received + -- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196), + -- which the `predef` at engine/events/npc_trade.asm:168 runs like any other. + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + save.pokedex.seen[received.species] = true + save.pokedex.caught[received.species] = true return given, received end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index cd270b09..04b3a637 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -273,6 +273,7 @@ Save.DEFAULT_OPTIONS = { -- means something different, hence the different name. color = "gbc", videoMode = "windowed", + fpsCap = 60, musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 7f043d1d..b4f5d3d9 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -638,6 +638,16 @@ local function gen2Rows(opts, hooks) end) end + local okCap, FrameCap = pcall(require, "src.core.FrameCap") + if okCap then + add(Strings("MAX FPS"), + function() return FrameCap.label(opts.fpsCap) end, + function(dir) + opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir) + return true + end) + end + addTouchRows(rows, add, opts, hooks) return rows diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index c919e104..6360da09 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS -- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit -- pointer, so the bank has to come from here. local PAL_MAP_BANK = 0x02 +-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b +-- carries no manifest symbol to resolve it through. +local BALL_ICON_GFX = { 0x0b, 0x41a4 } -- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two -- tiles per byte: low nibble first tile, high nibble second (`dn` in the -- tilepal macro). The high bit of each nibble is the VRAM bank, not colour. @@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx() hud.expBarFirstTile = 0x55 hud.expBarCells = 9 + -- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ + -- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232). + local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX + self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8, + "battle/hud/balls.png", true) + hud.balls = "assets/generated/battle/hud/balls.png" + hud.ballsFirstTile = 0x31 + -- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on- -- transparent font page: the bar's rule is shade 3 (black) while its fill is -- shade 1/2 (the HP colour), so flattening it to one ink loses the very diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 2efd6dcd..37fa0868 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3594,7 +3594,17 @@ function RomImporter:_setAllMods(want, confirmed) local LauncherMods = require("src.mods.LauncherMods") local ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do - if m.enabled ~= want then + local mismatched = m.enabled ~= want + if not self.modScope and type(m.enabledByVersion) == "table" then + mismatched = false + for _, game in ipairs(GameVersion.ORDER) do + if m.enabledByVersion[game] ~= want then + mismatched = true + break + end + end + end + if mismatched then ids[#ids + 1] = m.id if want and m.experimental then experimental = true end end diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index fcacc0cc..ea0e7d83 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -68,14 +68,20 @@ end -- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for -- saves from before the order existed, then maintained incrementally. -function Bag.order(save) +function Bag.order(save, data) local order = save.bagOrder if not order then + local items = (data or require("src.core.Data")).items order = {} for id in pairs(save.inventory) do if not isBadge(id) then table.insert(order, id) end end - table.sort(order) + table.sort(order, function(a, b) + local ia = (items and items[a] and items[a].index) or math.huge + local ib = (items and items[b] and items[b].index) or math.huge + if ia ~= ib then return ia < ib end + return a < b + end) save.bagOrder = order end -- drop stale ids, append unknown ones (defensive against direct @@ -95,6 +101,29 @@ function Bag.order(save) return order end +-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the +-- rotate the PACK's SELECT performs, over the rows of ONE pocket. +function Bag.move(save, id, pocket, toIndex, data) + local order = Bag.order(save, data) + local slots, ids = {}, {} + for i = 1, #order do + if pocketOf(order[i], data) == pocket then + slots[#slots + 1] = i + ids[#ids + 1] = order[i] + end + end + local from + for i = 1, #ids do + if ids[i] == id then from = i break end + end + if not from then return false end + local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids)) + if to == from then return false end + table.insert(ids, to, table.remove(ids, from)) + for i = 1, #slots do order[slots[i]] = ids[i] end + return true +end + -- Add qty of an item; returns false (and adds nothing) when a new slot -- is needed and the bag is full, or when the stack would pass 99 -- (AddItemToInventory's per-slot quantity cap). diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 987fa90b..75fbbe40 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 } -- up over the still-visible text (YesNoChoicePokeCenter and friends); -- the box then closes and choice(yes) runs instead of onDone. -- opts.defaultNo starts the cursor on NO. +-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16 -- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like -- _UsedStrengthText) never wait for a button: once the last page has -- typed out, auto.sound() runs (returning an audio source blocks like @@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts) self.choice = opts and opts.choice self.defaultNo = opts and opts.defaultNo self.choiceNoSound = opts and opts.noSound + self.choiceLabels = opts and opts.choiceLabels + self.choiceBox = opts and opts.choiceBox self.money = opts and opts.money self.auto = opts and opts.auto self.stay = opts and opts.stay @@ -340,6 +343,7 @@ function TextBox:update(dt) self.game.stack:pop() -- this text box, under the choice self.choice(yes) end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound, + labels = self.choiceLabels, box = self.choiceBox, -- this box is anchored below it; the pair moves together anchor = "bottom" })) end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 104b2c50..e6d58a57 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts) opts[k] = v end end + -- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet + if opts and opts.money == true then + opts.money = function() return ctx.save.money end + end ctx.game.stack:push(TextBox.new(ctx.game, text, function() runner:resume() end, opts)) @@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount) ctx.save.money = math.max(0, ctx.save.money + amount) end +-- check_money : HasEnoughMoney (home/money.asm:1) +function Commands.check_money(ctx, amount) + ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0) +end + +-- take_money : SubBCDPredef (engine/math/bcd.asm:193) +function Commands.take_money(ctx, amount) + ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0)) +end + -- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the -- live overworld memory in sync so a scripted home warp from the HoF PC -- does not leave Red's house mats aimed at Indigo Plateau (#103). diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 5d6807f1..b4a5dffb 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -300,7 +300,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end list.index = math.min(list.index, math.max(1, #list.items)) if extra and extra.evolveTo then - list:close() + -- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu local Evolution = require("src.pokemon.Evolution") -- item_effects.asm ItemUseEvoStone sets wForceEvolution before -- TryEvolvingMon, so a stone evolution's B press is read and @@ -428,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list) -- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon -- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on -- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM -> - -- party_menu.asm TM/HM type). Stones and other pickOnly items keep the - -- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate - -- strictly on def.machine. #210 + -- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE + -- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114). if def and def.machine then opts.tmhm = { move = def.machine.move, kind = def.machine.kind } + elseif ItemEffects.isStone(id) then + opts.evoStone = id end require("src.ui.Screens").push(game, "PartyMenu", opts) end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index e175202b..3f14b5bf 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts) -- and docking it to the window edge instead tears it off that screen by -- however far the letterbox sits from the edge. self.anchor = opts and opts.anchor or nil - local box = Theme.choiceBox + local box = (opts and opts.box) or Theme.choiceBox self.tx = (opts and opts.tx) or box.tx self.ty = (opts and opts.ty) or box.ty self.tw = (opts and opts.tw) or box.tw self.th = (opts and opts.th) or box.th + -- TwoOptionMenuStrings rows carry their own labels and a "blank line + -- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16) + self.labels = (opts and opts.labels) or { "YES", "NO" } + self.firstItem = (opts and opts.firstItem) or box.firstItem or 1 return self end @@ -81,10 +85,12 @@ function ChoiceBox:draw() local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() Font.drawBox(tx, ty, tw, th, paper) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) - Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8) + -- advances 2 * SCREEN_WIDTH -- home/text.asm:64 + local row = self.firstItem + Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8) + Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8) Font.drawCode(Theme.cursor, (tx + 1) * 8, - (ty + (self.index == 1 and 1 or 3)) * 8) + (ty + row + (self.index == 1 and 0 or 2)) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index b35dcef5..f774dcd0 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -293,23 +293,30 @@ function IntroMovie:update(dt) return end local input = self.game.input - if input:wasPressed("a") or input:wasPressed("start") then - -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only - -- on a fresh START or A -- B alone never skips the intro. - -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the - -- white hold stands in for that beat before the title is built. - self:exitToTitle() - return - end + -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only + -- on a fresh START or A -- B alone never skips the intro. + local skip = input:wasPressed("a") or input:wasPressed("start") self.timer = self.timer + 1 if self.phase == 1 then + -- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311) if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end elseif self.phase == 2 then if self.timer == STAR_START then Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30 end + -- intro.asm:325 `jr c, .next` + if skip and self.timer >= STAR_START and self.timer < WAVES_END then + self:startPhase(3) + return + end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end else + -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the + -- white hold stands in for that beat before the title is built. + if skip then + self:exitToTitle() + return + end -- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing -- (intro.asm:337), so PlayIntroScene's first op is not on the music's -- own frame diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 04c73930..833b5ffb 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -313,6 +313,9 @@ function PartyMenu.new(game, opts) -- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 self.tmhm = opts.tmhm + -- Evolution stones: opts.evoStone = item id gives Gen 1's + -- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411 + self.evoStone = opts.evoStone self.forceSwitch = opts.forceSwitch self.battle = opts.battle self.party = party -- link/scoped battles pass their local party view @@ -800,6 +803,20 @@ function PartyMenu:draw() else Font.draw(Strings("NOT ABLE"), 88, y + 8) end + elseif self.evoStone then + -- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching + -- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot + local can = false + for _, evo in ipairs(def.evolutions or {}) do + if evo.method == "ITEM" and evo.item == self.evoStone then + can = true break + end + end + if can then + Font.draw(Strings("ABLE"), 120, y + 8) + else + Font.draw(Strings("NOT ABLE"), 88, y + 8) + end else if mon.hp <= 0 then Font.draw(Strings("FNT"), 136, y) diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua index c72992d1..74b9b4d0 100644 --- a/src/ui/Theme.lua +++ b/src/ui/Theme.lua @@ -17,6 +17,12 @@ local Theme = { textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, -- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3) choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 }, + -- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line + -- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16 + healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 }, + -- 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 }, } function Theme.load(data) diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 404a416c..f48905f2 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -63,6 +63,7 @@ function Editor.load(opts) Editor.hostPoll = opts.hostPoll == true Editor.drag = nil Editor.rects = {} + Editor._closed = false Editor._hostMouse = false Editor._hostTouches = nil Editor.fonts = { @@ -137,6 +138,7 @@ function Editor.unload() Editor.drag = nil Editor.onClose = nil Editor.version = nil + Editor.rects = {} Editor._hostMouse = false Editor._hostTouches = nil end @@ -159,6 +161,8 @@ local function persist() end local function close() + if Editor._closed then return end + Editor._closed = true persist() local cb = Editor.onClose Editor.unload() @@ -427,6 +431,7 @@ function Editor.pollHostPointers() if not Editor._hostMouse then Editor._hostMouse = true beginDrag("mouse", x, y) + if Editor._closed then return end else moveDrag("mouse", x, y) end @@ -446,6 +451,7 @@ function Editor.pollHostPointers() if not Editor._hostTouches[id] then Editor._hostTouches[id] = true beginDrag(id, tx, ty) + if Editor._closed then return end else moveDrag(id, tx, ty) end diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 95780fed..ac4fac08 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -14,7 +14,9 @@ -- This is what the party-menu FLY field move opens (#195). local Font = require("src.render.Font") +local PaletteFX = require("src.render.PaletteFX") local Sound = require("src.core.Sound") +local SpriteRenderer = require("src.render.SpriteRenderer") local TownMap = {} TownMap.__index = TownMap @@ -227,7 +229,21 @@ function TownMap.new(game, opts) local sprites = game.data.sprites or {} local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED - local ok, img = pcall(love.graphics.newImage, red and red.image) + -- the marker is the overworld walking sheet, so it wears that sheet's OBJ + -- palette and shade-0 keying -- engine/items/town_map.asm:342 + local colors, group + if PaletteFX.usesGbcPack() then + colors, group = PaletteFX.spriteObp(red, "player") + end + if not colors then + if PaletteFX.usesSpriteObp() then + colors, group = PaletteFX.ogObj() + else + colors, group = PaletteFX.dmgObj() + end + end + local ok, img = pcall(SpriteRenderer.obpImage, + red and red.image, colors, group) if ok and img then self.playerSheet = img self.playerQuad = love.graphics.newQuad(0, 0, 16, 16, @@ -316,6 +332,13 @@ function TownMap:update(dt) end end +-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed +-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301) +function TownMap:markPlayerRedraw(x, y) + if not PaletteFX.usesSpriteObp() then return end + PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y) +end + local function drawSquare(loc) if isRoute(loc) then love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter @@ -364,6 +387,7 @@ function TownMap:draw() local x, y = markerXY(self.playerLoc) if self.playerSheet then love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3) + self:markPlayerRedraw(x - 4, y - 3) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) @@ -406,6 +430,8 @@ function TownMap:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.playerSheet, self.playerQuad, self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3) + self:markPlayerRedraw(self.playerLoc.x * 8 - 4, + self.playerLoc.y * 8 - 3) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, diff --git a/src/ui/YellowIntro.lua b/src/ui/YellowIntro.lua index 0cfc1446..80b25947 100644 --- a/src/ui/YellowIntro.lua +++ b/src/ui/YellowIntro.lua @@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone) local ok, canvas = pcall(love.graphics.newCanvas, 256, 256) self.bgCanvas = ok and canvas or nil - -- Yellow boots exactly like Red up to the attract movie: the copyright - -- card and the GAME FREAK shooting-star splash play first. Reuse - -- IntroMovie's phases 1-2 and take over where its Gengar fight (phase - -- 3) would begin; a skip press during the pre-roll skips everything. + -- intro.asm:311 copyright card, splash.asm:29 shooting star local IntroMovie = require("src.ui.IntroMovie") local pre = IntroMovie.new(game, nil) local baseStart = pre.startPhase @@ -644,8 +641,8 @@ end -- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land -- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then -- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM --- buffers before the title screen is built (#523). The pre-roll's own skip --- is IntroMovie's path, not this one, and still finishes immediately. +-- buffers before the title screen is built (#523). A press during the +-- pre-roll splash starts the scenes instead of reaching here. function YellowIntro:exitToTitle() if self.finished or self.exiting then return end self.exiting = true diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index 00afb61b..716ed85d 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f -- (engine/battle/trainer_huds.asm:143-152). local TILE_CAUGHT = 0x5d +-- The ball icons StageBallTilesData stages, one per party slot +-- (engine/battle/trainer_huds.asm:47-99). +local TILE_BALL_NORMAL = 0x31 +local TILE_BALL_STATUSED = 0x32 +local TILE_BALL_FAINTED = 0x33 +local TILE_BALL_EMPTY = 0x34 +-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is +-- DrawPlayerHUDBorder's with $77 swapped for $5c. +local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c + +BattleHud.PARTY_LENGTH = 6 + function BattleHud.new(menuGfx, palettes) local self = setmetatable({}, BattleHud) self.gfx = menuGfx and menuGfx.battleHud or nil @@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX +-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11) +-- (engine/gfx/cgb_layouts.asm:142-145). +function BattleHud:expColors() + local pal = self.palettes and self.palettes.expBar + if not pal then return nil end + return { + { 255, 255, 255 }, + { pal[1][1], pal[1][2], pal[1][3] }, + { pal[2][1], pal[2][2], pal[2][3] }, + { 0, 0, 0 }, + } +end + function BattleHud:drawExpBar(fraction, tx, ty) if not self:image("hpBar") then return false end fraction = math.max(0, math.min(1, fraction or 0)) local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX) -- The whole row wears the exp bar's palette, full and empty cells included. - local pal = self.palettes and self.palettes.expBar - local colors = pal and { - { 255, 255, 255 }, - { pal[1][1], pal[1][2], pal[1][3] }, - { pal[2][1], pal[2][2], pal[2][3] }, - { 0, 0, 0 }, - } or nil + local colors = self:expColors() local remaining = pixels for cell = BattleHud.EXP_CELLS - 1, 0, -1 do @@ -269,19 +288,56 @@ end -- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f / -- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub -- is two rows tall. +local PLAYER_FRAME_TILES = { + sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST, + cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, + -- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet + -- (engine/gfx/load_font.asm:57-65). + farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST, + side = TILE_PLAYER_RIGHT, + nearCorner = TILE_PLAYER_BOTTOM_RIGHT, + farCorner = TILE_PLAYER_BOTTOM_LEFT, + bottom = TILE_BOTTOM_SIDE, +} + function BattleHud:drawPlayerFrame() self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9) - self:placeBorder({ - sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST, - cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, - -- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet - -- (engine/gfx/load_font.asm:57-65). - farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST, - side = TILE_PLAYER_RIGHT, - nearCorner = TILE_PLAYER_BOTTOM_RIGHT, - farCorner = TILE_PLAYER_BOTTOM_LEFT, - bottom = TILE_BOTTOM_SIDE, - }, 18, 10, -1) + self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1) +end + +-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the +-- player border with $5c for the bottom right, and no bar at (18,9). +function BattleHud:drawPartyIconFrame() + self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1) + return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile, + TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors()) +end + +-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party +-- count (engine/battle/trainer_huds.asm:47-100). +local function ballTile(mon) + if not mon then return TILE_BALL_EMPTY end + if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end + return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL +end + +-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214). +function BattleHud:ballColors() + local pals = self.palettes and self.palettes.battleObjects + return pals and pals.PAL_BATTLE_OB_YELLOW or nil +end + +-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites +-- from (tx, ty), each one tile further along `step`. +function BattleHud:drawBallRow(party, tx, ty, step) + if not self:image("balls") then return false end + local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL + local colors = self:ballColors() + for slot = 1, BattleHud.PARTY_LENGTH do + self:drawTile("balls", first, ballTile(party and party[slot]), + tx + (slot - 1) * step, ty, colors) + end + return true end BattleHud.TILE_HP_LABEL = TILE_HP_LABEL diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index bbbc248c..cd1b7d15 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -314,6 +314,13 @@ function BattleState.new(game, opts) self.showEnemyHud = false self.showPlayerHud = false + -- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the + -- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733). + self.ballRows = { + player = true, + enemy = not (self.battle and self.battle.wild), + } + -- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7 -- frontpic in the enemy pic box BEFORE the intro slide, and it stays there -- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The @@ -349,6 +356,7 @@ function BattleState.new(game, opts) local enemy = self.battle and self.battle.enemy self:noteFirstUnown(enemy) + self:markSeen(enemy) if enemy then if self.battle.wild then -- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's @@ -399,6 +407,13 @@ function BattleState.new(game, opts) -- "X fainted!" is even displayed. The replacement arrives with its own -- `send` event, which is where the cart's send-out animation sits. self.shownMon = { player = player, enemy = enemy } + -- home/battle.asm:150 UpdateBattleHuds + self.shownStatus = { + player = (player and player.status) or false, + enemy = (enemy and enemy.status) or false, + } + -- engine/battle/trainer_huds.asm:142-151 + self.caughtMark = self:dexCaught(enemy) -- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is -- only advanced inside its level loop, right after that level's bar has -- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor @@ -443,6 +458,16 @@ function BattleState:noteFirstUnown(mon) save.firstUnownSeen = Unown.monLetter(mon) end +-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen +-- flag is stamped for every battle mode, so a trainer's mon counts too. +function BattleState:markSeen(mon) + local save = self.save + if not (save and mon and mon.species) then return end + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.seen[mon.species] = true +end + -- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the -- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu -- before LoadBattleMenu, TutorialPack before its own loop), so each one goes @@ -936,6 +961,25 @@ function BattleState:dexCaught(mon) return (mon and caught and caught[mon.species]) and true or false end +-- The status the HUD prints, one drain behind the engine the way shownHp is: +-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150). +function BattleState:hudStatus(mon, side) + local shown = side and self.shownStatus and self.shownStatus[side] + if shown == nil then return mon and mon.status or nil end + return shown or nil +end + +-- home/battle.asm:150, for the clears no queued event carries (a mon waking, +-- a thaw, a bag cure): the tags catch up once the queue is idle. +function BattleState:syncShownStatus() + local shown, battle = self.shownStatus, self.battle + if not (shown and battle) then return end + for _, side in ipairs({ "player", "enemy" }) do + local mon = battle[side] + shown[side] = (mon and mon.status) or false + end +end + -- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531) -- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a -- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0, @@ -1153,7 +1197,7 @@ function BattleState:stepAnim(input) -- Cart still reaches the after-anim arm after a move script ends; a skip -- of the move should not drop the hit shake that follows it. if self:startPendingAfterAnim() then return end - return self:endSendOutAnim() + return self:endSendOutAnim(true) end if not self.anim:step() then self:latchCaughtPic() @@ -1170,10 +1214,17 @@ end -- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and -- HUD update run the moment its animation is done, cut short or not. -function BattleState:endSendOutAnim() +function BattleState:endSendOutAnim(skipped) local after = self.afterSendOut if not after then return end self.afterSendOut = nil + if after.shiny and not skipped then + after.shiny = nil + if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then + self.afterSendOut = after + return + end + end self:finishSendOut(after) end @@ -1202,6 +1253,7 @@ function BattleState:advanceQueue() self.introTextShown = nil end if not event then + self:syncShownStatus() -- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no -- turn (engine/battle/core.asm:2955-2963). if self.shiftSwitchIndex then @@ -1315,6 +1367,14 @@ function BattleState:advanceQueue() self.shownHp[event.side] = event.mon.hp or 0 if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end end + -- And the same lag for the status tag (home/battle.asm:150); a send snaps it + -- to the incoming mon. + if self.shownStatus and event.side + and (event.kind == "status" + or (event.kind == "send" and event.mon)) then + self.shownStatus[event.side] = + (event.kind == "send" and event.mon.status or event.status) or false + end -- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE -- GiveExperiencePoints before the exp is committed (the call at :6888 sits -- ahead of the commit at :6889-6901), so the bar crawls from the figures @@ -1339,7 +1399,12 @@ function BattleState:advanceQueue() -- is still on screen for its own line and the replacement arrives here. if self.shownMon then self.shownMon[event.side] = event.mon end -- The second of the cart's two wFirstUnownSeen writes (core.asm:3251). - if event.side == "enemy" then self:noteFirstUnown(event.mon) end + if event.side == "enemy" then + self:noteFirstUnown(event.mon) + self:markSeen(event.mon) + -- engine/battle/trainer_huds.asm:142-151 + self.caughtMark = self:dexCaught(event.mon) + end if event.side == "player" then -- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back -- to back (engine/battle/core.asm:3809), so a switched-in mon opens on @@ -1535,12 +1600,18 @@ end -- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD -- come after the animation, not with it. function BattleState:startSendOut(side, mon) - local after = { side = side, mon = mon } + -- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's + -- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377). + local after = { side = side, mon = mon, shiny = mon and mon.shiny and true } -- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic -- into the box before they play the animation, which is the one thing that -- undoes a cleared box. self.picHidden[side] = false self.faintSlide = nil + -- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which + -- this animation's own sprites overwrite. + self.ballRows.player = false + self.ballRows.enemy = false if self:animForId("ANIM_SEND_OUT_MON", side) then self.afterSendOut = after return true @@ -2569,6 +2640,9 @@ function BattleState:pushCaught(enemy, itemId) -- in the box", item_effects.asm:624). Boxes.deposit stays an append: the -- PC's own move is InsertPokemonIntoBox, which inserts at the cursor. table.insert(box, 1, enemy) + -- SendMonIntoBox refills the boxed slot's PP before it closes SRAM + -- (move_mon.asm:1062-1063). + Boxes.restorePP(enemy) -- `.SendToPC` re-reads sBoxCount AFTER the insert and sets -- BATTLERESULT_BOX_FULL when the box has just filled -- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit @@ -2638,6 +2712,9 @@ end -- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231). function BattleState:offerShiftSwitch(mon) self.shiftIndex = 1 + -- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt + -- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15). + self.ballRows.enemy = true local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe" local player = (self.save and self.save.player and self.save.player.name) or "GOLD" @@ -2921,6 +2998,9 @@ function BattleState:useItem(itemId) end local player = self.battle.player caught, rate = Catching.attempt({ + battle = self.battle, + mon = enemy, + def = enemyDef, maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp, catchRate = enemyDef and enemyDef.catchRate or 45, @@ -3231,13 +3311,13 @@ function BattleState:drawHud() -- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag -- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops -- the level entirely while one is up. - Chrome.print(self:statusTag(enemy) or ("" .. tostring(enemy.level or 1)), - 6, 1) + Chrome.print(self:statusTag(enemy, "enemy") + or ("" .. tostring(enemy.level or 1)), 6, 1) local enemyGender = self:genderSymbol(enemy) if enemyGender then Chrome.print(enemyGender, 9, 1) end -- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at -- (1,1) (engine/battle/trainer_huds.asm:140-152). - if self.battle and self.battle.wild and self:dexCaught(enemy) then + if self.battle and self.battle.wild and self.caughtMark then self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"), enemy.maxHp or (enemy.stats and enemy.stats.hp)) end @@ -3249,6 +3329,14 @@ function BattleState:drawHud() self:drawFrame(1, 3, 10, false) end end + -- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls + -- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2). + if showStatus and self.ballRows.enemy then + if not self.showEnemyHud and self.hud:available() then + self.hud:drawEnemyFrame() + end + self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1) + end self:drawPic(enemy, false) @@ -3257,6 +3345,14 @@ function BattleState:drawHud() -- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the -- border from (18,10) going left, and the exp bar at (10,11). self:drawPic(player, true) + -- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls + -- walking RIGHT from OAM (96, 96), i.e. tile (11, 10). + if showStatus and self.ballRows.player then + if not self.showPlayerHud and self.hud:available() then + self.hud:drawPartyIconFrame() + end + self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1) + end -- No player HUD in the catching tutorial: DrawPlayerHUD lives in -- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and -- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's @@ -3270,7 +3366,7 @@ function BattleState:drawHud() Chrome.print(self:name(player), 10, 7) -- PrintPlayerHUD places the same status tag at (14,8) and skips the level -- while it is up. - Chrome.print(self:statusTag(player) + Chrome.print(self:statusTag(player, "player") or ("" .. tostring(self.shownLevel or player.level or 1)), 14, 8) local playerGender = self:genderSymbol(player) if playerGender then Chrome.print(playerGender, 17, 8) end @@ -3303,8 +3399,8 @@ local STATUS_TAGS = { paralyze = "PAR", sleep = "SLP", } -function BattleState:statusTag(mon) - return mon and STATUS_TAGS[mon.status] or nil +function BattleState:statusTag(mon, side) + return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil end -- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 8271c4fc..b019a12b 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" } -- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows. local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" } +-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240). +local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" } + function BoxMenu:submenuRows() if self.mode == "move" then return MOVE_SUBMENU end + if self.mode == "deposit" then return DEPOSIT_SUBMENU end return WITHDRAW_SUBMENU end @@ -219,21 +223,12 @@ function BoxMenu:act() if self.onClose then self.onClose() end return end - -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu. - if self.mode == "move" or self.mode == "withdraw" then - if not self:selected() then return end - self.phase = "submenu" - -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE. - self.submenuIndex = 1 - return - end - local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) - if not ok then - self.message = result - return - end - self.message = nil - self:clampIndex() + -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu, + -- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102). + if not self:selected() then return end + self.phase = "submenu" + -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row. + self.submenuIndex = 1 end -- ------------------------------------------------------------ MOVE, step 2 @@ -243,8 +238,9 @@ end -- the PARTY, so a boxed mon walks straight past. Returns true, or false and -- the string the cart places. function BoxMenu:checkMailPreventBlackout() - -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`. - if not self:isParty() then return true end + -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes + -- that byte too, so its list is the party (bills_pc.asm:17-18). + if not (self:isParty() or self.mode == "deposit") then return true end local party = self.save.party or {} -- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send -- one away at all, however healthy the rest of it is. @@ -311,10 +307,25 @@ function BoxMenu:doWithdraw() self:clampIndex() end +-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit +function BoxMenu:doDeposit() + local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) + if not ok then + self.message = result + return + end + self.message = nil + self.phase = nil + self.index, self.scroll = 1, 0 + self:clampIndex() +end + function BoxMenu:chooseSubmenu() local row = self:submenuRows()[self.submenuIndex] if row == "MOVE" then self:beginMove() + elseif row == "DEPOSIT" then + self:doDeposit() elseif row == "WITHDRAW" then self:doWithdraw() elseif row == "STATS" then @@ -393,6 +404,9 @@ function BoxMenu:insertMon() target = target - 1 end table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) + -- .CopyToBox is InsertPokemonIntoBox, which tails into + -- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37). + if not self:isParty(destIndex) then Boxes.restorePP(mon) end self.phase = nil self.moveFrom, self.backup = nil, nil self.index, self.scroll = 1, 0 @@ -521,6 +535,16 @@ function BoxMenu:askRelease() if self:isCancel() then return end local mon = self:selected() if not mon then return end + -- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check + -- (engine/pokemon/bills_pc.asm:183-187). + if self.mode == "deposit" then + local allowed, refusal = self:checkMailPreventBlackout() + if not allowed then + self.phase = nil + self.message = refusal + return + end + end -- Both release paths run BillsPC_IsMonAnEgg first, so the question is never -- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428). if mon.isEgg then @@ -534,7 +558,12 @@ function BoxMenu:askRelease() local name = mon.nickname or mon.name or mon.species or "?" game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end - local ok, err = Boxes.release(self.save, self.boxIndex, self.index) + local ok, err + if self.mode == "deposit" then + ok, err = Boxes.releaseFromParty(self.save, self.index) + else + ok, err = Boxes.release(self.save, self.boxIndex, self.index) + end if not ok then self.message = err return diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 2ad76be9..3984f95e 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -518,8 +518,9 @@ function ItemPcMenu:drawList() local entry = self.rows[i] if i == self.listIndex then Chrome.cursor(5, ty) end Chrome.print(entry.name, 6, ty) - -- PlaceMenuItemQuantity: the xNN is the entry's second line. - Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1) + -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the + -- entry's second line, right-aligned in a blank-padded 2-digit field. + Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) elseif i == self:listTotal() then if i == self.listIndex then Chrome.cursor(5, ty) end Chrome.print("CANCEL", 6, ty) diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index ce543a7a..dc920687 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -12,9 +12,9 @@ -- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18 -- vertical strip repeated across all 32 columns, and MagnetTrainTilemap, -- the 20x4 train laid over rows 6-9) come from the extracted cache at --- data.field.magnetTrain. A cache built before the extractor learned to --- follow them has neither, and then the ride runs with a blank screen --- rather than with invented art. +-- data.gen2Field.magnetTrain. A cache built before the extractor +-- learned to follow them has neither, and then the ride runs with a +-- blank screen rather than with invented art. -- * The background is baked into a 256x144 canvas once, because the only -- thing that changes per frame is the per-band SCX. -- * SetMagnetTrainPals gives the four bush rows and the four bottom rows @@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts) self.onDone = opts.onDone self.finished = false - local field = self.data and self.data.field + local field = self.data and self.data.gen2Field local gfx = field and field.magnetTrain self.ride = MagnetTrain.new({ toGoldenrod = opts.toGoldenrod, @@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts) fgTilemap = gfx and gfx.tilemap, }) - self.tileset = self.data and self.data.tilesets - and self.data.tilesets.TILESET_TRAIN_STATION + self.tileset = self.data and self.data.gen2Tilesets + and self.data.gen2Tilesets.TILESET_TRAIN_STATION self.palettes = self:bgPalettes() self.spriteSheet = self:playerSheet() @@ -84,7 +84,7 @@ end -- even though both stations are INDOOR maps. function MagnetTrainRide:bgPalettes() local data = self.data - local palettes = data and data.palettes + local palettes = data and data.gen2Palettes if not palettes then return nil end return Palettes.bgSet(palettes, { environment = "TOWN" }, Palettes.clockDaytime()) @@ -129,7 +129,7 @@ end -- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because -- the OAM data addresses the four tiles of a frame individually. function MagnetTrainRide:playerSheet() - local sprites = self.data and self.data.sprites + local sprites = self.data and self.data.gen2Sprites local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS) local path = def and def.image if not path then return nil end @@ -259,8 +259,8 @@ end -- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry -- names. function MagnetTrainRide:playerPalette() - local palettes = self.data and self.data.palettes - local sprites = self.data and self.data.sprites + local palettes = self.data and self.data.gen2Palettes + local sprites = self.data and self.data.gen2Sprites if not palettes then return nil end return Palettes.spritePalette(palettes, Palettes.clockDaytime(), sprites and sprites.SPRITE_CHRIS) diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 0b200f81..e0e3c507 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -211,6 +211,15 @@ local ROWS = { TC.buzz(options.haptics) if game and game.persistOptions then game:persistOptions() end end }, + { label = "MAX FPS", key = "fpsCap", port = true, + cycle = function(options, delta) + local FrameCap = require("src.core.FrameCap") + options.fpsCap = FrameCap.cycle(options.fpsCap, delta) + FrameCap.apply(options.fpsCap) + end, + text = function(options) + return require("src.core.FrameCap").label(options.fpsCap) + end }, { label = "CANCEL", cancel = true }, } diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index c5f50485..d1e3c2d7 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -67,6 +67,10 @@ local SUBMENU_LABEL = { -- (data/text/common_2.asm), the three lines TossMenu prints in order. local TOSS_HOW_MANY = { "Throw away how", "many?" } +-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem +-- holds a row and the cursor is looking for its new home. +local ASK_ITEM_MOVE = { "Where should this", "be moved to?" } + -- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals. local NO_POKEMON = { "You don't have a", "#MON!" } local EGG_CANT_HOLD = { "An EGG can't hold", "an item." } @@ -127,6 +131,9 @@ function PackMenu.new(game, opts) self.game = game self.save = opts.save or (game and game.save) self.items = opts.items or (game and game.data and game.data.items) + -- Bag.order / Bag.move read `.items` off an injectable data table, and the + -- one this screen draws from is not always the Data singleton. + self.bagData = { items = self.items } self.world = opts.world or (game and game.world) self.onChoose = opts.onChoose self.onClose = opts.onClose @@ -207,7 +214,13 @@ end function PackMenu:rebuild() local pocket = self:pocket().id local rows = {} - for itemId, raw in pairs((self.save and self.save.inventory) or {}) do + local save = self.save + local inventory = (save and save.inventory) or {} + -- Row order IS wBagItems' order, which is what SELECT rewrites. + local order = (save and save.inventory) + and Bag.order(save, self.bagData) or {} + for _, itemId in ipairs(order) do + local raw = inventory[itemId] -- A count that is not a number at all (a hand-written save, a mod, an old -- migration) counts as one rather than raising out of the draw. local count = tonumber(raw) or (raw and 1) or 0 @@ -219,18 +232,13 @@ function PackMenu:rebuild() name = PackMenu.label(itemId, def), teaches = self:moveLabel(def and def.teaches), tmNumber = def and def.tmNumber, - -- KEY_ITEM and TM_HM rows do not show a quantity on the cart. - showCount = pocket == "ITEM" or pocket == "BALL", - index = def and def.index or math.huge, + -- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the + -- count for an HM only -- a TM prints ×NN like any other stack. + showCount = pocket == "ITEM" or pocket == "BALL" + or (pocket == "TM_HM" and tostring(itemId):sub(1, 3) ~= "HM_"), } end end - -- Bag order on the cart is acquisition order; without that recorded, item id - -- order is the stable, reproducible choice. - table.sort(rows, function(a, b) - if a.index ~= b.index then return a.index < b.index end - return a.id < b.id - end) self.rows = rows self.index = math.min(self.index, #rows + 1) if self.index < 1 then self.index = 1 end @@ -671,6 +679,11 @@ function PackMenu:update(_dt) self:updateQuantity(input) return end + -- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item + if self.switching then + self:updateSwitch(input) + return + end if self.message then if input:wasPressed("a") or input:wasPressed("b") then self.message = nil @@ -716,11 +729,52 @@ function PackMenu:update(_dt) end return elseif input:wasPressed("select") then - self:registerSelected() + self:armSwitch() return end end +-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select +function PackMenu:armSwitch() + if self:isCancel() then return end + if not self.rows[self.index] then return end + self.switching = self.index + self.message = ASK_ITEM_MOVE +end + +-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs +-- out, and left/right cannot leave the pocket mid-move. +function PackMenu:updateSwitch(input) + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or self:total() + self:ensureVisible() + elseif input:wasPressed("down") then + self.index = self.index < self:total() and self.index + 1 or 1 + self:ensureVisible() + elseif input:wasPressed("a") or input:wasPressed("select") then + self:placeSwitch() + elseif input:wasPressed("b") then + self:endSwitch() + end +end + +-- engine/items/pack.asm:1307 .place_insert / .end_switch +function PackMenu:placeSwitch() + local from = self.switching + local row = self.rows[from] + if row and not self:isCancel() and self.index ~= from then + Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData) + self:rebuild() + self:storeCursor() + end + self:endSwitch() +end + +function PackMenu:endSwitch() + self.switching = nil + self.message = nil +end + -- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry -- that ExitMenu answers with (`ret c`), which is QUIT by another name. function PackMenu:updateSubmenu(input) @@ -777,12 +831,10 @@ function PackMenu:updateConfirm(input) end end --- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the --- highlighted row reaches the same routine: the cart's SELECT is the bag's own --- item shuffle, which this port does not have, so the button is free and a --- player who knows Gen 1's registration shortcut gets it. World:registerItem --- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F --- refuses), so neither door can register what the cart would not. +-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY +-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch). +-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything +-- CANT_SELECT_F refuses), so it cannot register what the cart would not. function PackMenu:registerSelected() if self:isCancel() then return end local row = self.rows[self.index] @@ -828,22 +880,38 @@ end -- The list, description and cursor, on top of whatever chrome was drawn. -- --- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN --- and one column RIGHT of the name -- the quantity is the entry's second line, --- not a right-aligned column, which is why every PACK row is two tiles tall. +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row +-- DOWN and one column RIGHT of the name -- the quantity is the entry's second +-- line, not a right-aligned column, which is why every PACK row is two tiles +-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked, +-- so the ones digit sits at name + 3 whether the count is 5 or 50. +-- +-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the +-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. function PackMenu:drawList(listX, listY) for row = 1, VISIBLE_ROWS do local i = row + self.scroll local ty = listY + (row - 1) * LIST_SPACING if i <= #self.rows then local entry = self.rows[i] - if i == self.index then Chrome.cursor(listX - 1, ty) end + if i == self.index then + Chrome.cursor(listX - 1, ty) + elseif i == self.switching then + Chrome.cursor(listX - 1, ty, true) + end Chrome.print(entry.name, listX, ty) if entry.teaches then - -- The TM pocket puts the move the TM teaches on that second line. + -- The TM pocket puts the move the TM teaches on that second line, and + -- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's + -- line here, since the move name owns the one below it. Chrome.print(entry.teaches, listX + 1, ty + 1) + if entry.showCount then + Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 9, ty) + end elseif entry.showCount then - Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1) + Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 1, ty + 1) end elseif i == self:total() then if i == self.index then Chrome.cursor(listX - 1, ty) end @@ -897,12 +965,10 @@ function PackMenu:drawSubmenu() end end --- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1` --- with NoPriceToDisplay behind it: a small box in the bottom right holding --- nothing but the count. +-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay function PackMenu:drawQuantity() Chrome.box(15, 9, 5, 3) - Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10) + Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10) end -- YesNoBox's own coords, the same box every other Gen 2 screen here draws. diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 13a508d3..107c05a7 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") +local ItemEffects = require("src.core.gen2.ItemEffects") local Logger = require("src.core.Logger") local Mail = require("src.core.gen2.Mail") local Mon = require("src.battle.gen2.Mon") @@ -668,17 +669,12 @@ end -- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon -- with no HP reads FNT whatever its status byte says. -local STATUS_STRING = { - slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR", - poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR", - sleep = "SLP", toxic = "PSN", -} - local function statusString(mon) if (mon.hp or 0) <= 0 then return "FNT" end local status = mon.status if not status then return nil end - return STATUS_STRING[tostring(status):lower()] + local class = ItemEffects.STATUS_CLASS[tostring(status):lower()] + return class and class:upper() end -- One list row's strings, exactly what WritePartyMenuTilemap's quality diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index 365481b1..06c724ad 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -24,6 +24,8 @@ local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") local Mail = require("src.core.gen2.Mail") local Runtime = require("src.mods.Runtime") +local Save = require("src.core.gen2.Save") +local SaveMenu = require("src.ui.gen2.SaveMenu") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -36,6 +38,13 @@ local MON_HOLDING_MAIL = { Strings.source("Please remove the\nMAIL."), } +-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont` +-- has already scrolled by the time YesNoBox goes up over its last two. +local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" } + +-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383). +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + local PcMenu = {} PcMenu.__index = PcMenu PcMenu.isOpaque = true @@ -90,6 +99,13 @@ function PcMenu.new(game, opts) self.onClose = opts.onClose self.house = opts.house and true or false self.events = opts.events + -- The same route the start menu's SAVE row takes (src/core/Game2.lua:435), + -- so the save.write veto and the save.writing event fire here too. + self.writer = opts.writer + or (game and type(game.writeSave) == "function" + and function() return game:writeSave() end) + or Save.save + self.saveExists = opts.saveExists -- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu -- reaches that through 's PC (src/ui/gen2/ItemPcMenu.lua), so -- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the @@ -156,6 +172,91 @@ function PcMenu:close() if self.onClose then self.onClose(self.changedDecorations) end end +-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch +-- engine/menus/save.asm:40 ChangeBoxSaveGame +function PcMenu:beginChangeBox(index) + self.changeBox = index + self.savePhase = "confirm" + self.saveChoice = 1 + self.saveTimer = 0 + self.saved = nil + local existed = self.saveExists + if existed == nil then existed = Save.exists("gold") end + self.existed = existed +end + +-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up. +function PcMenu:refuseChangeBox() + self.savePhase, self.changeBox = nil, nil + self.saveTimer = 0 +end + +function PcMenu:acceptChangeBox() + if self.saveChoice == 2 then return self:refuseChangeBox() end + if self.savePhase == "confirm" and self.existed then + self.savePhase = "overwrite" + self.saveChoice = 1 + return + end + self.savePhase = "saving" + self.saveTimer = 0 +end + +-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and +-- SavingDontTurnOffThePower, so the new index rides the file that is written. +function PcMenu:writeChangeBox() + Boxes.setCurrent(self.save, self.changeBox) + local ok = self.writer(self.save) + self.saved = ok and true or false + if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end +end + +function PcMenu:savePrompt() + if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end + if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end + if self.savePhase == "done" then + if self.saved then + local name = (self.save.player and self.save.player.name) or "GOLD" + return { name .. " saved", "the game." } + end + return { "Could not save.", "" } + end + return CHANGE_BOX_SAVE +end + +function PcMenu:updateChangeBox() + -- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does + -- anything until the sequence runs out (engine/menus/save.asm:55). + if self.savePhase == "saving" then + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVING_FRAMES then + self:writeChangeBox() + self.savePhase = "done" + self.saveTimer = 0 + end + return + end + if self.savePhase == "done" then + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVED_FRAMES then + self.savePhase, self.changeBox = nil, nil + self.picking = false + end + return + end + + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") or input:wasPressed("down") then + self.saveChoice = self.saveChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + self:acceptChangeBox() + elseif input:wasPressed("b") then + -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). + self:refuseChangeBox() + end +end + function PcMenu:choose() local entry = self.entries[self.index] if not entry then return end @@ -231,6 +332,11 @@ function PcMenu:update(_dt) return end + if self.savePhase then + self:updateChangeBox() + return + end + if self.picking then local total = Boxes.NUM_BOXES if input:wasPressed("up") then @@ -238,8 +344,11 @@ function PcMenu:update(_dt) elseif input:wasPressed("down") then self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 elseif input:wasPressed("a") then - Boxes.setCurrent(self.save, self.pickIndex) - self.picking = false + if self.pickIndex == (self.save.currentBox or 1) then + self.picking = false + else + self:beginChangeBox(self.pickIndex) + end elseif input:wasPressed("b") then self.picking = false end @@ -287,6 +396,22 @@ function PcMenu:drawPanel() ("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX), 18, ty) end + if self.savePhase then + -- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list. + Chrome.box(0, 12, 20, 6) + local lines = self:savePrompt() + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) + if self.savePhase == "confirm" or self.savePhase == "overwrite" then + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.saveChoice == 1 and 1 or 3)) + end + love.graphics.setColor(1, 1, 1, 1) + return + end Chrome.box(0, 14, 20, 4) Chrome.print("Which BOX?", 1, 16) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 30bbea33..a7cb56d0 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -2096,10 +2096,23 @@ function Pokegear:mapCursorSprite(x, y) Chrome.cursor(math.floor(x / 8), math.floor(y / 8)) end +-- PokegearRadio_Init's tile $08 at `depixel 4, 10, 4, 4`, three rows deep +-- (data/sprite_anims/oam.asm:588), x = knob (pokegear.asm:1355). +function Pokegear:drawTuningKnob() + self:loadArrowSheet() + if not (self.arrow and self.arrow:available()) then return end + local row = self:currentStation() + local tx = (72 + (row and row.knob or 0)) / 8 + self.arrow:draw(0x08, tx, 1) + self.arrow:draw(0x08, tx, 2) + self.arrow:draw(0x08, tx, 3) +end + function Pokegear:drawRadio() self:ensureTuned() self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio) self:drawStrip() + self:drawTuningKnob() local station = self:currentStation() -- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air -- prints nothing: NoRadioStation clears the box and leaves it clear. diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 3c8868c7..bd745d63 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -54,6 +54,11 @@ local TIME_X, TIME_Y = 13, 8 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 +-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and +-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save. +local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } +local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } + function SaveMenu:wantsFillScale() return true end function SaveMenu:drawsWidescreen() return true end @@ -75,14 +80,18 @@ function SaveMenu.new(game, opts) return self end -function SaveMenu:playSfx(id) - local data = self.game and self.game.data +function SaveMenu.playSaveSfx(game, id) + local data = game and game.data local audio = data and data.audio if not (audio and audio.sfxOrder) then return end local name = audio.sfxOrder[id + 1] if name and audio.sfx and audio.sfx[name] then Sound.play(data, name) end end +function SaveMenu:playSfx(id) + SaveMenu.playSaveSfx(self.game, id) +end + function SaveMenu:finish(saved) if self.onDone then self.onDone(saved) end end @@ -162,10 +171,10 @@ function SaveMenu:prompt() if self.phase == "overwrite" then -- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText -- when the ID differs. Only the first can happen here. - return { "There is already a", "save file. Is it" } + return OVERWRITE_PROMPT end if self.phase == "saving" then - return { "SAVING… DON'T TURN", "OFF THE POWER." } + return SAVING_PROMPT end if self.phase == "done" then if self.saved then @@ -226,4 +235,10 @@ function SaveMenu:drawWidescreen(winW, winH) G.pop() end +SaveMenu.SFX_SAVE = SFX_SAVE +SaveMenu.SAVING_FRAMES = SAVING_FRAMES +SaveMenu.SAVED_FRAMES = SAVED_FRAMES +SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT +SaveMenu.SAVING_PROMPT = SAVING_PROMPT + return SaveMenu diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 52f9377f..7f4dc4ee 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -63,6 +63,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") +local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") local Palettes = require("src.world.gen2.Palettes") local Pokerus = require("src.core.gen2.Pokerus") @@ -109,15 +110,6 @@ local TYPE_NAMES = { CURSE_TYPE = "???", } --- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon --- with no HP reads FNT whatever its status byte says. Same table the party --- list uses; both screens call the same routine on the cart. -local STATUS_STRING = { - slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR", - poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR", - sleep = "SLP", toxic = "PSN", -} - -- Gen 2 pics are 5x5, 6x6 or 7x7 and PadFrontpic centres the small ones in -- the 7x7 block PrepMonFrontpic lays at hlcoord 0, 0. Same table the dex -- uses, for the same reason. @@ -208,11 +200,15 @@ local function levelText(level) return "" .. tostring(level) end +-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon +-- with no HP reads FNT whatever its status byte says. Same lookup the party +-- list makes; both screens call the same routine on the cart. local function statusText(mon) if (mon.hp or 0) <= 0 then return "FNT" end local status = mon.status if not status then return nil end - return STATUS_STRING[tostring(status):lower()] + local class = ItemEffects.STATUS_CLASS[tostring(status):lower()] + return class and class:upper() end -- wTempMonPokerusStatus is one byte: the low nibble counts the days left and diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 0c926faf..ef29b7fd 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -105,8 +105,16 @@ function NPC:update(map, entities) end end +-- UpdateNPCSprite branches to NotYetMoving while BIT_FONT_LOADED is set +-- -- engine/overworld/movement.asm:139 +local function textBoxUp() + local stack = require("src.core.Game").stack + local top = stack and stack.top and stack:top() + return top ~= nil and not top.isOverworld +end + function NPC:walkPhase() - if not self.moving then return 0 end + if not self.moving or textBoxUp() then return 0 end -- engine/overworld/movement.asm:301 local p = (self.animClock or 0) % 16 return (p >= 4 and p < 12) and 1 or 0 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 52374154..bb0a7f3a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -20,6 +20,7 @@ local Player = require("src.world.Player") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") +local Theme = require("src.ui.Theme") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -1109,6 +1110,13 @@ function OverworldState:update(dt) end end + -- EnterMapAnim's .done tail re-enables the companion once the swoop or the + -- spin-down has landed (player_animations.asm:40) + if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive + or self.teleportOut or self.transitioning or self.player.spinning) then + self:showPikachuAfterWarp() + end + -- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite -- spins UP out of the map before the fade (player_animations.asm -- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the @@ -1190,7 +1198,9 @@ function OverworldState:update(dt) or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive end - if not scripted and not self.transitioning then + -- a scriptMove's onDone can push a text box on the frame it retires, and + -- DisplayTextID owns the loop from there (home/text_script.asm:3) + if not scripted and not self.transitioning and Game.stack:top() == self then self:handleInput() end @@ -1716,6 +1726,28 @@ function OverworldState:syncSurfingPikachu() p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false end +-- _LeaveMapAnim drops the companion's sprite before the animation starts and +-- EnterMapAnim only puts it back once landed -- home/pikachu.asm:1, :10 +function OverworldState:hidePikachuForWarp() + self.pikachuWarpHidden = true + require("src.world.PikachuFollower").setVisible(self, false) +end + +function OverworldState:showPikachuAfterWarp() + self.pikachuWarpHidden = nil + self.pikachuTrail = { x = self.player.cellX, y = self.player.cellY } + local Follower = require("src.world.PikachuFollower") + local npc = Follower.current(self) + if npc then + npc.cellX, npc.cellY = self.player.cellX, self.player.cellY + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.targetX, npc.targetY = nil, nil + npc.goalX, npc.goalY = nil, nil + npc.moving = false + end + Follower.setVisible(self, true) +end + -- The rejection loop shared by the Good and Super Rods -- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an -- odd random byte is no bite; otherwise a 2-bit pick rerolls until it @@ -1822,6 +1854,7 @@ function OverworldState:flyTo(mapId) Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE self.player.surfing = false self:syncSurfingPikachu() + self:hidePikachuForWarp() -- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3), -- then SFX_FLY and the up-right path, a 40-frame beat off screen, and -- the exit over the top-left -- the warp fades only once the bird is @@ -1852,6 +1885,7 @@ function OverworldState:beginTeleportOut(onDone) require("src.core.Sound").play(Game.data, "Teleport_Exit1") self.player.surfing = false self:syncSurfingPikachu() + self:hidePikachuForWarp() self.player.inputLocked = true -- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so -- spinRise lifts the sprite (Player:pose) while spinFrames counts down @@ -3045,6 +3079,7 @@ function OverworldState:nurseHeal(onDone, npc) end -- Yellow's companion has its own beat threaded through this sequence local Follower = require("src.world.PikachuFollower") + -- YesNoChoicePokeCenter draws HEAL/CANCEL, not YES/NO (home/yes_no.asm:21) Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes) if not yes then Game.stack:push(TextBox.new(Game, bye, onDone)) @@ -3093,7 +3128,7 @@ function OverworldState:nurseHeal(onDone, npc) end end)) end) - end })) + end, choiceLabels = { "HEAL", "CANCEL" }, choiceBox = Theme.healCancelBox })) end -- pokecenter.asm bows the nurse between the two PrintText calls (#995) @@ -4526,6 +4561,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- down, so the player is never drawable mid-fade nor standing bare on the -- landing frame (#916) self.playerHidden = false + -- setMap respawned a follower under the player; _LeaveMapAnim's Func_1510 + -- suppression runs until EnterMapAnim lands (home/pikachu.asm:1) + if self.pikachuWarpHidden then + require("src.world.PikachuFollower").setVisible(self, false) + end -- The warp we land ON stays inert for the completed-step check until we -- physically step off it, so a warp whose destination cell is itself a -- warp cannot bounce us straight back (elevator cars, stacked stair/door diff --git a/src/world/Player.lua b/src/world/Player.lua index 65406b85..54727a7b 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -226,7 +226,16 @@ function Player:facingCell() return Collision.target(self.cellX, self.cellY, self.facing) end +-- UpdatePlayerSprite jumps to .notMoving while BIT_FONT_LOADED is set +-- -- engine/overworld/movement.asm:57 +local function textBoxUp() + local stack = require("src.core.Game").stack + local top = stack and stack.top and stack:top() + return top ~= nil and not top.isOverworld +end + function Player:walkPhase() + if textBoxUp() then return 0 end -- moving, the land-frame after a completed step, or an active wall-bonk -- (issue #230) animate; a standing sprite otherwise if not self.moving and not self.stepLanded diff --git a/src/world/gen2/MapPreview.lua b/src/world/gen2/MapPreview.lua index d02411ed..9defa1c5 100644 --- a/src/world/gen2/MapPreview.lua +++ b/src/world/gen2/MapPreview.lua @@ -10,10 +10,10 @@ local PixelCanvas = require("src.render.PixelCanvas") local MapPreview = {} +-- home/map.asm:1739-1748 LoadTilesetGFX local ROOF_TILESETS = { TILESET_JOHTO = true, TILESET_JOHTO_MODERN = true, - TILESET_KANTO = true, } local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 0e0518d5..911ac65b 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -117,6 +117,15 @@ local VAR = { SPECIALPHONECALL = 0x14, } +-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row: +-- nothing writes it, and FieldMoves has no string for it either. +local PLAYER_STATE_BY_ID = { + [0] = FieldMoves.PLAYER_NORMAL, + [1] = FieldMoves.PLAYER_BIKE, + [4] = FieldMoves.PLAYER_SURF, + [8] = FieldMoves.PLAYER_SURF_PIKA, +} + local BATTLETYPE = { CANLOSE = 1, FORCESHINY = 7, @@ -5207,6 +5216,7 @@ function World:dropMapImages(mapId) if key:sub(1, #prefix) == prefix then store[key] = nil end end end + if self.connectionMaps then self.connectionMaps[mapId] = nil end end -- LoadMapAttributes' refill, for every map the session has edited. Neighbour @@ -7480,13 +7490,10 @@ function World:zoomScale() return Zoom.scale(self:fitScale()) end --- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof --- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets --- in the same map group (lab, houses) corrupts their GFX. +-- home/map.asm:1739 LoadTilesetGFX local ROOF_TILESETS = { TILESET_JOHTO = true, TILESET_JOHTO_MODERN = true, - TILESET_KANTO = true, } function World:atlasFor(mapDef) @@ -8724,6 +8731,44 @@ function World:spawnFacing() end end +-- A neighbour map, built once and kept for its collision alone: the seam +-- queries below run on the per-step path. +function World:connectionMap(mapId) + self.connectionMaps = self.connectionMaps or {} + local cached = self.connectionMaps[mapId] + if cached ~= nil then return cached or nil end + local def = self.maps[mapId] + local tileset = def and self.tilesets[def.tileset] + local map = (def and tileset) and Map.new(def, tileset) or false + self.connectionMaps[mapId] = map + return map or nil +end + +-- home/map.asm:1908 GetMovementPermissions +function World:cellCollisionAcross(map, cx, cy) + if map:inBounds(cx, cy) then return map:cellCollision(cx, cy) end + local dir + if cy < 0 then dir = "up" + elseif cy >= map.heightCells then dir = "down" + elseif cx < 0 then dir = "left" + elseif cx >= map.widthCells then dir = "right" + end + local conn = dir and map:connection(DIR_CONN[dir]) + local dest = conn and conn.mapId and self.maps[conn.mapId] + local destMap = dest and self:connectionMap(conn.mapId) + if destMap then + local x, y = Map.connectionLanding(dest, conn, dir, cx, cy) + local vertical = dir == "up" or dir == "down" + local want = (vertical and cx or cy) - (conn.offset or 0) * 2 + -- connectionLanding clamps into the destination; past the end of the strip + -- the buffer still holds this map's own border block + if x and (vertical and x or y) == want then + return destMap:cellCollision(x, y) + end + end + return map:cellCollision(cx, cy) +end + -- Seamless edge cross: swap map data, park the player one cell before the -- landing (same world pixels the neighbor strip already showed), and keep -- the step running so the seam does not hitch. @@ -8736,7 +8781,13 @@ function World:tryConnection(dir) local x, y = Map.connectionLanding( dest, conn, dir, self.player.cellX, self.player.cellY) if not x then return false end - local destMap = Map.new(dest, self.tilesets[dest.tileset]) + local destMap = self:connectionMap(conn.mapId) + if not destMap then return false end + -- home/map.asm:1946 GetMovementPermissions side-wall arm + if dir == "down" + and Permissions.neighborBlocksDown(dir, destMap:cellCollision(x, y)) then + return false + end -- A surfing crossing lands on water, which isWalkable refuses; the arm the -- step would have taken is what decides, the same as it does inside the map. local landable @@ -8869,7 +8920,8 @@ function World:movePlayer(dir) -- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the -- veto applies to walking and surfing alike. local permitted = Permissions.stepPermitted( - function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir) + function(x, y) return self:cellCollisionAcross(map, x, y) end, + p.cellX, p.cellY, dir) -- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and -- Player:tryMove's entity scan only ever compares the one cell an object -- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed diff --git a/tests/drivers/fly_pikachu_bug1400_test.lua b/tests/drivers/fly_pikachu_bug1400_test.lua new file mode 100644 index 00000000..319decb9 --- /dev/null +++ b/tests/drivers/fly_pikachu_bug1400_test.lua @@ -0,0 +1,112 @@ +-- Driver: Fly / Teleport must take Pikachu with it (#1400). _LeaveMapAnim +-- drops the companion sprite before the bird flaps (home/pikachu.asm:1) and +-- EnterMapAnim only puts it back once the swoop or the spin has landed, so +-- Pikachu is invisible for the whole sequence and never stands on the landing +-- cell ahead of the player. +-- +-- POKEPORT_DRIVER=tests/drivers/fly_pikachu_bug1400_test.lua \ +-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- +-- Never add POKEPORT_SPEED; the run needs an imported Yellow cache. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local GameVersion = require("src.core.GameVersion") + local PF = require("src.world.PikachuFollower") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running as Yellow (needs POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + -- DisablePikachuOverworldSpriteDrawing is what keeps it in the ball + -- (pokeyellow scripts/OaksLab.asm); out of the ball is what follows (#1009) + game.save.pikachuInBall = false + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.onBike = false + + local function drawn() + local ow = game.overworld + local npc = ow and PF.current(ow) + if not npc then return false end + for _, e in ipairs(ow.entities or {}) do + if e == npc then return true end + end + return false + end + + U.teleport(game, "ROUTE_17", 4, 10, "down") + U.wait(30) + local ow = game.stack:top() + check("Pikachu is out and drawn before the flight", drawn()) + U.shot(game, DIR .. "/bug1400_0_before.png") + + ow:flyTo("PALLET_TOWN") + U.wait(12) -- mid in-place flap + check("hidden during the departure flap", not drawn()) + U.shot(game, DIR .. "/bug1400_1_flap.png") + + U.wait(60) -- the bird's path out + check("still hidden while the bird flies off", not drawn()) + + local guard = 0 + while ow.map.id == "ROUTE_17" and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + guard = 0 + while not ow.flyArrive and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + U.wait(12) -- mid swoop, the frame the old bug showed Pikachu already landed + check("hidden through the arrival swoop", not drawn()) + U.shot(game, DIR .. "/bug1400_2_arrive.png") + + guard = 0 + while ow.flyArrive and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + U.wait(10) + check("back out once the bird has landed", drawn()) + do + local npc = PF.current(ow) + local p = ow.player + check("and it comes back on the player's own cell", + npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY) + end + U.shot(game, DIR .. "/bug1400_3_landed.png") + + -- Dig / Teleport / Escape Rope take the same _LeaveMapAnim path + game.save.lastHeal = { map = "VIRIDIAN_CITY", x = 23, y = 26 } + ow:beginTeleportOut() + U.wait(20) + check("hidden during the teleport-out spin", not drawn()) + U.shot(game, DIR .. "/bug1400_4_spin_out.png") + + guard = 0 + while (ow.teleportOut or ow.player.spinning or ow.transitioning) + and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + U.wait(10) + check("back out once the arrival spin has landed", drawn()) + U.shot(game, DIR .. "/bug1400_5_spin_in.png") + + U.log("Watch the shots in order: Pikachu stands beside the player before") + U.log("the flight, is nowhere on screen for the flap, the fade and the") + U.log("swoop, and only reappears under him once the bird sets him down.") + U.log("A Pikachu standing on the landing cell during the swoop is the bug.") + U.log("The pad is yours -- fly around and watch the departures.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_bug1401_test.lua b/tests/drivers/gold_bug1401_test.lua new file mode 100644 index 00000000..166bcd88 --- /dev/null +++ b/tests/drivers/gold_bug1401_test.lua @@ -0,0 +1,99 @@ +-- #1401: $d9/$da battlergfx loaded the wrong row count +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1401_test.lua love . + +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +-- data/moves/animations.asm:1840 Growl, :3605 Icy Wind +local CASES = { + { move = "GROWL", cmd = "battlergfx_2row", rows = 1, head = 7, feet = 6 }, + { move = "ICY_WIND", cmd = "battlergfx_1row", rows = 2, head = 14, feet = 12 }, +} + +local function sheet(runner, gfx) + for _, entry in ipairs(runner.loaded or {}) do + if entry.gfx == gfx then return entry end + end + return nil +end + +local function liftedStrip(runner) + local structs = runner.objects and runner.objects.structs or {} + for slot = 1, #structs do + local id = structs[slot].objectId + if type(id) == "string" and id:match("PLAYERHEAD") then return id end + if type(id) == "string" and id:match("ENEMYFEET") then return id end + end + return nil +end + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 30) }), + "startBattle failed") + + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle and battle.battle, "battle screen is not on the stack") + assert(battle.anims and battle.anims.scripts, + "battle_anims.lua has no scripts -- re-import Gold") + + local failed = false + for _, case in ipairs(CASES) do + battle.anim = nil + if not battle:animForMove(case.move, "player") then + U.log("FAIL no animation for", case.move) + failed = true + else + local head, feet + for _ = 1, 400 do + local runner = battle.anim + if not runner then break end + head = head or sheet(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + feet = feet or sheet(runner, "BATTLE_ANIM_GFX_ENEMYFEET") + U.wait(1) + end + if not (head and feet) then + U.log("FAIL", case.move, "never loaded the battler sheets") + failed = true + else + U.log(("%-9s %-16s rows=%d head=%d feet=%d (want rows=%d head=%d feet=%d)") + :format(case.move, case.cmd, head.rows, head.tiles, feet.tiles, + case.rows, case.head, case.feet)) + if head.rows ~= case.rows or head.tiles ~= case.head + or feet.tiles ~= case.feet then + U.log("FAIL", case.move, "loaded the wrong row count") + failed = true + end + end + end + end + + U.log(failed and "FAIL #1401" or "PASS #1401 battlergfx rows follow the jumptable") + + U.log("look: Growl lifts ONE row of the Cyndaquil's own tiles, not two") + battle.anim = nil + battle:animForMove("GROWL", "player") + for _ = 1, 400 do + if not battle.anim then break end + local strip = liftedStrip(battle.anim) + if strip then + U.log("parked on", strip) + break + end + U.wait(1) + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_bug1421_test.lua b/tests/drivers/gold_bug1421_test.lua new file mode 100644 index 00000000..eec12acb --- /dev/null +++ b/tests/drivers/gold_bug1421_test.lua @@ -0,0 +1,161 @@ +-- #1421: the Gold battle HUD ran ahead of its own animations. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1421_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1421 (default) +-- +-- Two symptoms, one seam, and neither can be asserted -- both are "what is on +-- screen while an animation runs": +-- +-- caught a wild mon that is NOT in the dex is caught with a MASTER BALL. +-- The caught mark ($5d at (1,1)) must be absent for every frame of +-- the throw and every line after it, because DrawEnemyHUDBorder -- +-- the only thing that paints it -- is never called again during a +-- capture (engine/battle/trainer_huds.asm:134-151). The control +-- run right after it re-enters a battle with the same species now +-- in the dex, where the mark IS there from the first frame. +-- +-- status THUNDER WAVE on the enemy. PAR may not be on the HUD until the +-- animation and the "is paralyzed!" line are done with, which is +-- where UpdateBattleHuds runs (home/battle.asm:150). +-- +-- Every frame of both animations is logged as `live` (the engine's byte, a +-- whole turn ahead) against `hud` (what the HUD is allowed to print), so the +-- lag is a column of text as well as a strip of pictures. The run ends in a +-- third wild battle with THUNDER WAVE under the cursor: press A, A, A and +-- watch the tag land with its own line. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1421" +local WILD = "PIDGEY" + +local hostVisible = love.visible +love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end + +local function tap(game, button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function toMenu(game, screen) + for _ = 1, 400 do + if screen.phase == "menu" then return true end + if screen.battle.over then return false end + tap(game, "a", 2) + end + return screen.phase == "menu" +end + +local function giveMoves(game, mon, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +-- Shoot and log while the screen is busy: `probe` returns the two values the +-- run is about, and the caller gets them for every frame. +local function watch(game, screen, prefix, label, probe, limit) + local frames = 0 + while frames < (limit or 240) do + if frames % 3 == 0 then + U.shot(game, ("%s-%03d.png"):format(prefix, frames)) + local live, hud = probe() + U.log(("[driver] %s f%03d live=%s hud=%s") + :format(label, frames, tostring(live), tostring(hud))) + end + if not screen.anim and screen.phase ~= "resolving" then break end + frames = frames + 1 + U.wait(1) + end + return frames +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.caught[WILD] = nil + save.inventory = { MASTER_BALL = 5, POKE_BALL = 10 } + save.boxes = nil + save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30), + { "THUNDER_WAVE", "TACKLE" }) } + + ------------------------------------------------------------------ caught + local screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) }) + assert(toMenu(game, screen), "never reached the battle menu") + U.shot(game, OUT .. "/caught-00-menu.png") + U.log(("[driver] caught: dex before = %s, hud latch = %s") + :format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark))) + + screen:useItem("MASTER_BALL") + watch(game, screen, OUT .. "/caught-01-throw", "caught", + function() + return save.pokedex.caught[WILD] and true or false, screen.caughtMark + end, 200) + + -- The lines that follow the throw: "Gotcha!", the dex entry, the nickname + -- prompt. The mark may not appear on any of them either. + for i = 0, 7 do + U.shot(game, ("%s/caught-02-after-%d.png"):format(OUT, i)) + if screen.phase == "ask-nickname" then tap(game, "b", 4) + else tap(game, "a", 4) end + if screen.phase == "done" or not game.stack:top() then break end + end + U.log(("[driver] caught: dex after = %s, hud latch = %s") + :format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark))) + + for _ = 1, 300 do + if game.world and not (game.stack:top() or {}).battle then break end + tap(game, "a", 2) + end + + ------------------------------------------------------------------ control + -- Same species, now in the dex: the mark IS on the HUD from the first frame + -- the enemy HUD is drawn, because DrawEnemyHUDBorder runs at battle start. + save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30), + { "THUNDER_WAVE", "TACKLE" }) } + screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) }) + assert(toMenu(game, screen), "never reached the battle menu") + U.shot(game, OUT .. "/control-00-mark.png") + U.log(("[driver] control: dex = %s, hud latch = %s") + :format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark))) + + ------------------------------------------------------------------ status + screen:submit({ kind = "move", move = "THUNDER_WAVE" }) + watch(game, screen, OUT .. "/status-01-anim", "status", + function() + local enemy = screen.battle.enemy + return enemy and enemy.status, screen:hudStatus(enemy, "enemy") + end, 240) + U.shot(game, OUT .. "/status-02-line.png") + for i = 0, 4 do + tap(game, "a", 4) + U.shot(game, ("%s/status-03-after-%d.png"):format(OUT, i)) + end + U.log(("[driver] status: live=%s hud=%s"):format( + tostring(screen.battle.enemy and screen.battle.enemy.status), + tostring(screen:hudStatus(screen.battle.enemy, "enemy")))) + + U.log("[driver] shots in " .. OUT + .. " -- the battle is yours: FIGHT, THUNDER WAVE, watch the tag land") +end diff --git a/tests/drivers/gold_bug1425_test.lua b/tests/drivers/gold_bug1425_test.lua new file mode 100644 index 00000000..b282ad18 --- /dev/null +++ b/tests/drivers/gold_bug1425_test.lua @@ -0,0 +1,97 @@ +-- #1425 (and #1424): the PACK's ×NN column. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1425_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1425 (default) +-- +-- Nothing here can be asserted: the bug IS the column a digit lands in. +-- PlaceMenuItemQuantity's `lb bc, 1, 2` (engine/menus/menu_2.asm:24) is a +-- two-digit field with the leading digit blanked, so a ×5 and a ×50 must have +-- their ones digit in the SAME column; the port printed "×5" hard against the +-- cross. Each pocket is seeded with a one-digit and a two-digit count stacked +-- next to each other so the two rows can be read off against one another, and +-- the TM pocket is here because its rows used to print no count at all. +-- +-- The run ends with the PACK still open on the TM pocket, so a human takes the +-- controls exactly where the screenshots stop. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") + +-- One-digit and two-digit counts side by side in every pocket that shows one. +local SEED = { + { "POTION", 5 }, + { "SUPER_POTION", 50 }, + { "ANTIDOTE", 1 }, + { "FULL_HEAL", 99 }, + { "POKE_BALL", 7 }, + { "GREAT_BALL", 12 }, + { "TM_DYNAMICPUNCH", 1 }, + { "TM_HEADBUTT", 3 }, + { "TM_THUNDER", 24 }, + { "HM_CUT", 1 }, + { "HM_SURF", 1 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1425" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + local id, count = entry[1], entry[2] + if not (game.data.items and game.data.items[id]) then + U.log("[driver] SKIP", id, "-- not in this cache") + else + save.inventory[id] = count + table.insert(Bag.order(save), id) + end + end + + local pack = PackMenu.new(game, { save = save, world = game.world, + onClose = function() end }) + game.stack:push(pack) + + shot("00-items") -- ×5 over ×50: the ones digits line up + U.tap(game, "right") + shot("01-balls") -- ×7 over ×12 + U.tap(game, "right") + shot("02-key-items") -- no counts at all here + U.tap(game, "right") + shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none + + -- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks + -- TM_HEADBUTT while "Where should this be moved to?" holds the box. + U.tap(game, "down") + U.tap(game, "select") + shot("04-tmhm-armed") + U.tap(game, "up") + shot("05-tmhm-destination") + U.tap(game, "a") + shot("06-tmhm-moved") -- HEADBUTT is now the first TM row + + U.log("[driver] bag order:", table.concat(Bag.order(save), " ")) + + -- Back to the ITEM pocket and into a TOSS, whose own box prints the count + -- with leading zeros (×01, not × 1). + U.tap(game, "left") + U.tap(game, "left") + U.tap(game, "left") + U.tap(game, "a") + U.tap(game, "down") + U.tap(game, "down") + U.tap(game, "a") + shot("07-toss-quantity") + + U.log("[driver] shots in " .. out .. " -- the PACK is yours") +end diff --git a/tests/drivers/gold_bug1428_test.lua b/tests/drivers/gold_bug1428_test.lua new file mode 100644 index 00000000..cbda6184 --- /dev/null +++ b/tests/drivers/gold_bug1428_test.lua @@ -0,0 +1,174 @@ +-- #1428: the Gold battle HUD never drew the party ball rows. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1428_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1428 (default) +-- +-- BattleStart_TrainerHuds (engine/battle/trainer_huds.asm:1-9) runs from +-- inside BattleStartMessage, so the rows are on screen UNDER the opening line +-- and nowhere else: the player's six always, the opponent's only outside a +-- wild battle. EnemySwitch_TrainerHud (:11-15) brings the opponent's row +-- back for the "will you switch?" prompt after one of its mons drops. +-- +-- The party is seeded so all four staged tiles are on screen at once +-- (StageBallTilesData, :47-99): healthy, statused, fainted, and the empty +-- slots past the party count. +-- +-- trainer the intro line, both rows, then the shift prompt after the first +-- enemy mon faints -- the opponent's row again, one ball darkened +-- wild the same intro with only the player's row, per the `dec a / ret z` +-- +-- Nothing here is assertable: the fix IS twelve sprites. The run ends on the +-- trainer battle's own menu with a human holding the controls. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Trainers = require("src.world.gen2.Trainers") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1428" + +local hostVisible = love.visible +love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end + +local function tap(game, button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +-- Four healthy, one poisoned, one fainted, and one slot left empty: every +-- tile StageBallTilesData can stage, in one row. The lead is handed a +-- damaging move outright, because slot 1 of its level-up set is LEER. +local function seedParty(game) + local party = {} + for i = 1, 5 do + party[i] = Mon.new(game.data, "CYNDAQUIL", 20 + i) + end + party[1].moves = {} + for i, id in ipairs({ "EMBER", "TACKLE" }) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + party[1].moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + party[4].status = "poison" + party[5].hp = 0 + return party +end + +local function rowState(screen) + local rows = screen.ballRows or {} + return ("player=%s enemy=%s balls=%s"):format( + tostring(rows.player), tostring(rows.enemy), + tostring(screen.hud and screen.hud:image("balls") ~= nil)) +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.inventory = {} + save.party = seedParty(game) + + --------------------------------------------------------------- trainer + local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1 + assert(entry, "no BUG_CATCHER member 1 in trainers.lua") + entry.party = Trainers.party(game.data, entry) + local screen = openBattle(game, { trainer = entry }) + + -- The opening line: both rows, both borders, and no names or bars yet. + -- Not before the intro slide has settled, or the shots catch the pics + -- mid-transform instead of the HUD. + U.wait(150) + for i = 0, 6 do + U.shot(game, ("%s/trainer-00-intro-%d.png"):format(OUT, i)) + U.log("[driver] intro " .. i .. ": " .. rowState(screen)) + U.wait(6) + end + U.log(("[driver] OT party = %d"):format(#(screen.battle.enemyParty or {}))) + + -- Page through the pic slide and the send-out: the rows go with the OAM the + -- send-out animation takes over. + for i = 0, 7 do + tap(game, "a", 5) + U.shot(game, ("%s/trainer-01-sendout-%d.png"):format(OUT, i)) + end + for _ = 1, 400 do + if screen.phase == "menu" then break end + tap(game, "a", 2) + end + U.shot(game, OUT .. "/trainer-02-menu.png") + U.log("[driver] menu: " .. rowState(screen)) + + --------------------------------------------------------------- shift prompt + -- Drop the enemy's lead so HandleEnemySwitch offers the switch: its row is + -- redrawn for the prompt, with the fainted ball darkened. + if #(screen.battle.enemyParty or {}) > 1 then + for _ = 1, 400 do + if screen.phase == "ask-shift" or screen.phase == "shift-intro" then + break + end + if screen.battle.over then break end + if screen.phase == "menu" then + local enemy = screen.battle.enemy + if enemy then enemy.hp = 1 end + screen:chooseMenu("fight") + U.wait(2) + screen:chooseMove(1) + U.wait(4) + else + tap(game, "a", 6) + end + end + U.log("[driver] shift prompt: phase=" .. tostring(screen.phase)) + for i = 0, 5 do + U.shot(game, ("%s/trainer-03-shift-%d.png"):format(OUT, i)) + U.log("[driver] shift " .. i .. ": phase=" .. tostring(screen.phase) + .. " " .. rowState(screen)) + U.wait(6) + end + -- NO: the enemy sends its next mon and the row goes with the animation. + tap(game, "down", 4) + tap(game, "a", 4) + for i = 0, 5 do + U.shot(game, ("%s/trainer-04-after-shift-%d.png"):format(OUT, i)) + tap(game, "a", 5) + end + else + U.log("[driver] this trainer has one mon; no shift prompt to show") + end + + --------------------------------------------------------------- wild + -- ShowPlayerMonsRemaining runs for a wild battle too; the `dec a / ret z` + -- right after it is what keeps the OT row off. + for _ = 1, 600 do + if not (game.stack:top() or {}).battle then break end + tap(game, "a", 2) + end + save.party = seedParty(game) + screen = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + U.wait(150) + for i = 0, 6 do + U.shot(game, ("%s/wild-00-intro-%d.png"):format(OUT, i)) + U.log("[driver] wild " .. i .. ": " .. rowState(screen)) + U.wait(6) + end + for _ = 1, 400 do + if screen.phase == "menu" then break end + tap(game, "a", 2) + end + U.shot(game, OUT .. "/wild-01-menu.png") + + U.log("[driver] shots in " .. OUT .. " -- the battle is yours") +end diff --git a/tests/drivers/gold_bug1431_test.lua b/tests/drivers/gold_bug1431_test.lua new file mode 100644 index 00000000..5dc08dc9 --- /dev/null +++ b/tests/drivers/gold_bug1431_test.lua @@ -0,0 +1,142 @@ +-- Driver: the shiny sparkle on a SENT OUT mon (#1431). +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1431_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1431 (default) +-- +-- SendOutPlayerMon calls BattleCheckPlayerShininess and replays +-- ANIM_SEND_OUT_MON with wBattleAnimParam 1 -- the script's `.Shiny` arm -- +-- between the plain animation and the cry (engine/battle/core.asm:3820-3837); +-- ShowSetEnemyMonAndSendOutAnimation does the same for every enemy send-out +-- (:3364-3383). Only the wild-intro path (BattleStartMessage, :8701-8715) had +-- been ported, so a shiny lead-off, a shiny switch-in and a shiny enemy +-- replacement all came out silent. +-- +-- Three send-outs to watch, none of them the wild intro: the player's lead-off, +-- the player's own switch, and the opponent's replacement after a faint. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local GbcPalette = require("src.render.GbcPalette") + +-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces. +local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 } + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1431" + +local function tap(game, button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +-- Stop on the frame the `.Shiny` arm is actually running: animId plus the +-- wBattleAnimParam the arm branches on (data/moves/animations.asm:414-417). +local function shinyArm(screen) + local anim = screen.anim + return anim ~= nil and anim.animId == "ANIM_SEND_OUT_MON" + and anim.param == 1 +end + +local function watch(game, screen, label, frames) + for _ = 1, (frames or 400) do + if shinyArm(screen) then + U.log("[driver] " .. label .. ": .Shiny arm running") + U.shot(game, ("%s/%s.png"):format(OUT, label)) + return true + end + U.wait(1) + end + U.log("[driver] " .. label .. ": NO shiny arm seen") + return false +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + -- A shiny only reads as one in colour, and the sparkle rides the same anim. + GbcPalette.setMode("gbc") + + local save = game.save + save.inventory = {} + local lead = Mon.new(game.data, "CYNDAQUIL", 40, { dvs = SHINY_DVS }) + local bench = Mon.new(game.data, "TOTODILE", 40, { dvs = SHINY_DVS }) + assert(lead.shiny and bench.shiny, "the FORCESHINY DVs did not take") + local move = assert(game.data.moves.EMBER, "no EMBER in moves.lua") + lead.moves = { { id = "EMBER", pp = move.pp, maxPp = move.pp } } + save.party = { lead, bench } + + local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1 + assert(entry, "no BUG_CATCHER member 1 in trainers.lua") + local Trainers = require("src.world.gen2.Trainers") + entry.party = Trainers.party(game.data, entry) + for _, mon in ipairs(entry.party) do mon.shiny = true end + local screen = openBattle(game, { trainer = entry }) + + ------------------------------------------------------------ enemy lead + -- ShowSetEnemyMonAndSendOutAnimation, out of the opening sequence. + watch(game, screen, "00-enemy-sendout", 900) + + ----------------------------------------------------------- player lead + -- SendOutPlayerMon behind the "Go!" line: this is the one the report is + -- about, and it never sparkled before the fix. + local sawPlayer = watch(game, screen, "01-player-sendout", 900) + + for _ = 1, 600 do + if screen.phase == "menu" then break end + tap(game, "a", 2) + end + + ---------------------------------------------------------- player switch + -- The same routine again, this time as a voluntary mid-battle switch. + local sawSwitch = false + if screen.phase == "menu" and (screen.battle.party or {})[2] then + screen:submit({ kind = "switch", index = 2 }) + sawSwitch = watch(game, screen, "02-player-switch", 900) + for _ = 1, 600 do + if screen.phase == "menu" or screen.battle.over then break end + tap(game, "a", 2) + end + end + + ------------------------------------------------------ enemy replacement + local sawReplace = false + if #(screen.battle.enemyParty or {}) > 1 then + for _ = 1, 600 do + if screen.battle.over then break end + if screen.phase == "menu" then + local enemy = screen.battle.enemy + if enemy then enemy.hp = 1 end + screen:chooseMenu("fight") + U.wait(2) + screen:chooseMove(1) + break + end + tap(game, "a", 2) + end + sawReplace = watch(game, screen, "03-enemy-replacement", 900) + end + + U.log(("[driver] player lead-off %s, player switch %s, enemy replacement %s") + :format(tostring(sawPlayer), tostring(sawSwitch), tostring(sawReplace))) + U.log("[driver] shots in " .. OUT) + U.log("[driver] every send-out of a shiny must flash, sparkle and chime") + U.log("[driver] BEFORE its cry, not only the wild mon at battle start.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_bug1441_test.lua b/tests/drivers/gold_bug1441_test.lua new file mode 100644 index 00000000..7b4d9bbb --- /dev/null +++ b/tests/drivers/gold_bug1441_test.lua @@ -0,0 +1,72 @@ +-- #1441: the Magnet Train ride drew a flat white screen. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1441_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1441 (default) +-- +-- Nothing here is assertable: the bug IS what the screen shows. The ride +-- bakes its background out of data.gen2Field.magnetTrain, TILESET_TRAIN_ +-- STATION's sheet and the TOWN palettes, and every one of those was read +-- under its Gen 1 key, so all four came back nil and GbcPalette's fallback +-- filled the panel with DMG_SHADES[1]. +-- +-- The run boots into the Goldenrod station, prints whether the four tables +-- are actually there, shoots the Saffron-bound ride from JUMPTABLE_INIT +-- through to the arrival, then plays the Goldenrod-bound one back at 1x with +-- no screenshots in the way -- a human watching the window sees the whole +-- animation, which is the only place this bug ever showed. +local U = require("tests.drivers.util") + +local STATION = "GOLDENROD_MAGNET_TRAIN_STATION" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1441" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + world:setMap(STATION, 11, 8, "up") + U.wait(10) + + local data = game.data or {} + local field = data.gen2Field and data.gen2Field.magnetTrain + U.log("gen2Field.magnetTrain:", field and #field.bgTiles or "MISSING") + U.log("gen2Tilesets.TILESET_TRAIN_STATION:", + data.gen2Tilesets and data.gen2Tilesets.TILESET_TRAIN_STATION + and data.gen2Tilesets.TILESET_TRAIN_STATION.image or "MISSING") + U.log("gen2Palettes:", data.gen2Palettes and "ok" or "MISSING") + U.log("gen2Sprites.SPRITE_CHRIS:", + data.gen2Sprites and data.gen2Sprites.SPRITE_CHRIS and "ok" or "MISSING") + + local done = false + world:magnetTrain(true, function() done = true end) + U.wait(2) + + local ride = game.stack:top() + U.log("ride background:", ride and ride.background + and (ride:background() and "baked" or "nil canvas") or "no screen") + + -- SFX_TRAIN_ARRIVED lands a good while in; shoot across the whole ride so + -- the departure, the three scrolling bands and the stop are all on disk. + for index = 0, 11 do + U.shot(game, ("%s/%02d-ride.png"):format(out, index)) + U.wait(20) + if done then break end + end + for _ = 1, 600 do + if done then break end + U.wait(5) + end + U.log("ride finished at frame", U.frame()) + U.log("shots in " .. out) + + -- The return leg, played out in full with nothing else on screen: watch the + -- window, not the PNGs. The ride reads no input, so it ends on its own. + local back = false + world:magnetTrain(false, function() back = true end) + for _ = 1, 900 do + if back then break end + U.wait(1) + end + U.wait(60) +end diff --git a/tests/drivers/gold_radio_knob_bug1442.lua b/tests/drivers/gold_radio_knob_bug1442.lua new file mode 100644 index 00000000..4a47cbe2 --- /dev/null +++ b/tests/drivers/gold_radio_knob_bug1442.lua @@ -0,0 +1,57 @@ +-- Eyeball driver (#1442): the radio card's tuning knob. PokegearRadio_Init +-- spawns SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB on tile $08 and AnimateTuningKnob +-- writes wRadioTuningKnob into its XOFFSET, so a red needle stands in the dial +-- box at screen x = 72 + knob and steps with every UP/DOWN. Before the fix the +-- card drew the dial art and nothing in it. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_SHOTS=/tmp/radioknob \ +-- POKEPORT_DRIVER=tests/drivers/gold_radio_knob_bug1442.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- The run parks on the radio card with the knob on 08.5, so UP and DOWN move +-- the needle by hand. +local U = require("tests.drivers.util") + +local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/radioknob" + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Goldenrod, where the card is handed out, and the two engine flags the + -- START menu and the strip read: ENGINE_POKEGEAR and ENGINE_RADIO_CARD. + assert(world:setMap("GOLDENROD_CITY", 12, 20, "down"), + "setMap failed for GOLDENROD_CITY") + world:setEngineFlag(4, true) -- ENGINE_POKEGEAR + world:setEngineFlag(0, true) -- ENGINE_RADIO_CARD + U.wait(5) + + game:openStartMenuItem("pokegear") + U.wait(5) + local gear = game.stack:top() + assert(gear and gear.cards, "the POKeGEAR did not open") + gear.mode = "card" + for index, card in ipairs(gear.cards) do + if card.id == "radio" then gear.cardIndex = index end + end + assert(gear:card().id == "radio", "the RADIO card is missing from the strip") + U.wait(5) + + -- 04.5, the bottom of the dial: the needle sits at the left of the box. + U.shot(game, SHOTS .. "/01-radio-04.5.png") + U.log("knob", tostring(gear:currentStation().knob), "at 04.5") + + U.tap(game, "up") U.wait(4) + U.tap(game, "up") U.wait(4) + U.shot(game, SHOTS .. "/02-radio-08.5.png") + U.log("knob", tostring(gear:currentStation().knob), "at 08.5") + + U.log("compare 01 and 02: a red needle stands in the dial box and has", + "moved right; UP/DOWN now walk it by hand") + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_entrance_bug1429_test.lua b/tests/drivers/pikachu_entrance_bug1429_test.lua new file mode 100644 index 00000000..02cd8410 --- /dev/null +++ b/tests/drivers/pikachu_entrance_bug1429_test.lua @@ -0,0 +1,89 @@ +-- Driver: the starter Pikachu's battle entrance (#1429). +-- +-- POKEPORT_DRIVER=tests/drivers/pikachu_entrance_bug1429_test.lua \ +-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug1429 POKEPORT_TOUCH=0 \ +-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed) +-- +-- SendOutMon branches on IsThisPartyMonStarterPikachu (pokeyellow +-- engine/battle/core.asm:1798-1819): the starter never gets POOF_ANIM or +-- AnimateSendingOutMon. It walks in instead -- +-- StarterPikachuBattleEntranceAnimation (engine/battle/pikachu_entrance_anim.asm) +-- paints the back pic one column at a time from hlcoord 0,5, eight columns two +-- frames apart, and only then does PlayPikachuSoundClip voice it. +-- +-- The run halts in the middle of the walk-in, then again on the finished pic. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local GameVersion = require("src.core.GameVersion") + local PF = require("src.world.PikachuFollower") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running as Yellow (needs POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) + + game.save.player.name = "bryan" + local pika = Pokemon.new(game.data, "PIKACHU", 20) + BattleState.stampOT(game.save, pika) + game.save.party = { pika, Pokemon.new(game.data, "CHARMANDER", 20) } + check("the lead reads as the starter Pikachu", + PF.isStarterPikachu(game.save, pika)) + + U.teleport(game, "ROUTE_1", 5, 20, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 90) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 4) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + -- Poll every frame: the slide is 16 frames long and the ball poof, if the + -- bug were back, would never set this slot at all. + local sliding = tapUntil(function() + return battle.picOff ~= nil and battle.picOff.playerMon ~= nil + end, 120, 2) + check("the send-out started the entrance slide, not the ball poof", sliding) + check("no grow-in is running alongside it", battle.growIn == nil) + + if sliding then + U.log("slide x =", tostring(battle.picOff.playerMon.x)) + check("mid-slide screenshot reached disk", + U.shot(game, DIR .. "/bug1429_pikachu_sliding.png")) + U.log("captured", DIR .. "/bug1429_pikachu_sliding.png") + for _ = 1, 40 do + if battle:picOffset("playerMon") == 0 then break end + U.wait(1) + end + check("the pic landed on its own column", battle:picOffset("playerMon") == 0) + check("landed screenshot reached disk", + U.shot(game, DIR .. "/bug1429_pikachu_landed.png")) + U.log("captured", DIR .. "/bug1429_pikachu_landed.png") + end + + U.log("Pikachu should have walked in from the LEFT edge of the screen,") + U.log("column by column, with no ball, no poof and no grow-out-of-the-ball,") + U.log("and voiced its PCM clip only once it was fully drawn (#1429).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/shift_prompt_box_bug1398_test.lua b/tests/drivers/shift_prompt_box_bug1398_test.lua new file mode 100644 index 00000000..51472d78 --- /dev/null +++ b/tests/drivers/shift_prompt_box_bug1398_test.lua @@ -0,0 +1,114 @@ +-- Driver: where the "Will PLAYER change POKéMON?" YES/NO box sits (#1398). +-- +-- POKEPORT_DRIVER=tests/drivers/shift_prompt_box_bug1398_test.lua \ +-- POKEPORT_IDENTITY=bug1398 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +-- +-- EnemySendOutFirstMon (engine/battle/core.asm:1378-1384) does NOT go through +-- InitYesNoTextBoxParameters for this one prompt: it inlines its own +-- TWO_OPTION_MENU at hlcoord 0,7, i.e. hard against the LEFT edge, while every +-- other YES/NO in the game sits at hlcoord 14,7 on the right. The port only +-- had the shared right-hand box, so the SHIFT offer came up on the wrong side. +-- +-- The run stops with the prompt on screen and the controls in a human's hands. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Theme = require("src.ui.Theme") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- roster 1 is RATTATA 11 / EKANS 11: two mons, so the KO of the first one + -- reaches EnemySendOutFirstMon with a reserve to announce. + local OPP, ROSTER = "OPP_YOUNGSTER", 1 + + check("trainer class " .. OPP .. " is in the data", + game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil) + check("Theme carries the left-edge box", + Theme.trainerSwitchBox ~= nil and Theme.trainerSwitchBox.tx == 0 + and Theme.trainerSwitchBox.ty == 7) + + -- SHIFT is what makes the prompt appear at all (the BIT_BATTLE_SHIFT test at + -- core.asm:1375-1377 skips it under SET). + game.save.options = game.save.options or {} + game.save.options.battleStyle = "shift" + game.save.player.name = "bryan" + game.save.party = { + Pokemon.new(game.data, "MEWTWO", 70), + Pokemon.new(game.data, "CHARIZARD", 50), + } + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER) + check("trainer battle constructed", ok and battle ~= nil) + if not ok then + U.log("could not start", OPP, "->", tostring(battle)) + while true do coroutine.yield() end + end + check("the foe has a reserve to send out", #battle.enemyParty >= 2) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 60) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 6) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + if not check("reached the FIGHT/PKMN/ITEM/RUN menu", + tapUntil(function() return battle.phase == "menu" end, 200)) then + U.log("phase is", tostring(battle.phase)) + end + + -- FIGHT, first move: L70 MEWTWO one-shots the L11 lead. + U.tap(game, "a") + U.wait(10) + U.tap(game, "a") + U.wait(10) + + -- Stop the moment the YES/NO goes up over the still-visible prompt page. + local function choiceBox() + local top = game.stack:top() + return (top and top.tx and top.labels and top.labels[1] == "YES") and top + or nil + end + local reached = tapUntil(function() return choiceBox() ~= nil end, 200, 4) + if not check("the SHIFT prompt opened its YES/NO box", reached) then + U.log("phase", tostring(battle.phase), + "enemy hp", tostring(battle.enemy and battle.enemy.mon.hp), + "top", tostring(game.stack:top())) + end + + local box = choiceBox() + if box then + check("the box is at hlcoord 0,7 (core.asm:1378-1384)", + box.tx == 0 and box.ty == 7) + U.log("box tx=" .. tostring(box.tx) .. " ty=" .. tostring(box.ty)) + check("prompt screenshot reached disk", + U.shot(game, DIR .. "/bug1398_shift_prompt.png")) + U.log("captured", DIR .. "/bug1398_shift_prompt.png") + end + + U.log("The foe's lead is down and the SHIFT offer is on screen.") + U.log("YES/NO must sit against the LEFT edge, over the text box's left half,") + U.log("not on the right where every other YES/NO in the game lives (#1398).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/talk_pose_bug1435_test.lua b/tests/drivers/talk_pose_bug1435_test.lua new file mode 100644 index 00000000..8b63163f --- /dev/null +++ b/tests/drivers/talk_pose_bug1435_test.lua @@ -0,0 +1,73 @@ +-- Driver: nobody is caught mid-stride while a text box is up (#1435). +-- UpdatePlayerSprite and UpdateNPCSprite both jump to their standing frame +-- while BIT_FONT_LOADED is set (engine/overworld/movement.asm:57, :139), so a +-- box that opens on a walk frame freezes a standing sprite, never a leg out. +-- +-- POKEPORT_DRIVER=tests/drivers/talk_pose_bug1435_test.lua \ +-- POKEPORT_IDENTITY=bug1435 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Mom stands at (5,4) in Red's house (pokered + -- data/maps/objects/RedsHouse1F.asm); park the player beside her and push + -- into her so the walk-in-place cycle is running when the box opens. + U.teleport(game, "REDS_HOUSE_1F", 6, 4, "down") + U.wait(20) + local ow = game.overworld + local p = ow.player + + local walking = false + for _ = 1, 90 do + table.insert(game.input.pressQueue, "left") + game.input.state.left = true + coroutine.yield() + if p:walkPhase() == 1 then walking = true break end + end + game.input.state.left = false + check("bumping into MOM shows the walk frame", walking) + U.shot(game, DIR .. "/bug1435_1_midstride.png") + + U.tap(game, "a") + U.wait(4) + check("talking to MOM opened a box over the overworld", + game.stack:top() ~= ow) + check("the walk clock is still mid-cycle underneath", + (p.bumpFrames or 0) > 0 or p.moving) + check("but the player is drawn standing", p:walkPhase() == 0) + U.shot(game, DIR .. "/bug1435_2_talking.png") + + -- The same gate for an NPC caught mid-step: Pallet Town's wanderers walk + -- on their own, so wait for one and open a box while it is between cells. + U.teleport(game, "PALLET_TOWN", 8, 8, "down") + U.wait(30) + ow = game.overworld + local mover + for _ = 1, 900 do + for _, npc in ipairs(ow.npcs) do + if npc.moving and npc:walkPhase() == 1 then mover = npc break end + end + if mover then break end + coroutine.yield() + end + if check("caught a Pallet Town NPC mid-step", mover ~= nil) then + game.stack:push(TextBox.new(game, "TESTING THE POSE.")) + U.wait(4) + check("the NPC is still mid-step underneath", mover.moving) + check("but it is drawn standing too", mover:walkPhase() == 0) + U.shot(game, DIR .. "/bug1435_3_npc.png") + end + + U.log("Look at bug1435_2_talking.png and bug1435_3_npc.png: with the box") + U.log("up, both sprites stand square on their feet. A leg out, or the") + U.log("split-stride frame held for the whole conversation, is the bug.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/title_seam_bug1453_test.lua b/tests/drivers/title_seam_bug1453_test.lua new file mode 100644 index 00000000..e0f42918 --- /dev/null +++ b/tests/drivers/title_seam_bug1453_test.lua @@ -0,0 +1,176 @@ +-- Manual check for #1453: the SGB zone scissors must stay contiguous in +-- framebuffer pixels on a fractional-DPI surface (Android), where LOVE 11 +-- truncates the scissor to whole units before scaling it. Zones are pokered +-- data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows 0-7 / 8-9 / 10-17). +-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1453_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- PROBE_DPI picks the density to emulate (default 2.625, the reporter's). +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music. +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local Renderer = require("src.render.Renderer") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DPI = tonumber(os.getenv("PROBE_DPI") or "") or 2.625 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- past the copyright splash / attract movie (engine/movie/splash.asm) + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and top.screenId == "TitleState" and top.sgbPalettes then + title = top + break + end + U.tap(game, "start") + U.wait(9) + end + check("the title screen is on top", title ~= nil) + if not (title and title.sgbPalettes) then + U.log("No title state to look at; nothing below can run.") + while true do coroutine.yield() end + end + + local opts = game.save.options + local mode = opts and opts.colors or PaletteFX.mode + if PaletteFX.mode ~= "gbc" then + U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check") + PaletteFX.setMode("gbc") + U.wait(20) + end + local zones = PaletteFX.ensureZones(title:sgbPalettes(game)) + check("the title builds three SGB zones", zones ~= nil and #zones == 3) + + -- A canvas carries its own dpiscale, so the emulated Android surface goes + -- through the same LOVE scissor path the phone does, on this desktop. + local probe = love.graphics.newCanvas(math.floor(1920 / DPI), + math.floor(1080 / DPI), + { dpiscale = DPI }) + local PW, PH = probe:getPixelWidth(), probe:getPixelHeight() + local Sp = math.max(1, math.floor(math.min(PW / 160, PH / 144))) + local Ux, Uy = Sp / DPI, Sp / DPI + local uox = math.floor((PW - 160 * Sp) / 2) / DPI + local uoy = math.floor((PH - 144 * Sp) / 2) / DPI + local uvpw, uvph = 160 * Ux, 144 * Uy + U.log(("emulating %dx%d px at dpi %.4f, fit scale %d"):format(PW, PH, DPI, Sp)) + + -- what the pre-fix Renderer handed LOVE: fractional units with a half + -- framebuffer pixel of bias, which LOVE 11 truncates away + local function scissorOld(x, y, w, h) + local x2, y2 = math.min(x + w, uox + uvpw), math.min(y + h, uoy + uvph) + x, y = math.max(x, uox), math.max(y, uoy) + if x2 <= x or y2 <= y then return false end + local px1, py1 = math.floor(x * DPI), math.floor(y * DPI) + local px2, py2 = math.ceil(x2 * DPI), math.ceil(y2 * DPI) + love.graphics.setScissor((px1 + 0.5) / DPI, (py1 + 0.5) / DPI, + (px2 - px1 + 0.5) / DPI, (py2 - py1 + 0.5) / DPI) + return true + end + + local function renderProbe(old) + love.graphics.setCanvas(probe) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + if old then + local shader = PaletteFX.shader() + love.graphics.setShader(shader) + for _, z in ipairs(zones) do + PaletteFX.sendColors(shader, z.colors) + if scissorOld(uox + z.x * Ux, uoy + z.y * Uy, z.w * Ux, z.h * Uy) then + love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy) + end + end + love.graphics.setScissor() + love.graphics.setShader() + else + Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zones, Ux, Uy, + uox, uoy, uox, uoy, uvpw, uvph, DPI, DPI) + end + love.graphics.setCanvas() + return probe:newImageData() + end + + local top = math.floor(uoy * DPI) + local bottom = math.floor((uoy + uvph) * DPI) + local col = math.floor(uox * DPI) + 2 + local function blackRows(data) + local rows = {} + for py = top, math.min(bottom - 1, data:getHeight() - 1) do + local r, g, b = data:getPixel(col, py) + if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end + end + return rows + end + + local dataNew = renderProbe(false) + local dataOld = renderProbe(true) + local rowsNew, rowsOld = blackRows(dataNew), blackRows(dataOld) + U.log(("picture rows %d-%d, sampling the background column at x=%d") + :format(top, bottom - 1, col)) + U.log("pre-fix math left", #rowsOld, "black rows:", + #rowsOld > 0 and table.concat(rowsOld, ",") or "none") + check("no letterbox row shows through the zone boundaries", #rowsNew == 0) + check("the emulated surface reproduces the seam without the fix", #rowsOld > 0) + + local ZOOM, CW, CH = 4, 220, 28 + local cy = math.max(top, math.floor((uoy + 64 * Uy) * DPI) - math.floor(CH / 2)) + local cx = math.floor(uox * DPI) + local function zoomOf(data) + local out = love.image.newImageData(CW * ZOOM, CH * ZOOM) + out:mapPixel(function(x, y) + return data:getPixel(cx + math.floor(x / ZOOM), cy + math.floor(y / ZOOM)) + end) + return out + end + local function writePng(imageData, path) + local dir = path:match("^(.*)[/\\][^/\\]+$") + if dir and dir ~= "" then os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') end + local f = io.open(path, "wb") + if not f then return false end + f:write(imageData:encode("png"):getString()) + f:close() + return true + end + + local zoomOld, zoomNew = zoomOf(dataOld), zoomOf(dataNew) + check("wrote " .. SHOT_DIR .. "/bug1453_before.png", + writePng(zoomOld, SHOT_DIR .. "/bug1453_before.png")) + check("wrote " .. SHOT_DIR .. "/bug1453_after.png", + writePng(zoomNew, SHOT_DIR .. "/bug1453_after.png")) + + local imgOld = love.graphics.newImage(zoomOld) + local imgNew = love.graphics.newImage(zoomNew) + local baseDraw = love.draw + love.draw = function() + baseDraw() + local w, h = love.graphics.getDimensions() + local s = math.min((w - 24) / imgOld:getWidth(), + (h * 0.5 - 48) / (imgOld:getHeight() * 2)) + local bw = imgOld:getWidth() * s + 16 + local bh = imgOld:getHeight() * 2 * s + 56 + local bx, by = (w - bw) / 2, h - bh - 8 + love.graphics.setColor(0, 0, 0, 0.75) + love.graphics.rectangle("fill", bx, by, bw, bh) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.print("BEFORE (dpi " .. DPI .. ")", bx + 8, by + 4) + love.graphics.draw(imgOld, bx + 8, by + 20, 0, s, s) + love.graphics.print("AFTER", bx + 8, by + 24 + imgOld:getHeight() * s) + love.graphics.draw(imgNew, bx + 8, by + 40 + imgOld:getHeight() * s, 0, s, s) + end + + PaletteFX.setMode(mode) + U.wait(10) + U.shot(game, SHOT_DIR .. "/bug1453_title.png") + U.log("captured", SHOT_DIR .. "/bug1453_title.png") + U.log("The title is live and the pad is yours; the strip along the bottom is") + U.log("the row-64 zone boundary of an emulated " .. DPI .. "x Android surface,") + U.log("magnified " .. ZOOM .. "x: BEFORE carries the black hairline across the") + U.log("picture, AFTER is unbroken off-white. PROBE_DPI=2.75 tries another one.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/town_map_marker_bug1437_test.lua b/tests/drivers/town_map_marker_bug1437_test.lua new file mode 100644 index 00000000..08cb51a0 --- /dev/null +++ b/tests/drivers/town_map_marker_bug1437_test.lua @@ -0,0 +1,72 @@ +-- Driver: the TOWN MAP player marker wears the OBJ palette (#1437). +-- LoadPlayerSpriteGraphics hands the town map the same walking sheet the +-- overworld draws (engine/items/town_map.asm:342), so the marker has to go +-- through the OBJ ramp and the shade-0 keying every other sprite gets -- +-- green on Red, pink on Blue, never a raw white block on the BG ramp. +-- +-- POKEPORT_DRIVER=tests/drivers/town_map_marker_bug1437_test.lua \ +-- POKEPORT_IDENTITY=bug1437 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local PaletteFX = require("src.render.PaletteFX") + local SpriteRenderer = require("src.render.SpriteRenderer") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.player.name = "bryan" + game.save.visited = { + PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true, + CERULEAN_CITY = true, CELADON_CITY = true, + } + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local function openMap(mode, tag) + game.save.options = game.save.options or {} + game.save.options.colors = mode + PaletteFX.setMode(mode) + U.wait(4) + Screens.push(game, "TownMap") + U.wait(20) + local top = game.stack:top() + check(tag .. ": the TOWN MAP is up", top and top.playerQuad ~= nil) + -- the marker must be a baked OBJ image, not a fresh newImage of the sheet + local sprites = game.data.sprites or {} + local red = sprites.SPRITE_RED + local colors, group + if PaletteFX.usesSpriteObp() then + colors, group = PaletteFX.ogObj() + else + colors, group = PaletteFX.dmgObj() + end + local want = red and SpriteRenderer.obpImage(red.image, colors, group) + check(tag .. ": the marker is the OBP-baked sheet", + top and want ~= nil and top.playerSheet == want) + -- hold the shot on a frame the blink is showing the marker + for _ = 1, 40 do + if top.blink < 16 then break end + coroutine.yield() + end + U.shot(game, DIR .. "/bug1437_" .. tag .. ".png") + U.tap(game, "b") + U.wait(10) + end + + openMap("ogred", "ogred") + openMap("gbc", "gbc") + openMap("og", "og") + + U.log("Open the shots: in OG RED the marker over PALLET TOWN is the green") + U.log("boot-ROM trainer, in the other modes it is coloured by the map zone") + U.log("like any overworld sprite. A white box, or a red/black marker on") + U.log("the BG ramp, is the bug. The pad is yours -- the TOWN MAP is one") + U.log("B away and the ITEM bag reopens it.") + + while true do coroutine.yield() end +end diff --git a/tests/engine/gen2_battler_gfx_rows_bug1231.lua b/tests/engine/gen2_battler_gfx_rows_bug1231.lua index e74a036a..4a811e0c 100644 --- a/tests/engine/gen2_battler_gfx_rows_bug1231.lua +++ b/tests/engine/gen2_battler_gfx_rows_bug1231.lua @@ -47,12 +47,32 @@ do T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base") end +-- engine/battle_anims/anim_commands.asm:317 do local runner = AnimRunner.new({}) runner:start(nil) AnimRunner.COMMANDS.battlergfx_1row(runner) local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET") T.eq(head and head.battler, "enemy", "the script command routes the same way") + T.eq(head.rows, 2, "$da's macro says 1row, its jumptable slot says _2Row") + T.eq(head.tiles, 14, "two enemy rows") + T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "at the _2Row base") + T.eq(feet.tiles, 12, "two player rows") + T.eq(feet.tile, (0x80 - 6 * 2) - 49, "at the _2Row base") +end + +do + local runner = AnimRunner.new({}) + runner:start(nil) + AnimRunner.COMMANDS.battlergfx_2row(runner) + local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET") + T.eq(head.rows, 1, "$d9's macro says 2row, its jumptable slot says _1Row") + T.eq(head.tiles, 7, "one enemy row") + T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the _1Row base") + T.eq(feet.tiles, 6, "one player row") + T.eq(feet.tile, (0x80 - 6) - 49, "at the _1Row base") end T.finish("gen2 battler gfx row attribution bug 1231") diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index e781818b..dd1c9a7f 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -151,8 +151,8 @@ do local fresh = { mods = {} } SaveData.setModEnabled(fresh, "b", true, "gold") - eq(fresh.modsByVersion.gold.b, nil, - "no shared flag reads as enabled, so agreeing with it stores nothing") + eq(fresh.modsByVersion.gold.b, true, + "with no shared flag the answer is stored, not dropped against an assumed default") end do diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 71fe05f2..37b7b740 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -357,8 +357,8 @@ local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) -- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, --- video mode, the mobile-gated touch three (buildRows) and CANCEL. -check("twenty-one rows", #OptionsMenu.ROWS, 21) +-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. +check("twenty-two rows", #OptionsMenu.ROWS, 22) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index b8233558..f40aa02c 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -42464,7 +42464,7 @@ "after": "_MtMoonB2FSuperNerdTheresAPokemonLabText", "battle": "_MtMoonB2FSuperNerdTheyreBothMineText", "event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD", - "won": "_MtMoonB2fSuperNerdEachTakeOneText" + "won": "_MtMoonB2FSuperNerdOkIllShareText" }, "2": { "after": "_MtMoonB2FRocket1AfterBattleText", diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 753db374..f198ca5a 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -42441,7 +42441,7 @@ "after": "_MtMoonB2FSuperNerdTheresAPokemonLabText", "battle": "_MtMoonB2FSuperNerdTheyreBothMineText", "event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD", - "won": "_MtMoonB2fSuperNerdEachTakeOneText" + "won": "_MtMoonB2FSuperNerdOkIllShareText" }, "2": { "after": "_MtMoonB2FRocket1AfterBattleText",