diff --git a/assets/labels/ruby.png b/assets/labels/ruby.png index f6e8bf49..7ca7ccce 100644 Binary files a/assets/labels/ruby.png and b/assets/labels/ruby.png differ diff --git a/assets/labels/sapphire.png b/assets/labels/sapphire.png index 3ad34c23..517b482f 100644 Binary files a/assets/labels/sapphire.png and b/assets/labels/sapphire.png differ diff --git a/data/scripts/flavor/route_23.lua b/data/scripts/flavor/route_23.lua index 644d0d11..0ad7cdb4 100644 --- a/data/scripts/flavor/route_23.lua +++ b/data/scripts/flavor/route_23.lua @@ -1,31 +1,11 @@ --- Route 23's badge-gate guards (pokered scripts/Route23.asm). Each --- guard/swimmer stands beside the staircase to Victory Road and, when --- talked to, checks whether the player holds the badge matching that --- step; the automatic walk-into-the-guard blocking script --- (Route23DefaultScript / Route23CheckForBadgeScript) is not ported --- here -- only the talk-triggered text_asm bodies, which share that --- same subroutine (Route23CheckForBadgeScript) and text --- (Route23YouDontHaveTheBadgeYetText / Route23OhThatIsTheBadgeText, --- pokered/text/Route23.asm _Route23YouDontHaveTheBadgeYetText / --- _Route23OhThatIsTheBadgeText). Once shown the badge, pokered sets --- EVENT_PASSED__CHECK so the walk-through gate (when ported) --- won't re-ask; we mirror that with set_flag for fidelity even though --- no onStep gate currently reads it. - +-- scripts/Route23.asm:153 local M = {} --- rows: check_item(badge) -> have it? show "Oh! That is the X!" and --- set EVENT_PASSED_X_CHECK : show "You don't have the X yet!" +-- scripts/Route23.asm:195 local function badgeGuard(badge, passFlag) - local subs = { RAM = badge } - return { - { "check_item", badge }, -- 1 - { "jump_if_true", 5 }, -- 2 - { "show_text", "_Route23YouDontHaveTheBadgeYetText", subs }, -- 3 - { "jump", 7 }, -- 4 (end) - { "show_text", "_Route23OhThatIsTheBadgeText", subs }, -- 5 - { "set_flag", passFlag }, -- 6 - } + return function(game, ow, npc, done) + ow:route23BadgeCheck(badge, passFlag, done) + end end -- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index 5596e284..02632ae2 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -34,18 +34,19 @@ local function starterBall(askText, species, choseFlag, ownBall, { species = species, forceOwned = true } }, -- 5 { "ask", askText }, -- 6 { "jump_if_false", "end" }, -- 7 - -- scripts/OaksLab.asm:919 - { "show_text", "_OaksLabMonEnergeticText" }, -- 8 + -- scripts/OaksLab.asm:918 + { "hide_object", "OAKS_LAB", ownBall }, -- 8 + -- scripts/OaksLab.asm:921 + { "show_text", "_OaksLabMonEnergeticText" }, -- 9 -- OaksLab.asm: ReceivedMon (sound_get_key_item) then AddPartyMon; the -- jingle fires once the box has typed and holds it (#668) - { "text_sound", "Get_Key_Item" }, -- 9 - { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10 - { "give_pokemon", species, 5 }, -- 11 - { "set_flag", "EVENT_GOT_STARTER" }, -- 12 - { "set_flag", choseFlag }, -- 13 + { "text_sound", "Get_Key_Item" }, -- 10 + { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11 + { "give_pokemon", species, 5 }, -- 12 + { "set_flag", "EVENT_GOT_STARTER" }, -- 13 -- POKé BALLs come later, at OaksLabOak1Text's .give_poke_balls beat -- once the Route 22 rival is beaten (see TEXT_OAKSLAB_OAK1 below) - { "hide_object", "OAKS_LAB", ownBall }, -- 14 + { "set_flag", choseFlag }, -- 14 -- the rival walks to the countering ball (around the furniture) { "move_npc_to", 1, rivalBallX, 4 }, -- 15 { "face_object", 1, "up" }, -- 16 diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 1718d154..ec040087 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1061,7 +1061,8 @@ M.VICTORY_ROAD_3F = { -- pokered forces the fight on map entry: Agatha's victory arms -- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and -- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement --- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript. +-- (played back to front: up 3, right 1, up 1) before +-- ChampionsRoomRivalReadyToBattleScript. -- The rival object has no trainer header / sight range, so without that -- entrance script the player can walk past (issue #99). We arm on the -- run flag instead of Agatha's victory bit: same observable effect for @@ -1090,6 +1091,7 @@ local championsRoomRivalScript = { -- playBattle("final") then no-ops on the same song, so the theme stays -- continuous into the fight { "play_music", "Music_FinalBattle" }, -- 5 + { "save_end_battle_text", "_RivalDefeatedText" }, -- scripts/ChampionsRoom.asm:65 { "rival_battle", "OPP_RIVAL3", 1 }, -- 6 -- losing halts here; the numeric target this replaced pointed at the -- closing warp, which inducted a player who had just lost the fight (#704) @@ -1098,8 +1100,7 @@ local championsRoomRivalScript = { { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9 -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, -- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch = - -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText - -- is the port's generic " defeated BLUE!" engine line instead). + -- _ChampionsRoomRivalAfterBattleText. { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10 -- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo -- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after @@ -1153,10 +1154,11 @@ M.CHAMPIONS_ROOM = { end -- RivalEntrance_RLEMovement, then the battle/Oak script (queued -- separately so talk-script jump indices stay 1-based as written). + -- home/overworld.asm:1844 ow:queueScript({ - { "move_player", "up", 1 }, - { "move_player", "right", 1 }, { "move_player", "up", 3 }, + { "move_player", "right", 1 }, + { "move_player", "up", 1 }, }) ow:queueScript(championsRoomRivalScript, { npc = rival }) end, diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 37713777..631365ad 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -867,20 +867,19 @@ M.SAFFRON_CITY = { -- ------------------------------------------------------------------- local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag) + local function door(game, ow) + ow:replaceBlock(2, 0, game.save.flags[flag] and openBlock or closedBlock) + end local seal = function(game, ow) - local open = game.save.flags[flag] - ow:replaceBlock(2, 0, open and openBlock or closedBlock) + door(game, ow) -- the auto walk-in on first (south) entry (LoreleiScriptWalkIntoRoom) if autoFlag and not game.save.flags[autoFlag] and ow.player.cellY >= 10 then game.save.flags[autoFlag] = true ow:scriptMove(ow.player, "up", 6) end end - -- onVictory re-runs the seal so the door opens right after the - -- battle, like pokered's post-battle map reload return { onEnter = seal, - onVictory = seal, -- retreating toward the entrance gets "Don't run away!" and a -- shove back up (the entrance coords rows in each room script) onStep = function(game, ow, x, y) @@ -892,14 +891,14 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag) end)) return true end, - } + }, door end -- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then -- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a -- win. engageTrainer only shows the won line, so wrap talk like Lance / -- Game Corner Rocket and push header.after immediately after a win. -local function e4LeaderTalk(afterLabel) +local function e4LeaderTalk(afterLabel, door) return function(game, ow, npc, done) done = done or function() end if ow:trainerDefeated(npc) then @@ -912,26 +911,33 @@ local function e4LeaderTalk(afterLabel) done() return end + -- scripts/AgathasRoom.asm:2 + local function reopen() + door(game, ow) + done() + end local after = text(game)[afterLabel] - if after then push(game, after, done) else done() end + if after then push(game, after, reopen) else reopen() end end) end end -M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05, +local loreleiDoor, brunoDoor, agathaDoor +M.LORELEIS_ROOM, loreleiDoor = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05, "_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM") M.LORELEIS_ROOM.talk = { - TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"), + TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText", + loreleiDoor), } -M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05, +M.BRUNOS_ROOM, brunoDoor = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05, "_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM") M.BRUNOS_ROOM.talk = { - TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"), + TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText", brunoDoor), } -M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, +M.AGATHAS_ROOM, agathaDoor = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, "_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM") M.AGATHAS_ROOM.talk = { - TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"), + TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText", agathaDoor), } -- ------------------------------------------------------------------- @@ -953,21 +959,15 @@ M.AGATHAS_ROOM.talk = { -- (5,11)/(6,11) the doorway -> CheckAndSetEvent -- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals -- the door behind the player with SFX_GO_INSIDE --- (24,16) the entrance staircase -> WalkToLance: an auto-walk --- landing on (6,11). Vanilla WalkToLance_RLEList is --- up 12 / left 12 / down 7 / left 6 and skips collision --- through void tiles; our camera follows that literally --- and briefly shows the empty upper chamber (reads as --- "Gary's room"). We keep the same landing cell but --- route along the open-door floor corridor instead. +-- (24,16) the entrance staircase -> WalkToLance -- ------------------------------------------------------------------- --- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR --- unset (entrance blocks $31/$32). Exposed for parity tests. -local LANCE_WALK_IN = { - { "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 }, - { "up", 9 }, { "left", 2 }, { "up", 3 }, -} +-- scripts/LancesRoom.asm:111 +-- home/overworld.asm:1845 +local function lanceWalkInRoute(yellow) + return { { "left", 6 }, { "down", 7 }, { "left", 12 }, { "up", yellow and 13 or 12 } } +end +local LANCE_WALK_IN = lanceWalkInRoute(false) local function lanceEntranceBlocks(game, ow) local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR @@ -983,15 +983,17 @@ local function lanceLockDoor(game, ow) end local function lanceWalkIn(game, ow) + local route = lanceWalkInRoute(require("src.core.GameVersion").isYellow()) local i = 0 local function step() i = i + 1 - local seg = LANCE_WALK_IN[i] + local seg = route[i] if not seg then - -- lands on (6,11); vanilla's per-frame coord poll then locks the - -- door at once. scriptMove landings do not fire onStep, so lock - -- here. - lanceLockDoor(game, ow) + -- scripts/LancesRoom.asm:82 + local p = ow.player + if (p.cellX == 5 or p.cellX == 6) and p.cellY == 11 then + lanceLockDoor(game, ow) + end return end ow:scriptMove(ow.player, seg[1], seg[2], step) @@ -1001,6 +1003,7 @@ end M.LANCES_ROOM = { walkInRoute = LANCE_WALK_IN, + walkInRouteFor = lanceWalkInRoute, onEnter = function(game, ow) lanceEntranceBlocks(game, ow) -- the warp arrival lands ON the staircase trigger, and onStep only @@ -1019,7 +1022,6 @@ M.LANCES_ROOM = { if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end end if not lance or ow:trainerDefeated(lance) then return false end - lance:facePlayer(ow.player) -- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer -- after-battle text (rival became champion first). engageTrainer -- only shows the won line, so push the after text on a win. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1565d87d..facaf3fd 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1268,8 +1268,9 @@ end -- Returns true while animating. function BattleState:stepHPDrain() local busy = false + local only = self.drainOnly for _, b in ipairs({ self.player, self.enemy }) do - if b and b.shownHP then + if b and b.shownHP and (not only or b == only) then -- drainFloor is the stop the running row carries (see drainNext) local goal = b.mon.hp if b.drainFloor and b.drainFloor > goal @@ -1393,6 +1394,19 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end +-- home/text.asm:264 +function BattleState:arrowOwnsCell() + local save = self.game and self.game.save + if save and (save.generation == 2 or save.version == "gold") then return false end + return not self:wideLayout() +end + +-- home/text.asm:270 +function BattleState:blankArrowCell() + local line = self.shown and self.shown[2] + if line and line[18] and self:arrowOwnsCell() then line[18] = 0x7F end +end + function BattleState:visibleText() if self.phase ~= "messages" or not (self.current or self.animPlaying) then return nil @@ -1436,7 +1450,7 @@ function BattleState:updateQueue() -- an HP-bar drain holds the queue until the bar catches up if self.draining then if self:stepHPDrain() then return true end - self.draining = nil + self.draining, self.drainOnly = nil, nil if self.player then self.player.drainFloor = nil end if self.enemy then self.enemy.drainFloor = nil end end @@ -1478,7 +1492,7 @@ function BattleState:updateQueue() return true end if item.drain then - self.draining = true + self.draining, self.drainOnly = true, item.battler if item.battler then item.battler.drainFloor = item.stopAt end return true end @@ -1618,6 +1632,7 @@ function BattleState:updateQueue() end if input:wasPressed("a") or input:wasPressed("b") then self.msgWaiting = nil + self:blankArrowCell() self:beginMsgLine() -- then the two ScrollTextUpOneLine calls block for 5 frames each -- (home/text.asm:280-305) before the next line starts typing @@ -1714,6 +1729,7 @@ function BattleState:updateQueue() elseif input:wasPressed("a") or input:wasPressed("b") then -- home/text.asm:218 self.msgPrompt = nil + self:blankArrowCell() self.msgHold = true self.current = nil end @@ -1941,7 +1957,7 @@ function BattleState:enter() -- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The -- draw is still gated on the slide having landed, so nothing shows while -- the silhouettes are still coming in. - self.introBalls = true + self.introBalls = "pending" -- SGB: the player-side battle palette while the back pic is up is -- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when -- the intro's SET_PAL_BATTLE runs -- SetPal_Battle, @@ -1997,7 +2013,17 @@ function BattleState:enter() table.insert(self.queue, { waitSound = function() return self.introSfx end }) table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP }) end + -- engine/battle/common_text.asm:25 + if not self.ghost and not self.scopeReveal + and not (require("src.core.GameVersion").isYellow() + and (self.safari or self.demo)) then + self:act(function() self.introBalls = true end) + end self:say(self.introText) + -- engine/battle/common_text.asm:46 + if self.ghost then + self:say(self:romText("_GhostCantBeIDdText", "Darn! The GHOST\ncan't be ID'd!")) + end -- the unveil rides on that same box, before _InitBattleCommon clears the -- intro chrome below (#492) if self.scopeReveal then self:queueScopeReveal() end @@ -4553,6 +4579,7 @@ function BattleState:continueTrapping(user, target) -- through the attacker's final hit (endOfTurn nils it) user.trappingTurns = user.trappingTurns - 1 self:applyDamage(target, user.trapDamage or 1) + self:handleBuildingRage(target) if target.mon.hp <= 0 then self:onFaint(target) end end @@ -4573,6 +4600,7 @@ function BattleState:continueBide(user, target) -- here, after UnleashedEnergyText and before the damage (#375) self:animNext("BIDE", user.isPlayer) self:applyDamage(target, dmg) + self:handleBuildingRage(target) if target.mon.hp <= 0 then self:onFaint(target) end end @@ -4581,7 +4609,7 @@ function BattleState:selfDestruct(user) self:onFaint(user) end --- Applies damage honoring Substitute, Bide storage and Rage; returns the +-- Applies damage honoring Substitute and Bide storage; returns the -- amount that counts as dealt (for recoil/drain). function BattleState:applyDamage(target, dmg) if target.substituteHP then @@ -4601,13 +4629,28 @@ function BattleState:applyDamage(target, dmg) if target.bideTurns then target.bideDamage = (target.bideDamage or 0) + dealt end - if target.rageMove and dealt > 0 then - target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) - self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target))) - end return dealt end +-- engine/battle/move_effects/recoil.asm:28 +function BattleState:applyRecoil(user, recoil) + local mon = user.mon + local before = mon.hp + mon.hp = math.max(0, mon.hp - recoil) + if mon.hp ~= before then self:drainNext(user, mon.hp) end + self:sayNext(self:romText("_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user))) +end + +-- engine/battle/core.asm:4913 +function BattleState:handleBuildingRage(target) + if not target.rageMove or target.mon.hp <= 0 then return end + if (target.stages.attack or 0) >= 6 then return end + self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target))) + for _, m in ipairs(MoveEffects.changeStage(self, target, "attack", 1, false)) do + self:sayNext(m) + end +end + -- --------------------------------------------------------------------- -- fainting / exp / party -- --------------------------------------------------------------------- @@ -5809,14 +5852,20 @@ end -- PlayBattleVictoryMusic (core.asm:959-967) + EndLowHealthAlarm -- (core.asm:864-872): winning stops the low-health alarm and disables -- it for the rest of the battle (wLowHealthAlarmDisabled), then starts --- the victory theme once; gym leaders, Lance and the final rival share +-- the victory theme once; gym leaders and the final rival share -- MUSIC_DEFEATED_GYM_LEADER (core.asm:917-926). function BattleState:playVictoryMusic() require("src.core.Sound").stopLoop("Low_Health_Alarm") self.lowHealthAlarmDisabled = true if self.victoryMusicPlayed then return end self.victoryMusicPlayed = true - local kind = self.musicKind == "final" and "gym" or (self.musicKind or "wild") + local kind = self.musicKind or "wild" + -- engine/battle/core.asm:917 + if kind == "final" or self.isGymLeader then + kind = "gym" + elseif kind == "gym" then + kind = "trainer" + end require("src.core.Music").playVictory(self.data, kind) end @@ -6885,7 +6934,7 @@ function BattleState:drawHUDs(slide) -- rows coming back when the beaten trainer scrolls in (#282): -- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches -- OAM, which ClearSprites emptied when the intro text was dismissed. - local showIntroBalls = self.introBalls and slide == 0 + local showIntroBalls = self.introBalls == true and slide == 0 if showIntroBalls then if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then -- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78 @@ -6975,10 +7024,15 @@ function BattleState:drawTextArea() -- view instead of drawing off-screen at y=144 (#216). local off = self.scrollPx or 0 local ys = { 112, 128 } + -- home/text.asm:264 + local arrowCell = (self.msgWaiting or self.msgPrompt) and off == 0 + and self:arrowOwnsCell() for li, line in ipairs(self.shown or {}) do local y = (ys[li] or 128) + off for i = 1, #line do - drawGlyph(line[i], 8 + (i - 1) * 8, y) + if not (arrowCell and li == 2 and i == 18) then + drawGlyph(line[i], 8 + (i - 1) * 8, y) + end end end -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 599eab57..aefe7cbb 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -26,6 +26,11 @@ local function missBeat(battle, record) battle:waitNext(Timing.MOVE_STATUS_OR_MISS) end +-- engine/battle/core.asm:3224 +local function explodeMissRage(battle, record, target) + if record and record.explode then battle:handleBuildingRage(target) end +end + -- pokered's / text macros print "Enemy " before the enemy -- mon's nickname (home/text.asm PlaceMoveUsersName) local function displayName(b) @@ -131,6 +136,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- runs the explode effect ("even if Explosion or Selfdestruct -- missed, its effect still needs to be activated", core.asm:3223), -- so the user faints against a mid-Fly/Dig target too (#528) + explodeMissRage(battle, record, target) if record and record.onMiss then record.onMiss(ctx, "invulnerable") end return end @@ -153,6 +159,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) battle:sayNext(romText(battle.data, "_AttackMissedText", "%s's\nattack missed!", displayName(user))) + explodeMissRage(battle, record, target) -- Jump Kick crash, Explode self-destruct if record and record.onMiss then record.onMiss(ctx, "accuracy") end user.trappingTurns = nil @@ -205,6 +212,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) battle:sayNext(romText(battle.data, "_DoesntAffectMonText", "It doesn't affect\n%s!", displayName(target))) + explodeMissRage(battle, record, target) if record and record.onMiss then record.onMiss(ctx, "immune") end return end @@ -213,6 +221,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not (record and record.explode) then battle:cancelMoveAnim() end missBeat(battle, record) battle:sayNext(romText(battle.data, "_AttackMissedText", "%s's\nattack missed!", displayName(user))) + explodeMissRage(battle, record, target) if record and record.onMiss then record.onMiss(ctx, "floored") end return end @@ -261,6 +270,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) local totalDealt = 0 local landed, brokeSub = 0, false local critPending, ohkoPending = info.crit, info.ohko + local multi = hits > 1 for h = 1, hits do if target.mon.hp <= 0 then break end local hitRow @@ -310,6 +320,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) damage = dealt, crit = info.crit, typeMult = info.typeMult, }) end + -- engine/battle/core.asm:3243 + if multi then battle:handleBuildingRage(target) end if hadSub and not target.substituteHP then -- AttackSubstitute: breaking the substitute ends a multi-hit move brokeSub = true @@ -317,7 +329,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end hits = landed > 0 and landed or hits - if hits > 1 then + -- engine/battle/core.asm:3242 + if hits > 1 and target.mon.hp > 0 then -- player: _MultiHitText; enemy: _HitXTimesText (always plural) if user.isPlayer then battle:sayNext(romText(battle.data, "_MultiHitText", "Hit the enemy\n%d times!", hits)) @@ -330,13 +343,15 @@ function EffectRegistry.runDamaging(battle, ctx, record) ctx.rawDamage, ctx.totalDealt = dmg, totalDealt ctx.brokeSub, ctx.hits = brokeSub, hits ctx.hitSfx = hitSfx + local explode = record and record.explode + if not multi and explode then battle:handleBuildingRage(target) end if record and record.afterDamage then record.afterDamage(ctx, totalDealt) - elseif moveInst.struggle then - local recoil = math.max(1, math.floor(totalDealt / 2)) - battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user))) - battle:applyDamage(user, recoil) + elseif moveInst.struggle and not brokeSub then + battle:applyRecoil(user, math.max(1, math.floor(totalDealt / 2))) end + -- engine/battle/core.asm:3243 + if not multi and not explode then battle:handleBuildingRage(target) end -- secondary side effects (blocked by fainting) if record and record.run and record.kind ~= "primary" diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index befa81ad..2725644e 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -522,11 +522,14 @@ end -- engine/battle/core.asm ApplyDamageToEnemyPokemon local function drainHalf(label, text) return function(ctx) + -- engine/battle/core.asm:4899 + if ctx.brokeSub then return end local heal = math.max(1, math.floor(ctx.totalDealt / 2)) ctx.battle.lastDamage = heal local mon = ctx.user.mon mon.hp = math.min(mon.stats.hp, mon.hp + heal) - ctx.drain() + -- engine/battle/move_effects/drain_hp.asm:81 + ctx.battle:drainNext(ctx.user, mon.hp) -- `text` arrives as a source string (Strings.source at the call -- site keeps it in the catalog); the ROM's own line wins when the -- import carries it, and both are resolved here, at use time @@ -606,11 +609,12 @@ MoveEffects.full = { RECOIL_EFFECT = { afterDamage = function(ctx) + -- engine/battle/core.asm:4899 + if ctx.brokeSub then return end -- engine/battle/move_effects/recoil.asm local recoil = math.max(1, math.floor(ctx.totalDealt / (ctx.moveInst.struggle and 2 or 4))) - ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user))) - ctx.battle:applyDamage(ctx.user, recoil) + ctx.battle:applyRecoil(ctx.user, recoil) end, }, DRAIN_HP_EFFECT = { diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 14332147..2df412e4 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -149,7 +149,7 @@ end -- the party ball rows DrawAllPokeballs puts up with the intro text, moved -- out to the wide screen's own corners local function drawIntroBalls(battle) - if not battle.introBalls then return end + if battle.introBalls ~= true then return end if battle.enemyParty and (battle.kind == "trainer" or battle.kind == "link") then battle:drawBallRow(battle.enemyParty, 88, 40, -8) diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index cbf79db7..7d33cf2d 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -362,7 +362,7 @@ function BattleCheckpoint.restore(game, checkpoint, copy) battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil - battle.hitSfxWait = nil + battle.hitSfxWait, battle.drainOnly = nil, nil battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil battle.player.shownHP, battle.player.shownStatus = diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index d9d737c6..ef7b1d7f 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -56,17 +56,296 @@ end -- menu) can exclude badges the same way the bag does Bag.isBadge = isBadge +local MAX_STACK = 99 + +local function splits(id, data) + if require("src.core.GameVersion").generation() ~= 1 then return false end + data = data or require("src.core.Data") + local def = data and data.items and data.items[id] + return not (def and def.pocket) +end + +local function stackList(stacks, id, total) + local list = stacks and stacks[id] + if type(list) ~= "table" or #list == 0 or type(total) ~= "number" then return nil end + local sum = 0 + for i = 1, #list do + local c = list[i] + if type(c) ~= "number" or c < 1 or c > MAX_STACK or c % 1 ~= 0 then return nil end + sum = sum + c + end + if sum ~= total then return nil end + return list +end + +-- engine/items/inventory.asm:59 +local function slotsFor(id, n, data, stacks) + if not n or isBadge(id) then return 0 end + if type(n) ~= "number" then return 1 end + if n <= 0 then return 0 end + if not splits(id, data) then return 1 end + local list = stackList(stacks, id, n) + if list then return #list end + if n <= MAX_STACK then return 1 end + return math.ceil(n / MAX_STACK) +end +Bag.slotsFor = slotsFor + +local function derivedCount(total, k, n) + if type(total) ~= "number" or n <= 1 then return total end + if k < n then return MAX_STACK end + return total - MAX_STACK * (n - 1) +end + +local function isDerived(counts) + for k = 1, #counts - 1 do + if counts[k] ~= MAX_STACK then return false end + end + return true +end + +local function countsOf(store, id, data, stacks) + local total = store[id] + local n = slotsFor(id, total, data, stacks) + local list = stackList(stacks, id, total) + local out = {} + for k = 1, n do out[k] = (list and list[k]) or derivedCount(total, k, n) end + return out +end + +local function setCounts(store, id, counts, stacks) + local sum = 0 + for i = 1, #counts do sum = sum + counts[i] end + store[id] = sum > 0 and sum or nil + if stacks then + stacks[id] = (#counts > 1 and not isDerived(counts)) and counts or nil + end +end + +local function occurrences(order, id) + local at = {} + for i = 1, #order do + if order[i] == id then at[#at + 1] = i end + end + return at +end + +local function byIndex(data) + data = data or require("src.core.Data") + local items = data and data.items + local function key(id) + local def = items and items[id] + return (def and (def.index or def.itemId)) or math.huge + end + return function(a, b) + local ia, ib = key(a), key(b) + if ia ~= ib then + if type(ia) == type(ib) then return ia < ib end + return tostring(ia) < tostring(ib) + end + if type(a) == type(b) then return a < b end + return tostring(a) < tostring(b) + end +end + +local function normalize(store, order, data, stacks) + if stacks then + for id in pairs(stacks) do + local list = splits(id, data) and stackList(stacks, id, store[id]) + if not list or #list < 2 or isDerived(list) then stacks[id] = nil end + end + end + local seen, w = {}, 0 + for i = 1, #order do + local id = order[i] + local k = (seen[id] or 0) + 1 + if k <= slotsFor(id, store[id], data, stacks) then + seen[id] = k + w = w + 1 + order[w] = id + end + end + for i = #order, w + 1, -1 do order[i] = nil end + local missing = {} + for id, n in pairs(store) do + for _ = (seen[id] or 0) + 1, slotsFor(id, n, data, stacks) do + missing[#missing + 1] = id + end + end + if #missing > 1 then table.sort(missing, byIndex(data)) end + for _, id in ipairs(missing) do order[#order + 1] = id end + return order +end +Bag.normalize = normalize + +local function storeSlots(store, data, keep, stacks) + local n = 0 + for id, count in pairs(store or {}) do + if not keep or keep(id) then n = n + slotsFor(id, count, data, stacks) end + end + return n +end +Bag.storeSlots = storeSlots + +function Bag.stackRows(store, order, data, stacks) + local rows, seen = {}, {} + for i, id in ipairs(order) do + local k = (seen[id] or 0) + 1 + seen[id] = k + local total = store[id] + local list = stackList(stacks, id, total) + rows[#rows + 1] = { id = id, slot = k, index = i, + count = (list and list[k]) + or derivedCount(total, k, slotsFor(id, total, data, stacks)) } + end + return rows +end + +function Bag.setRows(store, order, rows, data, stacks) + local per = {} + for i = 1, #rows do + local id = rows[i].id + order[i] = id + if splits(id, data) and type(rows[i].count) == "number" then + local counts = per[id] or {} + counts[#counts + 1] = rows[i].count + per[id] = counts + end + end + for i = #order, #rows + 1, -1 do order[i] = nil end + for id, counts in pairs(per) do setCounts(store, id, counts, stacks) end +end + +-- engine/items/inventory.asm:7 +function Bag.addTo(store, order, id, qty, data, used, cap, stacks) + qty = qty or 1 + local old = store[id] + if type(old) == "number" and old <= 0 then old = nil end + if isBadge(id) then + store[id] = (old or 0) + qty + return true + end + if not splits(id, data) then + if not old and used >= cap then return false end + if (old or 0) + qty > MAX_STACK then return false end + store[id] = (old or 0) + qty + if not old then order[#order + 1] = id end + return true + end + local free = cap - used + local counts = old and countsOf(store, id, data, stacks) or {} + local left = qty + for k = 1, #counts do + local sum = counts[k] + left + if sum < MAX_STACK + 1 then + counts[k] = sum + left = 0 + break + end + -- engine/items/inventory.asm:72 + if free <= 0 then return false end + counts[k] = MAX_STACK + left = sum - MAX_STACK + end + while left > 0 do + -- engine/items/inventory.asm:44 + if free <= 0 then return false end + counts[#counts + 1] = math.min(left, MAX_STACK) + left = left - MAX_STACK + end + local before = #occurrences(order, id) + setCounts(store, id, counts, stacks) + for _ = before + 1, #counts do order[#order + 1] = id end + return true +end + +-- engine/items/inventory.asm:100 +function Bag.removeFrom(store, order, id, qty, data, slot, stacks) + qty = qty or 1 + local old = store[id] + if old ~= nil and type(old) ~= "number" then return end + if not old or old <= 0 then + store[id] = nil + if order then + for i = #order, 1, -1 do + if order[i] == id then table.remove(order, i) end + end + end + return + end + if isBadge(id) or not splits(id, data) then + local left = old - qty + store[id] = left > 0 and left or nil + if order and not store[id] then + for i = #order, 1, -1 do + if order[i] == id then table.remove(order, i) end + end + end + return + end + local counts = countsOf(store, id, data, stacks) + local k = (slot and counts[slot]) and slot or 1 + local take = math.min(qty, counts[k]) + counts[k] = counts[k] - take + qty = qty - take + for i = 1, #counts do + if qty <= 0 then break end + take = math.min(qty, counts[i]) + counts[i] = counts[i] - take + qty = qty - take + end + if order then + local at = occurrences(order, id) + for i = #at, 1, -1 do + if counts[i] ~= nil and counts[i] <= 0 then table.remove(order, at[i]) end + end + end + local kept = {} + for i = 1, #counts do + if counts[i] > 0 then kept[#kept + 1] = counts[i] end + end + setCounts(store, id, kept, stacks) +end + +-- engine/menus/swap_items.asm:78 +function Bag.swapSlots(store, order, first, second, data, stacks) + if first == second or not order[first] or not order[second] then return false end + local rows = Bag.stackRows(store, order, data, stacks) + local a, b = rows[first], rows[second] + if a.id ~= b.id or type(a.count) ~= "number" or type(b.count) ~= "number" + or not splits(a.id, data) then + rows[first], rows[second] = b, a + else + -- engine/menus/swap_items.asm:98 + local sum = a.count + b.count + if sum < MAX_STACK + 1 then + b.count = sum + table.remove(rows, first) + else + a.count = sum - MAX_STACK + b.count = MAX_STACK + end + end + Bag.setRows(store, order, rows, data, stacks) + return true +end + +local function stacksOf(save, key) + local t = save[key] + if type(t) ~= "table" then t = {} end + return t +end + +local function keepStacks(save, key, t) + save[key] = next(t) ~= nil and t or nil +end + -- Occupied slots, of one pocket when named or of the whole inventory when not. function Bag.slots(save, data, pocket) if not save or not save.inventory then return 0 end - local n = 0 - for id in pairs(save.inventory) do - if not isBadge(id) - and (not pocket or pocketOf(id, data) == pocket) then - n = n + 1 - end - end - return n + return storeSlots(save.inventory, data, pocket and function(id) + return pocketOf(id, data) == pocket + end, save.bagStacks) end -- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for @@ -76,40 +355,67 @@ function Bag.order(save, data) save.inventory = save.inventory or {} 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, function(a, b) - local ia = (items and items[a] and (items[a].index or items[a].itemId)) or math.huge - local ib = (items and items[b] and (items[b].index or items[b].itemId)) or math.huge - if ia ~= ib then - if type(ia) == type(ib) then return ia < ib end - return tostring(ia) < tostring(ib) - end - if type(a) == type(b) then return a < b end - return tostring(a) < tostring(b) - end) save.bagOrder = order end -- drop stale ids, append unknown ones (defensive against direct -- inventory writes) - local seen = {} - for i = #order, 1, -1 do - local id = order[i] - if not save.inventory[id] or seen[id] then - table.remove(order, i) - else - seen[id] = true - end - end - for id in pairs(save.inventory) do - if not isBadge(id) and not seen[id] then table.insert(order, id) end - end + local stacks = stacksOf(save, "bagStacks") + normalize(save.inventory, order, data, stacks) + keepStacks(save, "bagStacks", stacks) return order end +function Bag.rows(save, data) + if not save then return {} end + local order = Bag.order(save, data) + return Bag.stackRows(save.inventory, order, data, save.bagStacks) +end + +function Bag.swap(save, first, second, data) + if not save or not save.inventory then return false end + local order = Bag.order(save, data) + local stacks = stacksOf(save, "bagStacks") + local ok = Bag.swapSlots(save.inventory, order, first, second, data, stacks) + keepStacks(save, "bagStacks", stacks) + return ok +end + +-- ram/wram.asm:1895 +function Bag.pcOrder(save, data) + if not save then return {} end + save.pcItems = save.pcItems or {} + local order = type(save.pcOrder) == "table" and save.pcOrder or {} + save.pcOrder = order + local stacks = stacksOf(save, "pcStacks") + normalize(save.pcItems, order, data, stacks) + keepStacks(save, "pcStacks", stacks) + return order +end + +function Bag.pcRows(save, data) + if not save then return {} end + local order = Bag.pcOrder(save, data) + return Bag.stackRows(save.pcItems, order, data, save.pcStacks) +end + +function Bag.pcAdd(save, id, qty, data, cap) + if not save then return false end + local order = Bag.pcOrder(save, data) + local stacks = stacksOf(save, "pcStacks") + local ok = Bag.addTo(save.pcItems, order, id, qty, data, + storeSlots(save.pcItems, data, nil, stacks), cap or 50, stacks) + keepStacks(save, "pcStacks", stacks) + return ok +end + +function Bag.pcRemove(save, id, qty, data, slot) + if not save or not save.pcItems then return end + local stacks = stacksOf(save, "pcStacks") + Bag.removeFrom(save.pcItems, save.pcOrder, id, qty, data, slot, stacks) + keepStacks(save, "pcStacks", stacks) +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) @@ -133,49 +439,33 @@ function Bag.move(save, id, pocket, toIndex, data) 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). +-- engine/items/inventory.asm:59 function Bag.add(save, id, qty, data) if not save then return false end save.inventory = save.inventory or {} local inv = save.inventory + if isBadge(id) then + inv[id] = (inv[id] or 0) + (qty or 1) + return true + end -- Only the item's OWN pocket has to have room -- a full ITEM pocket does not -- keep a KEY_ITEM or an HM out, which is the whole point of pockets. local pocket = pocketOf(id, data) - if not inv[id] and not isBadge(id) - and Bag.slots(save, data, pocket) >= Bag.capacity(data, pocket) then - return false - end - if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then - return false - end - -- Insert into the order BEFORE the inventory write: Bag.order's defensive - -- append reads save.inventory, so writing first made it add the id and the - -- table.insert below add it again -- one pickup, two bag rows, until the - -- next order() pass deduped it (and a save taken in between kept both). - local isNew = not inv[id] - if isNew and not isBadge(id) then - table.insert(Bag.order(save, data), id) - end - inv[id] = (inv[id] or 0) + (qty or 1) - return true + local order = Bag.order(save, data) + local stacks = stacksOf(save, "bagStacks") + local ok = Bag.addTo(inv, order, id, qty, data, + Bag.slots(save, data, pocket), Bag.capacity(data, pocket), stacks) + keepStacks(save, "bagStacks", stacks) + return ok end -- Remove qty (default 1); clears the slot and its order entry at zero. -function Bag.remove(save, id, qty) +-- engine/menus/pc.asm:118 +function Bag.remove(save, id, qty, data, slot) if not save or not save.inventory then return end - local inv = save.inventory - inv[id] = (inv[id] or 0) - (qty or 1) - if inv[id] <= 0 then - inv[id] = nil - local order = save.bagOrder - if order then - for i, oid in ipairs(order) do - if oid == id then table.remove(order, i) break end - end - end - end + local stacks = stacksOf(save, "bagStacks") + Bag.removeFrom(save.inventory, save.bagOrder, id, qty, data, slot, stacks) + keepStacks(save, "bagStacks", stacks) end return Bag diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index cc91ef88..0d5010ec 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -159,9 +159,52 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) nextStep() end +local function clearScreenLayer() + local layer = { isOpaque = true, evoClear = true } + layer.update = function() end + layer.draw = function() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + -- engine/movie/evolution.asm:68 + layer.sgbPalettes = function(_, g) + local P = require("src.render.PaletteFX") + local c = layer.species and P.monPal(g.data, layer.species) + if c then return { P.whole(c) } end + return P.wholeNamed(g.data, "MEWMON") + end + return layer +end + +-- engine/pokemon/evos_moves.asm:156 +function Evolution.clearScreen(game, species) + while game.stack:top() and game.stack:top().evoIntro do game.stack:pop() end + local top = game.stack:top() + if not (top and top.evoClear) then + top = clearScreenLayer() + game.stack:push(top) + end + if species then top.species = species end +end + +local function dropClearScreen(game) + local top = game.stack:top() + while top and (top.evoClear or top.evoIntro) do + game.stack:pop() + top = game.stack:top() + end +end + +-- engine/pokemon/evos_moves.asm:245 +local function finishEvolution(game) + dropClearScreen(game) + -- engine/link/cable_club.asm:290 + Music.restoreMap(game.data) +end + -- Play the evolution movie (flashing forms), then apply + text. -- Headless (no real graphics) falls back to the plain text flow. -function Evolution.evolve(game, mon, newSpecies, onDone, via) +function Evolution.evolve(game, mon, newSpecies, onDone, via, batch) local oldName = mon.nickname or game.data.pokemon[mon.species].name -- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11 -- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134) @@ -181,8 +224,11 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via) -- forward `via` so trade evolutions stay non-cancelable while -- others accept B (evos_moves.asm:72-75) (#213) Screens.push(game, "EvolutionState", mon, newSpecies, function() - -- the result/cancel box owns the intro box's pop (#1596) - if game.stack:top() == intro then game.stack:pop() end + if batch then + Evolution.clearScreen(game) + else + finishEvolution(game) + end if onDone then onDone() end end, via) end @@ -190,6 +236,7 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via) game.stack:push(hold) end, } }) + intro.evoIntro = true game.stack:push(intro) return end @@ -201,10 +248,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via) .. romText(game.data, "_IntoText", "\ninto %s!", game.data.pokemon[newSpecies].name) game.stack:push(TextBox.new(game, msg, function() - Music.restoreMap(game.data) -- re-run the evolved species' level-up learn check before onDone -- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12) - Evolution.learnEvolutionMoves(game, mon, onDone) + Evolution.learnEvolutionMoves(game, mon, function() + if not batch then finishEvolution(game) end + if onDone then onDone() end + end) end, TextBox.soundOpts(game, "Get_Item2"))) end @@ -245,10 +294,11 @@ function Evolution.checkParty(game, onDone, leveledUp) i = i + 1 local p = pending[i] if not p then + dropClearScreen(game) if onDone then onDone() end return end - Evolution.evolve(game, p.mon, p.to, nextOne, p.via) + Evolution.evolve(game, p.mon, p.to, nextOne, p.via, true) end nextOne() return #pending diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 3801efa8..e49f7aa6 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -251,6 +251,12 @@ function PaletteFX.ogObjNormal() return OG_OBJ_NORMAL, "gbcobjnormal_soft" end +-- home/fade.asm:68 +function PaletteFX.ogObjWorld() + if darkWorld or fadeObpMap then return PaletteFX.ogObj() end + return PaletteFX.ogObjNormal() +end + -- The DMG object ramp every mode except OG RED bakes onto overworld sprites, -- plus its cache group (same two-value contract as ogObj). Entry 1 is never -- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index a730c278..15e0932a 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -319,10 +319,10 @@ function SpriteRenderer:resolveImage() local colors, group = PaletteFX.spriteObp(self.def, self.seed) if colors then return getObpImage(self.def.image, colors, group) end elseif PaletteFX.usesSpriteObp() then - -- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj + -- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObjWorld -- returns colors + a version-distinct cache group so the two never -- collide in obpCache) -- see issue #155 - return getObpImage(self.def.image, PaletteFX.ogObj()) + return getObpImage(self.def.image, PaletteFX.ogObjWorld()) end -- Every other mode (SGB and the mono/inverted novelties) leaves the sprite -- in DMG shades so the zone shader colors it out of the map's own palette, @@ -435,11 +435,11 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then -- OG RED (GBC boot-ROM look): every OBJ wears the one global object -- palette -- green over Red's red background, pink over Blue's blue - -- background (PaletteFX.ogObj, #155). The BG zone shader still runs over + -- background (PaletteFX.ogObjWorld, #155). The BG zone shader still runs over -- the world canvas, so the baked sprite is queued for a post-zone redraw -- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on -- top. - image = getObpImage(self.def.image, PaletteFX.ogObj()) + image = getObpImage(self.def.image, PaletteFX.ogObjWorld()) redraw = true else -- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which @@ -529,7 +529,7 @@ function SpriteRenderer:drawTile(path, x, y, flip, quad) local colors, group = PaletteFX.spriteObp(self.def, self.seed) if colors then image = getObpImage(path, colors, group) end elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then - image, redraw = getObpImage(path, PaletteFX.ogObj()), true + image, redraw = getObpImage(path, PaletteFX.ogObjWorld()), true else image = getObpImage(path, PaletteFX.dmgObj()) end diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 786120f4..13d1e7e1 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -444,6 +444,22 @@ function TextBox:arrowPos() gold and (self.boxTy + self.boxTh - 1) * 8 or self.line2Y end +-- home/text.asm:270 +function TextBox:blankArrowCell() + if not self.fixedGen1Rows then return end + local line = self.shown[#self.shown] + if not line or #self.shown < 2 then return end + local ax = self:arrowPos() + local pen = self.textX + for i, code in ipairs(line) do + if pen == ax then + line[i] = 0x7F + return + end + pen = pen + Font.advanceOf(code) + end +end + -- scripts/MtMoonPokecenter.asm:30 function TextBox:moneyVisible() if not self.money then return false end @@ -640,6 +656,7 @@ function step(self, dt) if self.contAdvance then -- ContText / ManualTextScroll: keep the box, scroll one line self.contAdvance = false + self:blankArrowCell() self.lineIndex = self.lineIndex + 1 self:beginLine() -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment @@ -760,13 +777,20 @@ function TextBox:draw() -- nothing in the original is ever drawn between two rows. local off = self.scrollPx or 0 local ys = { self.line1Y, self.line2Y } + -- home/text.asm:264 + local coverX, coverY + if self.fixedGen1Rows and self:arrowVisible() then + coverX, coverY = arrowX, arrowY + end for i, line in ipairs(self.shown) do local y = (ys[i] or self.line2Y) + (i == 1 and off or 0) -- the pen advances per glyph, matching the pixel budget paginate -- measured with; every fixed-width page still lands on the 8px grid local pen = self.textX for _, code in ipairs(line) do - drawGlyph(code, pen, y) + if not (pen == coverX and y == coverY) then + drawGlyph(code, pen, y) + end pen = pen + Font.advanceOf(code) end end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 72c4938b..171a5a02 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -733,48 +733,92 @@ local function getKeyedTile(self, tile) return img or nil end -function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY) - local ty = cy * 2 + 1 - for i = 0, 1 do - local tx = cx * 2 + i - local tile = self.map:tileAt(tx, ty) - local keyed = tile and self.gbcCtx and getKeyedTile(self, tile) - if keyed then - love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY) - else - local quad = self.quads[tile] - if quad then - love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY) - end +-- data/sprites/facings.asm:51 +-- engine/gfx/sprite_oam.asm:127 +function TileRenderer.feetStrip(px, py, sprite) + local fw = sprite and sprite.frameWidth or 16 + local fh = sprite and sprite.frameHeight or 16 + local ax = sprite and sprite.anchorX or fw / 2 + local ay = sprite and sprite.anchorY or fh + local x0 = math.floor(px + 8 - ax) + local top = math.floor(py + 12 - ay) + local y0 = top + math.max(0, fh - 8) + return x0, y0, x0 + fw, y0 + math.min(8, fh) +end + +function TileRenderer.eachStripSpan(x0, y0, x1, y1, fn, a, b, c, d) + for ty = math.floor(y0 / 8), math.floor((y1 - 1) / 8) do + local top, bottom = math.max(y0, ty * 8), math.min(y1, ty * 8 + 8) + for tx = math.floor(x0 / 8), math.floor((x1 - 1) / 8) do + local left, right = math.max(x0, tx * 8), math.min(x1, tx * 8 + 8) + fn(tx, ty, left, top, left - tx * 8, top - ty * 8, + right - left, bottom - top, a, b, c, d) end end end --- redraw a cell's bottom tile row (tall grass hides the lower half of --- sprites standing in it, like the GB sprite-priority trick) -function TileRenderer:drawCellBottom(cx, cy, camX, camY) +local function stripQuad(self, image, tile, ox, oy, w, h) + local cache = self.stripQuads + if not cache then + cache = {} + self.stripQuads = cache + end + local sub = ((ox * 9 + w) * 8 + oy) * 9 + h + local key = image == self.image and tile * 6000 + sub or -1 - sub + local q = cache[key] + if q then return q end + local iw, ih = image:getDimensions() + local sx, sy = 0, 0 + if image == self.image then + local perRow = self.map.tileset.tilesPerRow + sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 + end + q = love.graphics.newQuad(sx + ox, sy + oy, w, h, iw, ih) + cache[key] = q + return q +end + +local function drawStripTile(tx, ty, x, y, ox, oy, w, h, self, camX, camY) + local tile = self.map:tileAt(tx, ty) + if not (tile and self.quads[tile]) then return end + local img = self.gbcCtx and getKeyedTile(self, tile) or self.image + love.graphics.draw(img, stripQuad(self, img, tile, ox, oy, w, h), + x - camX, y - camY) +end + +local function markStripTile(tx, ty, x, y, ox, oy, w, h, self, camX, camY, colors) + local tile = self.map:tileAt(tx, ty) + if not (tile and self.quads[tile]) then return end + PaletteFX.markSpriteRedraw(self.image, + stripQuad(self, self.image, tile, ox, oy, w, h), + x - math.floor(camX), y - math.floor(camY), + 1, colors, true) +end + +function TileRenderer:drawStripRaw(x0, y0, x1, y1, camX, camY) + TileRenderer.eachStripSpan(x0, y0, x1, y1, drawStripTile, self, camX, camY) +end + +function TileRenderer:drawStrip(x0, y0, x1, y1, camX, camY) -- the RED++ path is pre-keyed; the white test would be a no-op there at -- best, and a false hit on some other group's near-white color 0 at worst local shader = not self.gbcCtx and getColor0KeyShader() or nil if shader then love.graphics.setShader(shader) end - self:drawCellBottomRaw(cx, cy, camX, camY) + self:drawStripRaw(x0, y0, x1, y1, camX, camY) if shader then love.graphics.setShader() end end --- queue the same bottom tile row for the post-zone sprite-redraw pass --- (GBC mode: OBP-baked sprites replay after the zone shader, so the --- grass patch that hides their feet must replay over them, colorized --- with the map's palette and color-0 keyed) -function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors) - local ty = cy * 2 + 1 - for i = 0, 1 do - local tx = cx * 2 + i - local quad = self.quads[self.map:tileAt(tx, ty)] - if quad then - PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX), - ty * 8 - math.floor(camY), 1, colors, true) - end - end +function TileRenderer:markStripRedraw(x0, y0, x1, y1, camX, camY, colors) + TileRenderer.eachStripSpan(x0, y0, x1, y1, markStripTile, + self, camX, camY, colors) +end + +function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY) + self:drawStripRaw(cx * 16, cy * 16 + 8, cx * 16 + 16, cy * 16 + 16, camX, camY) +end + +function TileRenderer:drawCellBottom(cx, cy, camX, camY) + self:drawStrip(cx * 16, cy * 16 + 8, cx * 16 + 16, cy * 16 + 16, camX, camY) end @@ -1024,6 +1068,10 @@ function TileRenderer:releaseBatches() for _, q in pairs(self.quads) do safeRelease(q) end self.quads = nil end + if self.stripQuads then + for _, q in pairs(self.stripQuads) do safeRelease(q) end + self.stripQuads = nil + end if self.anims then for _, a in ipairs(self.anims) do safeRelease(a.batch); a.batch = nil diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 997ae8fa..1b2b2a52 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -845,43 +845,91 @@ local function decodeItemRows(bytes, off, capacity) return rows end +local function stackCounts(stacks, id, qty) + local list = type(stacks) == "table" and stacks[id] + if type(list) ~= "table" or #list < 2 then return nil end + local sum = 0 + for i = 1, #list do + local c = list[i] + if type(c) ~= "number" or c < 1 or c > 99 or c % 1 ~= 0 then return nil end + sum = sum + c + end + if sum ~= qty then return nil end + return list +end + local function foldRows(rows, cw) - local inventory, order = {}, {} + local inventory, order, counts = {}, {}, {} for _, r in ipairs(rows) do local id = cw.itemsByIndex[r[1]] if id and not BADGE_BY_BIT_SET[id] and r[2] > 0 then - if not inventory[id] then order[#order + 1] = id end + order[#order + 1] = id inventory[id] = (inventory[id] or 0) + r[2] + local list = counts[id] or {} + list[#list + 1] = r[2] + counts[id] = list end end - return inventory, order + local stacks = {} + for id, list in pairs(counts) do + local derived = true + for k = 1, #list - 1 do + if list[k] ~= 99 then derived = false end + end + if not derived and stackCounts({ [id] = list }, id, inventory[id]) then stacks[id] = list end + end + return inventory, order, next(stacks) ~= nil and stacks or nil end -local function modelRows(inventory, order, capacity, cw) - local rows, seen, ids, rest = {}, {}, {}, {} - for _, id in ipairs(type(order) == "table" and order or {}) do - if not seen[id] then seen[id] = true; ids[#ids + 1] = id end +-- engine/items/inventory.asm:64 +local function modelRows(inventory, order, capacity, cw, stacks) + inventory = inventory or {} + local need, lists = {}, {} + local function slots(id) + if need[id] == nil then + local qty = tonumber(inventory[id]) + qty = qty and math.floor(qty) or 0 + lists[id] = stackCounts(stacks, id, qty) + need[id] = (qty > 0 and cw.itemsIndex[id] and not BADGE_BY_BIT_SET[id]) + and (lists[id] and #lists[id] or math.ceil(qty / 99)) or 0 + end + return need[id] end - for id in pairs(inventory or {}) do - if not seen[id] then rest[#rest + 1] = id end + local kept, have = {}, {} + for _, id in ipairs(type(order) == "table" and order or {}) do + if (have[id] or 0) < slots(id) then + have[id] = (have[id] or 0) + 1 + kept[#kept + 1] = id + end + end + local ids, at = {}, {} + for _, id in ipairs(kept) do + at[id] = (at[id] or 0) + 1 + ids[#ids + 1] = id + if at[id] == have[id] then + for _ = have[id] + 1, slots(id) do ids[#ids + 1] = id end + end + end + local rest = {} + for id in pairs(inventory) do + if not have[id] and slots(id) > 0 then rest[#rest + 1] = id end end table.sort(rest, function(a, b) local ia, ib = cw.itemsIndex[a] or 1e9, cw.itemsIndex[b] or 1e9 if ia ~= ib then return ia < ib end return tostring(a) < tostring(b) end) - for _, id in ipairs(rest) do ids[#ids + 1] = id end + for _, id in ipairs(rest) do + for _ = 1, slots(id) do ids[#ids + 1] = id end + end + local rows, k = {}, {} for _, id in ipairs(ids) do - local qty = tonumber((inventory or {})[id]) - local idx = cw.itemsIndex[id] - if qty and qty > 0 and idx and not BADGE_BY_BIT_SET[id] then - qty = math.floor(qty) - while qty > 0 and #rows < capacity do - local q = math.min(qty, 99) - rows[#rows + 1] = { idx, q } - qty = qty - q - end - end + if #rows >= capacity then break end + k[id] = (k[id] or 0) + 1 + local n = slots(id) + local q = lists[id] and lists[id][k[id]] + or (k[id] < n and 99 or math.floor(tonumber(inventory[id])) - 99 * (n - 1)) + rows[#rows + 1] = { cw.itemsIndex[id], q } end return rows end @@ -904,14 +952,14 @@ end local function decodeItems(bytes, off, capacity, cw) local rows = decodeItemRows(bytes, off, capacity) - local inventory, order = foldRows(rows, cw) + local inventory, order, stacks = foldRows(rows, cw) local carrier = nil - if not sameRows(modelRows(inventory, order, capacity, cw), rows) then carrier = rows end - return inventory, order, carrier + if not sameRows(modelRows(inventory, order, capacity, cw, stacks), rows) then carrier, stacks = rows, nil end + return inventory, order, carrier, stacks end -local function encodeItems(buf, countOff, off, capacity, inventory, order, carrier, cw) - local rows = modelRows(inventory, order, capacity, cw) +local function encodeItems(buf, countOff, off, capacity, inventory, order, carrier, cw, stacks) + local rows = modelRows(inventory, order, capacity, cw, stacks) carrier = validRows(carrier) if carrier then local inv, ord = foldRows(carrier, cw) @@ -1023,8 +1071,8 @@ function GenSave.decode(bytes, data, opts) if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end end - save.inventory, save.bagOrder, save.cartBag = decodeItems(bytes, O.bagItems, 20, cw) - save.pcItems, save.pcOrder, save.cartPc = decodeItems(bytes, O.pcItems, 50, cw) + save.inventory, save.bagOrder, save.cartBag, save.bagStacks = decodeItems(bytes, O.bagItems, 20, cw) + save.pcItems, save.pcOrder, save.cartPc, save.pcStacks = decodeItems(bytes, O.pcItems, 50, cw) -- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua), -- set AFTER the bag decode since that call replaces save.inventory @@ -1424,8 +1472,8 @@ function GenSave.encode(save, data, template) -- wBagItems (they only ever live in wObtainedBadges, already encoded -- above), so they must be filtered out here or they'd corrupt the bag -- with bogus "badge items". - encodeItems(buf, O.numBagItems, O.bagItems, 20, save.inventory or {}, save.bagOrder, save.cartBag, cw) - encodeItems(buf, O.numPcItems, O.pcItems, 50, save.pcItems or {}, save.pcOrder, save.cartPc, cw) + encodeItems(buf, O.numBagItems, O.bagItems, 20, save.inventory or {}, save.bagOrder, save.cartBag, cw, save.bagStacks) + encodeItems(buf, O.numPcItems, O.pcItems, 50, save.pcItems or {}, save.pcOrder, save.cartPc, cw, save.pcStacks) local flags = type(save.flags) == "table" and save.flags or {} local events = data.eventFlags diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 6194d39a..5b04cea3 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -19,7 +19,8 @@ local BAG_RETURN_WHITE = 19 -- acquisition order like wBagItems (Bag.order), not alphabetical local function buildItems(game) local items = {} - for _, id in ipairs(Bag.order(game.save)) do + for _, row in ipairs(Bag.rows(game.save, game.data)) do + local id = row.id local def = game.data.items[id] -- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns: -- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641) @@ -27,7 +28,9 @@ local function buildItems(game) table.insert(items, { value = id, label = def and def.name or id, - count = (not unsellable) and game.save.inventory[id] or nil, + count = (not unsellable) and row.count or nil, + slot = row.slot, + stack = row.count, }) end -- the $ff terminator's row: CANCEL is selectable and exits like B @@ -37,9 +40,12 @@ local function buildItems(game) end local function consume(game, id, list) - Bag.remove(game.save, id, 1) + local before = #Bag.rows(game.save, game.data) + local row = list and list.items and list.items[list.index] + local slot = row and row.value == id and row.slot or nil + Bag.remove(game.save, id, 1, game.data, slot) -- engine/items/inventory.asm:131-136 - if not game.save.inventory[id] then + if #Bag.rows(game.save, game.data) < before then game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0 if list then list.index, list.scroll = 1, 0 end end @@ -362,13 +368,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local function removeUsed() consume(game, id, list) -- refresh counts in the list - for i, it in ipairs(list.items) do - if it.value == id then - local left = game.save.inventory[id] - if left then it.count = left else table.remove(list.items, i) end - break - end - end + list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) end if not battle and not target and not picker then @@ -516,6 +516,8 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end local function pickTargetAndUse(game, battle, id, list) + -- engine/menus/start_sub_menus.asm:416 + if list then list.optionBox = nil end -- pick a target from the party -- the ETHERs and PP UP open the move menu after picking a mon -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move @@ -616,11 +618,13 @@ function BagMenu.new(game, opts) l.swapIndex = l.index return end - local order = Bag.order(game.save) - order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex] + -- engine/menus/swap_items.asm:45 + if l.swapIndex == l.index then return end + Bag.swap(game.save, l.swapIndex, l.index, game.data) l.swapIndex = nil require("src.core.Sound").play(game.data, "Swap") l.items = buildItems(game) + l.index = math.max(1, math.min(l.index, #l.items)) end, onChoose = function(item) -- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110) @@ -632,11 +636,13 @@ function BagMenu.new(game, opts) local id = item.value local def = game.data.items[id] if list.swapIndex then -- A also completes a pending swap - local order = Bag.order(game.save) - order[list.swapIndex], order[list.index] = order[list.index], order[list.swapIndex] + -- engine/menus/swap_items.asm:45 + if list.swapIndex == list.index then return end + Bag.swap(game.save, list.swapIndex, list.index, game.data) list.swapIndex = nil require("src.core.Sound").play(game.data, "Swap") list.items = buildItems(game) + list.index = math.max(1, math.min(list.index, #list.items)) return end if battle then -- no tossing mid-battle @@ -659,8 +665,12 @@ function BagMenu.new(game, opts) -- (engine/menus/start_sub_menus.asm:330-337; home/window.asm:193-206) list.hollowIndex = list.index local Menu = require("src.ui.Menu") - game.stack:push(Menu.new(game, { + local optionBox + optionBox = Menu.new(game, { { label = Strings("USE"), onSelect = function() + -- engine/menus/start_sub_menus.asm:362 + optionBox.hollowIndex = optionBox.index + list.optionBox = optionBox useItem(game, battle, id, list) end }, { label = Strings("TOSS"), keepOpen = true, onSelect = function() @@ -670,12 +680,13 @@ function BagMenu.new(game, opts) -- engine/menus/start_sub_menus.asm:298-300, 438-439 local function itemMenuLoop(pops) for _ = 1, pops do game.stack:pop() end - if not game.save.inventory[id] then + local fresh = buildItems(game) + if #fresh < #list.items then -- engine/items/inventory.asm:131 game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0 list.index, list.scroll = 1, 0 end - list.items = buildItems(game) + list.items = fresh list.index = math.min(list.index, math.max(1, #list.items)) end -- KeyItemFlags + HMs decide tossability (not price: @@ -689,7 +700,7 @@ function BagMenu.new(game, opts) end local QuantityBox = require("src.ui.QuantityBox") game.stack:push(QuantityBox.new(game, { - max = game.save.inventory[id] or 1, + max = item.stack or game.save.inventory[id] or 1, keepOpen = true, onDone = function(qty) if not qty then itemMenuLoop(2) return end @@ -703,7 +714,7 @@ function BagMenu.new(game, opts) game.stack:push(ChoiceBox.new(game, function(yes) game.stack:pop() if not yes then itemMenuLoop(2) return end - Bag.remove(game.save, id, qty) + Bag.remove(game.save, id, qty, game.data, item.slot) showMessages(game, { ((t._ThrewAwayItemText or Strings("Threw away\n%s.", name)) :gsub("{RAM:wNameBuffer}", name)) }, @@ -713,7 +724,8 @@ function BagMenu.new(game, opts) end, })) end }, - }, { tx = 13, ty = 10, tw = 7, th = 5 })) + }, { tx = 13, ty = 10, tw = 7, th = 5 }) + game.stack:push(optionBox) end, }) -- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and @@ -727,10 +739,22 @@ function BagMenu.new(game, opts) end local baseUpdate = list.update list.update = function(self, dt) + self.optionBox = nil baseUpdate(self, dt) game.bagListScrollOffset = self.scroll game.bagSavedMenuItem = self.index - self.scroll - 1 end + local baseDraw = list.draw + list.draw = function(self) + -- engine/menus/start_sub_menus.asm:299 + if game.stack:top() == self then + self.optionBox = nil + self.hollowIndex = nil + end + baseDraw(self) + -- engine/menus/start_sub_menus.asm:298 + if self.optionBox then self.optionBox:draw() end + end list.closeStartMenu = opts.onClose -- the item box overlaps the kept-open START menu box, so neither docks to -- a screen edge on its own (start_sub_menus.asm:302-329) #1745 diff --git a/src/ui/Diploma.lua b/src/ui/Diploma.lua index bb9f6280..176cb5c9 100644 --- a/src/ui/Diploma.lua +++ b/src/ui/Diploma.lua @@ -6,6 +6,7 @@ local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local PaletteFX = require("src.render.PaletteFX") local Sprites = require("src.pokemon.Sprites") local SpriteRenderer = require("src.render.SpriteRenderer") @@ -14,6 +15,7 @@ local Strings = require("src.core.Strings") -- engine/events/diploma.asm:65 local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 } } +local PIC_X, PIC_Y = 115, 80 local Diploma = {} Diploma.__index = Diploma @@ -69,6 +71,57 @@ local function drawFrameBox(frame, tx, ty, tw, th) end end +local function playerName(game) + return (game.save.player and game.save.player.name) or "RED" +end + +local function drawLines(game) + -- engine/events/diploma.asm:95 + Font.draw(Strings("Player"), 24, 32) + -- engine/events/diploma.asm:39 + Font.draw(playerName(game), 80, 32) + + -- engine/events/diploma.asm:97 + local congrats = { + { text = Strings.source("Congrats! This"), y = 48 }, + { text = Strings.source("diploma certifies"), y = 64 }, + { text = Strings.source("that you have"), y = 80 }, + { text = Strings.source("completed your"), y = 96 }, + { text = Strings.source("POKéDEX."), y = 112 }, + } + for _, line in ipairs(congrats) do + Font.draw(Strings(line.text), 16, line.y) + end + + -- engine/events/diploma.asm:98 + Font.draw(Strings("GAME FREAK"), 72, 128) +end + +local overlayCanvas, overlayKey + +local function textOverlay(game) + local g = love.graphics + if not (g.newCanvas and g.setCanvas and g.push and g.pop) then return nil end + local key = playerName(game) .. "\0" .. tostring(Strings.active()) + if overlayCanvas and overlayKey == key then return overlayCanvas end + if not overlayCanvas then + local ok, c = pcall(g.newCanvas, 160, 144) + if not ok or not c then return nil end + overlayCanvas = c + end + g.push("all") + g.setCanvas(overlayCanvas) + g.origin() + g.setScissor() + g.setShader() + g.clear(0, 0, 0, 0) + g.setColor(0, 0, 0, 1) + drawLines(game) + g.pop() + overlayKey = key + return overlayCanvas +end + function Diploma.new(game, onDone) local self = setmetatable({ game = game, @@ -102,25 +155,32 @@ function Diploma.render(game) titlePlayer or "assets/generated/title/player.png", { side = "front", kind = "diploma", data = game.data }) local pic + local ogObp = false if picPath then if picTrueColor then pic = tryImage(picPath) else - local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90, - "diploma") + ogObp = PaletteFX.usesSpriteObp() and PaletteFX.pass() == "ui" + local ramp, group = OBP0_90, "diploma" + if ogObp then + local c = PaletteFX.ogObjBase() + -- engine/events/diploma.asm:65 + ramp = { c[1], c[1], c[2], c[3] } + group = GameVersion.isBlue() and "diploma_og_blue" or "diploma_og" + end + local ok, faded = pcall(SpriteRenderer.obpImage, picPath, ramp, group) pic = (ok and faded) or tryImage(picPath) + ogObp = ogObp and ok and faded ~= nil end end if pic then love.graphics.setColor(1, 1, 1, 1) - local sx, sy, sw, sh = love.graphics.getScissor() -- engine/events/diploma.asm:44 - love.graphics.setScissor(8, 8, 144, 128) - love.graphics.draw(pic, 115, 80) - if sx then love.graphics.setScissor(sx, sy, sw, sh) - else love.graphics.setScissor() end + love.graphics.draw(pic, PIC_X, PIC_Y) if picTrueColor then - PaletteFX.markTrueColor(115, 80, pic:getDimensions()) + PaletteFX.markTrueColor(PIC_X, PIC_Y, pic:getDimensions()) + elseif ogObp then + PaletteFX.markUiSpriteRedraw(pic, nil, PIC_X, PIC_Y) end end @@ -133,26 +193,17 @@ function Diploma.render(game) love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2 - -- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name) - Font.draw(Strings("Player"), 24, 32) - local playerName = (game.save.player and game.save.player.name) or "RED" - Font.draw(playerName, 80, 32) - - -- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14) - local congrats = { - { text = Strings.source("Congrats! This"), y = 48 }, -- hlcoord 2, 6 - { text = Strings.source("diploma certifies"), y = 64 }, -- hlcoord 2, 8 - { text = Strings.source("that you have"), y = 80 }, -- hlcoord 2, 10 - { text = Strings.source("completed your"), y = 96 }, -- hlcoord 2, 12 - { text = Strings.source("POKéDEX."), y = 112 }, -- hlcoord 2, 14 - } - for _, line in ipairs(congrats) do - Font.draw(Strings(line.text), 16, line.y) - end - - -- 6. Developer signature: hlcoord 9, 16 - Font.draw(Strings("GAME FREAK"), 72, 128) + drawLines(game) love.graphics.setColor(1, 1, 1, 1) + + if ogObp then + local overlay = textOverlay(game) + if overlay then + -- engine/events/diploma.asm:44 + PaletteFX.markUiSpriteRedraw(overlay, nil, 0, 0, + { clip = { PIC_X, PIC_Y, pic:getWidth(), pic:getHeight() } }) + end + end end function Diploma:draw() diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 014f1bc1..407a856c 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -126,26 +126,28 @@ end function EvolutionState:pushDoneText() local game = self.game local TextBox = require("src.render.TextBox") + local Evolution = require("src.pokemon.Evolution") if self.canceled then game.stack:push(TextBox.new(game, romText(game.data, "_StoppedEvolvingText", "Huh? %s\nstopped evolving!", self.oldName), function() - Music.restoreMap(game.data) game.stack:pop() + -- engine/pokemon/evos_moves.asm:296 + Evolution.clearScreen(game, self.mon.species) if self.onDone then self.onDone() end end)) return end - local Evolution = require("src.pokemon.Evolution") local newName = game.data.pokemon[self.newSpecies].name -- engine/pokemon/evos_moves.asm:136-153 local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName) .. romText(game.data, "_IntoText", "\ninto %s!", newName) game.stack:push(TextBox.new(game, msg, function() - Music.restoreMap(game.data) game.stack:pop() + -- engine/pokemon/evos_moves.asm:156 + Evolution.clearScreen(game, self.newSpecies) -- engine/pokemon/evos_moves.asm:212 (#12) Evolution.learnEvolutionMoves(game, self.mon, self.onDone) end, diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index d9a266be..fb59ae0f 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -150,8 +150,18 @@ function NamingScreen:enter() end end +-- engine/menus/naming_screen.asm:88 +local REPROMPT_WHITE_FRAMES = 45 + function NamingScreen:confirm() local name = table.concat(self.glyphs) + if name == "" and self.presets and #self.presets > 0 then + -- engine/movie/oak_speech/oak_speech2.asm:20 + self.row, self.col = 1, 1 + self.anim = 0 + self.whiteout = REPROMPT_WHITE_FRAMES + return + end if name == "" then -- An empty confirm (START, or the ED cell with nothing typed) must not -- invent a letter (#833). DisplayNamingScreen seeds wStringBuffer with @@ -163,10 +173,10 @@ function NamingScreen:confirm() -- .playerCancelled and keeps the old nick, which is why an explicit -- opts.default still wins here. Player/rival naming -- (oak_speech2.asm ChoosePlayerName) re-opens on '@' and never accepts an - -- empty result; the port keeps its preset fallback for that. + -- empty result. -- Contract for callers: "" means NO name -- BattleState:askNicknameUI and -- Commands.give_pokemon both guard on #name > 0 before setting nickname. - name = (self.presets and self.presets[1]) or self.default or "" + name = self.default or "" end Sound.play(self.game.data, "Press_AB") self.game.stack:pop() @@ -193,6 +203,10 @@ function NamingScreen:jumpToEnd() end function NamingScreen:update(dt) + if (self.whiteout or 0) > 0 then + self.whiteout = self.whiteout - 1 + return + end -- engine/menus/naming_screen.asm:131 self.anim = (self.anim or 0) + 1 local GRID = self:grid() @@ -252,6 +266,7 @@ function NamingScreen:draw() if self.choosing then return end love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) + if (self.whiteout or 0) > 0 then return end love.graphics.setColor(0, 0, 0, 1) -- engine/menus/naming_screen.asm:453 if self.mon then diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index d78304a8..60cd4321 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -253,6 +253,8 @@ function OakSpeech.defaultSteps(speech) pic = "player", -- oak_speech.asm:106-108 reveal = "fade_white_in", + -- data/text/text_2.asm:1753 + auto = true, }, { id = "shrink", @@ -331,6 +333,8 @@ function OakSpeech.new(game, onDone) local sprites = game.data.sprites or {} local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED self.walkSheet = tryImage(red and red.image) + self.walkDef = red + self.walkPath = red and red.image return self end @@ -453,7 +457,8 @@ function OakSpeech:runStep(step) self.holdBox = box self.game.stack:push(box) else - self:sayText(self:stepText(step), function() self:advance() end) + self:sayText(self:stepText(step), function() self:advance() end, + step.auto and { auto = { delay = 0 } } or nil) end end) elseif kind == "demo" then @@ -687,14 +692,23 @@ function OakSpeech:finish() if self.onDone then self.onDone() end end --- Shrink timeline (oak_speech.asm .next): --- frames 1-4 RedPicFront still up (ld c, 4 / DelayFrames) --- frames 5-8 ShrinkPic1 (ld c, 4 / DelayFrames) --- frames 9-28 ShrinkPic2, music fades (wAudioFadeOutControl; ld c, 20) --- frames 29-78 pic area cleared, walking sprite at the standard --- player screen spot (ResetPlayerSpriteData / --- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50) --- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames) +-- engine/movie/oak_speech/oak_speech.asm:125 +local SHRINK_PIC1_AT = 36 +local SHRINK_PIC2_AT = 76 +-- engine/movie/oak_speech/oak_speech.asm:154 +local SHRINK_WALK_AT = 108 +local SHRINK_FADE_AT = 158 +local SHRINK_END = SHRINK_FADE_AT + 3 * WHITE_FADE_STEP +-- home/fade.asm:71 +local SHRINK_FADE_BGP = { 0x90, 0x40, 0x00 } +local SHRINK_FADE_OBP0 = { 0x80, 0x40, 0x00 } + +OakSpeech.SHRINK_PIC1_AT = SHRINK_PIC1_AT +OakSpeech.SHRINK_PIC2_AT = SHRINK_PIC2_AT +OakSpeech.SHRINK_WALK_AT = SHRINK_WALK_AT +OakSpeech.SHRINK_FADE_AT = SHRINK_FADE_AT +OakSpeech.SHRINK_END = SHRINK_END + function OakSpeech:update(dt) local r = self.picReveal if r then @@ -708,24 +722,24 @@ function OakSpeech:update(dt) if not self.shrink then return end local s = self.shrink s.frame = s.frame + 1 - if s.frame == 5 then + if s.frame == SHRINK_PIC1_AT then self.pic = self.shrinkPic1 or self.pic self.picTrueColor = false - elseif s.frame == 9 then + elseif s.frame == SHRINK_PIC2_AT then self.pic = self.shrinkPic2 or self.pic self.picTrueColor = false -- wAudioFadeOutControl = 10: the music ramps to silence over ~70 - -- frames (7 levels x 10), reaching 0 just as the fade-to-white - -- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149, + -- frames (7 levels x 10), reaching 0 shortly before the fade-to-white + -- begins, instead of a hard cut (oak_speech.asm:145-149, -- home/fade_audio.asm) Music.fadeOut(10) - elseif s.frame == 29 then + elseif s.frame == SHRINK_WALK_AT then self.pic = nil self.picTrueColor = false self.walkVisible = true - elseif s.frame >= 79 and s.frame <= 102 then - self.fadeLevel = math.floor((s.frame - 79) / 8) + 1 - elseif s.frame > 102 then + elseif s.frame >= SHRINK_FADE_AT and s.frame < SHRINK_END then + self.fadeLevel = math.floor((s.frame - SHRINK_FADE_AT) / WHITE_FADE_STEP) + 1 + elseif s.frame >= SHRINK_END then -- clear before finish(): a finished-listener that pushes a state gets -- ITS state popped in the speech's place, and a live shrink would call -- finish() again next frame, re-firing the event every frame (#308) @@ -734,6 +748,33 @@ function OakSpeech:update(dt) end end +-- engine/movie/oak_speech/oak_speech.asm:162 +function OakSpeech:walkImage() + if not self.walkPath then return self.walkSheet, false end + local PF = require("src.render.PaletteFX") + local colors, group + if PF.usesGbcPack() then + colors, group = PF.spriteObp(self.walkDef) + if colors then colors, group = PF.fadeObp(colors, group) end + end + if not colors then + if PF.usesSpriteObp() then + if self.fadeLevel then + colors, group = PF.ogObjLit() + else + -- home/fade.asm:68 + colors, group = PF.ogObjNormal() + end + else + colors, group = PF.dmgObjLit() + end + end + local ok, img = pcall(require("src.render.SpriteRenderer").obpImage, + self.walkPath, colors, group) + if not (ok and img) then return self.walkSheet, false end + return img, PF.usesSpriteObp() or PF.usesGbcPack() +end + function OakSpeech:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) @@ -743,6 +784,14 @@ function OakSpeech:draw() require("src.render.PaletteFX").setShadeMap( require("src.render.Transition").shadeMapFor(revealBgp)) end + if self.fadeLevel then + -- home/fade.asm:26 + local PF = require("src.render.PaletteFX") + local Transition = require("src.render.Transition") + local i = math.min(#SHRINK_FADE_BGP, self.fadeLevel) + PF.setShadeMap(Transition.shadeMapFor(SHRINK_FADE_BGP[i])) + PF.setFadeObp(Transition.shadeMapFor(SHRINK_FADE_OBP0[i])) + end if self.pic then -- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic -- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside @@ -773,7 +822,11 @@ function OakSpeech:draw() -- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40 self.walkQuad = self.walkQuad or love.graphics.newQuad(0, 0, 16, 16, self.walkSheet:getDimensions()) - love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60) + local img, redraw = self:walkImage() + love.graphics.draw(img, self.walkQuad, 64, 60) + if redraw then + require("src.render.PaletteFX").markUiSpriteRedraw(img, self.walkQuad, 64, 60) + end end if self.shrinkText then -- This is a REPLICA of the dialogue box that just closed, redrawn at @@ -795,11 +848,6 @@ function OakSpeech:draw() end love.graphics.setColor(1, 1, 1, 1) end - if self.fadeLevel then - love.graphics.setColor(1, 1, 1, self.fadeLevel / 3) - love.graphics.rectangle("fill", 0, 0, 160, 144) - love.graphics.setColor(1, 1, 1, 1) - end end return OakSpeech diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index bd29ae2e..d675e357 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -35,14 +35,11 @@ PartyMenu.isOpaque = true -- MEWMON instead painted every bar with MEWMON's shades, which is why a -- full bar came out black and a low one purple (#274, absorbing #272). -- --- Two rects differ from the packet's, both because this port draws pixels +-- One rect differs from the packet's, because this port draws pixels -- where the hardware drew OAM over BG: -- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the -- message box's top edge, which on hardware was BG under an OBJ-free -- part of the block; here it would take MEWMON instead of the base. --- * the bar blocks sit one tile right of the packet's 05-11 because this --- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at --- 4; the span is the same "left cap + six fill tiles". function PartyMenu:sgbPalettes(game) local P = require("src.render.PaletteFX") local base = P.pal(game.data, "GREENBAR") @@ -65,7 +62,7 @@ function PartyMenu:sgbPalettes(game) if self.heal and self.heal.mon == mon then hp = self.heal.from end local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp)) if bar then - zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1) + zones[#zones + 1] = P.zone(bar, 5, i * 2 - 1, 11, i * 2 - 1) end end end @@ -466,7 +463,10 @@ end function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed - self.blink = ((self.blink or 0) + 1) % 320 + -- home/pokemon.asm:243 + if not (self.submenu or self.chosenHollow or self.heal or self.swapAnim) then + self.blink = ((self.blink or 0) + 1) % 320 + end -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking -- predef in item_effects.asm, so no button is read until it lands (#252). local heal = self.heal @@ -497,6 +497,7 @@ function PartyMenu:update(dt) end if anim.frames < 10 or (playing and anim.frames < 30) then return end self.swapAnim = nil + self.blink = 0 -- home/window.asm:16 if self.game.data then require("src.core.Sound").play(self.game.data, "Swap") end @@ -524,6 +525,7 @@ function PartyMenu:update(dt) self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then self.submenu = nil + self.blink = 0 -- home/window.asm:16 elseif input:wasPressed("a") then local mon = party[self.index] if followerUnavailable(self.game, mon) then @@ -595,6 +597,7 @@ function PartyMenu:update(dt) return end self.submenu = nil -- engine/menus/start_sub_menus.asm:66 + self.chosenHollow = true ow:useFlashFieldMove(function() self:close() end) return elseif action == "surf" then @@ -614,6 +617,7 @@ function PartyMenu:update(dt) -- trySurf closes this menu when its text does (#385) local fx, fy = ow.player:facingCell() self.submenu = nil -- engine/menus/start_sub_menus.asm:66 + self.chosenHollow = true ow:trySurf(fx, fy, function() self:close() end) return end @@ -687,6 +691,7 @@ function PartyMenu:update(dt) return end self.submenu = nil -- engine/menus/start_sub_menus.asm:66 + self.chosenHollow = true ow:useStrengthFieldMove(mon, function() self:close() end) return elseif ow and ow.useFieldMove then @@ -739,6 +744,7 @@ function PartyMenu:update(dt) return end self.submenu = nil + self.blink = 0 -- home/window.asm:16 end return end @@ -754,12 +760,15 @@ function PartyMenu:update(dt) if grid then self.index = grid self.game.partyMenuSavedIndex = self.index + self.blink = 0 -- home/window.asm:16 elseif input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or math.max(1, #party) self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768 + self.blink = 0 -- home/window.asm:16 elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768 + self.blink = 0 -- home/window.asm:16 elseif input:wasPressed("b") then self.game.stack:pop() if self.onCancel then self.onCancel() end @@ -964,11 +973,11 @@ function PartyMenu:draw() for _, m in ipairs(def.tmhm or {}) do if m == self.tmhm.move then can = true break end end - -- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge + -- engine/menus/party_menu.asm:93 if can then - Font.draw(Strings("ABLE"), 120, y + 8) + Font.draw(Strings("ABLE"), 96, y + 8) else - Font.draw(Strings("NOT ABLE"), 88, y + 8) + Font.draw(Strings("NOT ABLE"), 96, y + 8) end elseif self.evoStone then -- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching @@ -979,10 +988,11 @@ function PartyMenu:draw() can = true break end end + -- engine/menus/party_menu.asm:160 if can then - Font.draw(Strings("ABLE"), 120, y + 8) + Font.draw(Strings("ABLE"), 96, y + 8) else - Font.draw(Strings("NOT ABLE"), 88, y + 8) + Font.draw(Strings("NOT ABLE"), 96, y + 8) end else if mon.hp <= 0 then @@ -1006,7 +1016,8 @@ function PartyMenu:draw() shown = { hp = math.floor(self.heal.shown), stats = mon.stats } end love.graphics.setColor(1, 1, 1, 1) - HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned) + -- engine/menus/party_menu.asm:71 + HudTiles.drawHPBar(self.game.data, 4, (y + 8) / 8, shown, nil, barZoned) love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8) end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index b9e6db3f..547fd873 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -6,6 +6,7 @@ -- move one); toss discards after a YES/NO confirm. Follows the -- BoxMenu/BagMenu list idioms. +local Bag = require("src.inventory.Bag") local ChoiceBox = require("src.ui.ChoiceBox") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") @@ -23,49 +24,19 @@ local function itemName(game, id) return def and def.name or id end -local function pcOrder(game) - local save, pc = game.save, game.save.pcItems - local order = type(save.pcOrder) == "table" and save.pcOrder or {} - local kept, seen = {}, {} - for _, id in ipairs(order) do - if pc[id] and not seen[id] then kept[#kept + 1], seen[id] = id, true end - end - local added = {} - for id in pairs(pc) do if not seen[id] then added[#added + 1] = id end end - table.sort(added, function(a, b) - local da, db = game.data.items[a], game.data.items[b] - local ia = da and (da.index or da.itemId) or math.huge - local ib = db and (db.index or db.itemId) or math.huge - if ia ~= ib then - if type(ia) == type(ib) then return ia < ib end - return tostring(ia) < tostring(ib) - end - if type(a) == type(b) then return a < b end - return tostring(a) < tostring(b) - end) - for _, id in ipairs(added) do kept[#kept + 1] = id end - for i = #order, 1, -1 do order[i] = nil end - for i, id in ipairs(kept) do order[i] = id end - save.pcOrder = order - return order -end - -local function buildItems(game, store, order) +local function buildItems(game, rows) local items = {} - local ids = order - if not ids then - ids = {} - for id in pairs(store) do table.insert(ids, id) end - table.sort(ids) - end - for _, id in ipairs(ids) do - if store[id] then + for _, row in ipairs(rows) do + local id = row.id + if row.count ~= nil then local def = game.data.items[id] local keyItem = (def and def.keyItem) or id:find("^HM_") ~= nil table.insert(items, { value = id, label = itemName(game, id), - count = not keyItem and store[id] or nil, + count = not keyItem and row.count or nil, + slot = row.slot, + stack = row.count, }) end end @@ -104,19 +75,11 @@ local function askQuantity(game, list, count, id, cb) })) end --- refresh the chosen row's count from `store` (or drop the row) -local function refreshRow(list, store, id) - for i, it in ipairs(list.items) do - if it.value == id then - if store[id] then - it.count = store[id] - else - table.remove(list.items, i) - list.index, list.scroll = 1, 0 -- engine/items/inventory.asm:131 - end - break - end +local function refreshRows(list, items) + if #items < #list.items then + list.index, list.scroll = 1, 0 -- engine/items/inventory.asm:131 end + list.items = items list.index = math.max(1, math.min(list.index, #list.items)) end @@ -166,7 +129,7 @@ local function withdraw(game) "There is nothing\nstored."), nil, { noSound = true })) return end - game.stack:push(pcList(game, buildItems(game, pc, pcOrder(game)), { + game.stack:push(pcList(game, buildItems(game, Bag.pcRows(game.save, game.data)), { kind = "pc_item_withdraw", messageBox = true, -- players_pc.asm:151-152 WhatToWithdrawText, printed before the list @@ -176,39 +139,30 @@ local function withdraw(game) onChoose = function(item, list) if leftOnCancel(item, list) then return end list.hollowIndex = list.index -- home/list_menu.asm:91 - askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) - local Bag = require("src.inventory.Bag") + askQuantity(game, list, item.stack or pc[item.value] or 1, item.value, function(qty) if not Bag.add(game.save, item.value, qty, game.data) then list.footer = Strings("You can't carry\nany more items.") return end - pc[item.value] = pc[item.value] - qty - if pc[item.value] <= 0 then - pc[item.value] = nil - pcOrder(game) - end + Bag.pcRemove(game.save, item.value, qty, game.data, item.slot) Sound.play(game.data, "Withdraw_Deposit") list:showCompletion(romText(game.data, "_WithdrewItemText", "Withdrew\n%s.{PROMPT}", itemName(game, item.value)), - function() refreshRow(list, pc, item.value) end) -- engine/menus/players_pc.asm:191 + function() refreshRows(list, buildItems(game, Bag.pcRows(game.save, game.data))) end) -- engine/menus/players_pc.asm:191 end) end, })) end -- wNumBoxItems capacity: 50 stacks (PC_ITEM_CAPACITY) -local function pcFull(game, pc, id) - if pc[id] then return false end -- growing an existing stack is fine +local function storeInPC(game, pc, id, qty) local cap = game.data.field.pcItemCap or 50 - local stacks = 0 - for _ in pairs(pc) do stacks = stacks + 1 end - return stacks >= cap + return Bag.pcAdd(game.save, id, qty, game.data, cap) end local function deposit(game) local pc = game.save.pcItems local inv = game.save.inventory - local Bag = require("src.inventory.Bag") -- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order local order = Bag.order(game.save, game.data) -- players_pc.asm:90-95: an empty bag never reaches the list @@ -217,7 +171,7 @@ local function deposit(game) "You have nothing\nto deposit."), nil, { noSound = true })) return end - game.stack:push(pcList(game, buildItems(game, inv, order), { + game.stack:push(pcList(game, buildItems(game, Bag.rows(game.save, game.data)), { kind = "pc_item_deposit", messageBox = true, -- players_pc.asm:97-98 WhatToDepositText, printed before the list @@ -227,21 +181,18 @@ local function deposit(game) onChoose = function(item, list) if leftOnCancel(item, list) then return end list.hollowIndex = list.index -- home/list_menu.asm:91 - askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) - if pcFull(game, pc, item.value) then + askQuantity(game, list, item.stack or inv[item.value] or 1, item.value, function(qty) + if not storeInPC(game, pc, item.value, qty) then list.footer = Strings("No room left to\nstore items.") return end - if not pc[item.value] then - local order = pcOrder(game) - order[#order + 1] = item.value - end - require("src.inventory.Bag").remove(game.save, item.value, qty) - pc[item.value] = (pc[item.value] or 0) + qty + Bag.remove(game.save, item.value, qty, game.data, item.slot) Sound.play(game.data, "Withdraw_Deposit") list:showCompletion(romText(game.data, "_ItemWasStoredText", "%s was\nstored via PC.{PROMPT}", itemName(game, item.value)), - function() refreshRow(list, inv, item.value) end) -- engine/menus/players_pc.asm:137 + function() + refreshRows(list, buildItems(game, Bag.rows(game.save, game.data))) + end) -- engine/menus/players_pc.asm:137 end) end, })) @@ -255,7 +206,7 @@ local function toss(game) "There is nothing\nstored."), nil, { noSound = true })) return end - game.stack:push(pcList(game, buildItems(game, pc, pcOrder(game)), { + game.stack:push(pcList(game, buildItems(game, Bag.pcRows(game.save, game.data)), { kind = "pc_item_toss", messageBox = true, -- players_pc.asm:205-206 WhatToTossText, printed before the list @@ -272,20 +223,16 @@ local function toss(game) end local QuantityBox = require("src.ui.QuantityBox") game.stack:push(QuantityBox.new(game, { - max = pc[item.value] or 1, + max = item.stack or pc[item.value] or 1, onDone = function(qty) if not qty then return end list.footer = Strings("Toss %s?", itemName(game, item.value)) game.stack:push(ChoiceBox.new(game, function(yes) if yes then - pc[item.value] = pc[item.value] - qty - if pc[item.value] <= 0 then - pc[item.value] = nil - pcOrder(game) - end + Bag.pcRemove(game.save, item.value, qty, game.data, item.slot) list:showCompletion(romText(game.data, "_ThrewAwayItemText", "Threw away\n%s.{PROMPT}", itemName(game, item.value)), - function() refreshRow(list, pc, item.value) end) -- engine/menus/players_pc.asm:240 + function() refreshRows(list, buildItems(game, Bag.pcRows(game.save, game.data))) end) -- engine/menus/players_pc.asm:240 else list.footer = nil end diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index c4bbeaa8..ff6cfe96 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -152,13 +152,16 @@ end -- home/list_menu.asm:472-477 local function sellItems(game) local items = {} - for _, id in ipairs(Bag.order(game.save)) do + for _, row in ipairs(Bag.rows(game.save, game.data)) do + local id = row.id local def = game.data.items[id] local keyed = (def and def.keyItem) or id:find("^HM_") ~= nil table.insert(items, { value = id, label = def and def.name or id, - count = (not keyed) and game.save.inventory[id] or nil, + count = (not keyed) and row.count or nil, + slot = row.slot, + stack = row.count, }) end items[#items + 1] = { cancel = true, label = Strings("CANCEL") } @@ -196,11 +199,13 @@ local function sell(game, menu) l.swapIndex = l.index return end - local order = Bag.order(game.save) - order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex] + -- engine/menus/swap_items.asm:45 + if l.swapIndex == l.index then return end + Bag.swap(game.save, l.swapIndex, l.index, game.data) l.swapIndex = nil require("src.core.Sound").play(game.data, "Swap") l.items = sellItems(game) + l.index = math.max(1, math.min(l.index, #l.items)) end, onChoose = function(item) -- home/list_menu.asm:105-110, 523-528 @@ -227,7 +232,7 @@ local function sell(game, menu) if game.stack:top() == qtyBox then game.stack:pop() end end qtyBox = QuantityBox.new(game, { - max = game.save.inventory[item.value] or 1, + max = item.stack or game.save.inventory[item.value] or 1, unitPrice = unit, keepOpen = true, onDone = function(qty) @@ -250,13 +255,14 @@ local function sell(game, menu) game.save.money = game.save.money + unit * qty -- home/inventory.asm:15 require("src.core.Sound").play(game.data, "Purchase") - Bag.remove(game.save, item.value, qty) - local left = game.save.inventory[item.value] - if left then - item.count = left - else - list:removeCurrent() + Bag.remove(game.save, item.value, qty, game.data, item.slot) + local fresh = sellItems(game) + if #fresh < #list.items then + -- engine/items/inventory.asm:131 + list.index, list.scroll = 1, 0 end + list.items = fresh + list.index = math.max(1, math.min(list.index, #list.items)) -- a sale prints nothing -- engine/events/pokemart.asm:112 list.footer = greet end })) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 821dbf8f..da646c1f 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -516,13 +516,15 @@ function ContinueInfo:draw() math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72) local badges = require("src.inventory.Badges").count(self.game.data, save) Font.draw(Strings("BADGES"), 40, 88) - Font.draw(("%2d"):format(badges), 128, 88) + -- engine/menus/main_menu.asm:370 + Font.draw(("%2d"):format(badges), 17 * 8, 11 * 8) local owned = 0 for _ in pairs(save.pokedex and save.pokedex.owned or {}) do owned = owned + 1 end Font.draw(Strings("POKéDEX"), 40, 104) - Font.draw(("%3d"):format(owned), 120, 104) + -- engine/menus/main_menu.asm:372 + Font.draw(("%3d"):format(owned), 16 * 8, 13 * 8) local t = math.floor(save.playTime or 0) Font.draw(Strings("TIME"), 40, 120) Font.draw(("%3d:%02d"):format(math.floor(t / 3600), diff --git a/src/ui/game3/option_rows.lua b/src/ui/game3/option_rows.lua index 9e3d1f19..a8b4d375 100644 --- a/src/ui/game3/option_rows.lua +++ b/src/ui/game3/option_rows.lua @@ -64,7 +64,9 @@ function Rows.build(ctx, skip) local rows = {} local function add(row) rows[#rows + 1] = row end local function addCart(id, make) - if not (skip and skip[id]) then add(make()) end + local swap = skip and skip[id] + if type(swap) == "table" then add(swap) + elseif not swap then add(make()) end end addCart("textSpeed", function() return { @@ -498,4 +500,12 @@ function Rows.group(rows, openPage) return view end +function Rows.withCart(ctx, cartRows, openPage, exclude) + local rows = {} + for _, r in ipairs(Rows.build(ctx, cartRows)) do + if not (exclude and exclude[r.id]) then rows[#rows + 1] = r end + end + return Rows.group(rows, openPage) +end + return Rows diff --git a/src/ui/game3/rs/main_menu.lua b/src/ui/game3/rs/main_menu.lua index 936fa2a6..535798a3 100644 --- a/src/ui/game3/rs/main_menu.lua +++ b/src/ui/game3/rs/main_menu.lua @@ -14,11 +14,15 @@ Menu.__index = Menu Menu.TYPE = { HAS_NO_SAVED_GAME = 0, HAS_SAVED_GAME = 1, HAS_MYSTERY_EVENT = 2 } local T = Menu.TYPE local LABEL = { CONTINUE = "gMainMenuString_Continue", NEW_GAME = "gMainMenuString_NewGame", - OPTION = "gMainMenuString_Option", MYSTERY_EVENTS = "gMainMenuString_MysteryEvents" } + OPTION = "gMainMenuString_Option", MYSTERY_EVENTS = "gMainMenuString_MysteryEvents", EXIT = "SystemText_Exit" } +local SCROLL_PX = 32 function Menu.items(kind) - if kind == T.HAS_MYSTERY_EVENT then return { "CONTINUE", "NEW_GAME", "MYSTERY_EVENTS", "OPTION" } end - if kind == T.HAS_SAVED_GAME then return { "CONTINUE", "NEW_GAME", "OPTION" } end - return { "NEW_GAME", "OPTION" } + local rows + if kind == T.HAS_MYSTERY_EVENT then rows = { "CONTINUE", "NEW_GAME", "MYSTERY_EVENTS", "OPTION" } + elseif kind == T.HAS_SAVED_GAME then rows = { "CONTINUE", "NEW_GAME", "OPTION" } + else rows = { "NEW_GAME", "OPTION" } end + rows[#rows + 1] = "EXIT" + return rows end function Menu.menuType(hasContinue, info, status) if not hasContinue or status == "invalid" or status == "empty" or status == "no_flash" then return T.HAS_NO_SAVED_GAME end @@ -66,7 +70,7 @@ function Menu.new(opts, ctx) opts = opts or {} local self = setmetatable({ info = opts.continueInfo, game = opts.game, bootState = opts.bootState, textSpeed = tonumber(opts.textSpeed) or 1, saveStatus = opts.saveStatus or "ok", pal = Pal.new(), - step = Kit.stepper(), state = "check_save", cursor = 1, + step = Kit.stepper(), state = "check_save", cursor = 1, scroll = 0, blink = 0, manifest = assert(Kit.manifest("birch"), "RS menu palette missing"), frameType = opts.continueInfo and opts.continueInfo.frameType or 0 }, Menu) self.menuType = opts.menuType or Menu.menuType(opts.hasContinue, self.info, self.saveStatus) @@ -74,6 +78,12 @@ function Menu.new(opts, ctx) self.pal:beginFade(Pal.ALL, 0, 16, 0, opts.returningFromOptions and Pal.BLACK or Pal.WHITE) return self end +function Menu:_fixScroll() + local w = Menu.windowFor(self.menuType, self.cursor) + local bottom, topPx = (w.top + w.height + 1) * 8, (w.top - 1) * 8 + while bottom - self.scroll > 160 do self.scroll = self.scroll + SCROLL_PX end + while topPx - self.scroll < 0 and self.scroll > 0 do self.scroll = self.scroll - SCROLL_PX end +end function Menu:_message(key) self.printer = Kit.printer(key, { speed = 2, canSpeedUp = false, textSpeedOption = self.textSpeed }) end @@ -84,6 +94,7 @@ function Menu:_openOptions() end function Menu:frame(inp) local state, result = self.state + self.blink = self.blink + 1 if state == "check_save" and not self.pal:fadeActive() then local key = self.saveStatus == "invalid" and "gSaveFileDeletedMessage" or self.saveStatus == "error" and "gSaveFileCorruptMessage" @@ -108,13 +119,14 @@ function Menu:frame(inp) Kit.playSe("SE_SELECT"); self.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.BLACK); self.state = "pressed_a" elseif inp.new.b then Kit.playSe("SE_SELECT"); self.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.WHITE); self.state = "pressed_b" - elseif inp.new.up and self.cursor > 1 then self.cursor = self.cursor - 1; self.state = "highlight" - elseif inp.new.down and self.cursor < #self.items then self.cursor = self.cursor + 1; self.state = "highlight" end + elseif inp.new.up and self.cursor > 1 then self.cursor = self.cursor - 1; self:_fixScroll(); self.state = "highlight" + elseif inp.new.down and self.cursor < #self.items then self.cursor = self.cursor + 1; self:_fixScroll(); self.state = "highlight" end elseif state == "pressed_a" and not self.pal:fadeActive() then local item = self.items[self.cursor] if item == "CONTINUE" then result = { action = "continue" } elseif item == "NEW_GAME" then result = "newGame" elseif item == "OPTION" then self:_openOptions() + elseif item == "EXIT" then result = { action = "exit" } elseif item == "MYSTERY_EVENTS" then require("src.core.game3.audio").fadeOutBgm(0) self.printer = nil @@ -126,7 +138,7 @@ function Menu:frame(inp) local opts = self.game and self.game.options if opts then self.textSpeed = require("src.core.game3.options").block(opts).textSpeed end if self.bootState then self.bootState.textSpeed = self.textSpeed end - self.options, self.cursor, self.state, self.saveStatus = nil, 1, "check_save", "ok" + self.options, self.cursor, self.scroll, self.state, self.saveStatus = nil, 1, 0, "check_save", "ok" self.pal:beginFade(Pal.ALL, 0, 16, 0, Pal.BLACK) end self.pal:updateFade() @@ -148,17 +160,18 @@ function Menu:colors() info = { bg = normal.bg, fg = Kit.color555(gender), shadow = normal.shadow }, fill = normal.bg, backdrop = Kit.color555(self.manifest.mainMenuPalette[1]) } end -function Menu:_drawContinue(c) +function Menu:_drawContinue(c, dy) local i = self.info or {} - Font.draw(RomText.plain("gMainMenuString_Player"), 16, 24, { colors = c.info }) - Font.draw(i.name or "", 72, 24, { colors = c.info }) - Font.draw(RomText.plain("gMainMenuString_Time"), 128, 24, { colors = c.info }) + local y1, y2 = 24 - dy, 40 - dy + Font.draw(RomText.plain("gMainMenuString_Player"), 16, y1, { colors = c.info }) + Font.draw(i.name or "", 72, y1, { colors = c.info }) + Font.draw(RomText.plain("gMainMenuString_Time"), 128, y1, { colors = c.info }) local time = string.format("%d:%02d", i.hours or 0, i.minutes or 0) - Font.draw(time, 224 - Font.measure(time), 24, { colors = c.info }) - Font.draw(RomText.plain("gMainMenuString_Pokedex"), 16, 40, { colors = c.info }) - Font.draw(tostring(i.dexCount or 0), 72, 40, { colors = c.info }) - Font.draw(RomText.plain("gMainMenuString_Badges"), 128, 40, { colors = c.info }) - Font.draw(tostring(i.badges or 0), 205, 40, { colors = c.info }) + Font.draw(time, 224 - Font.measure(time), y1, { colors = c.info }) + Font.draw(RomText.plain("gMainMenuString_Pokedex"), 16, y2, { colors = c.info }) + Font.draw(tostring(i.dexCount or 0), 72, y2, { colors = c.info }) + Font.draw(RomText.plain("gMainMenuString_Badges"), 128, y2, { colors = c.info }) + Font.draw(tostring(i.badges or 0), 205, y2, { colors = c.info }) end local canvas local function composite(img, rect) @@ -187,17 +200,22 @@ function Menu:draw() canvas = canvas or love.graphics.newCanvas(240, 160) canvas:setFilter("nearest", "nearest") love.graphics.push("all"); love.graphics.setCanvas(canvas); love.graphics.origin(); love.graphics.clear(0, 0, 0, 0) + local dy = self.scroll or 0 for index, item in ipairs(self.items) do local w = Menu.windowFor(self.menuType, index) - Chrome.stdFrame(w.left, w.top, w.width, w.height) - Font.draw(RomText.plain(LABEL[item]), w.left * 8, w.top * 8, { colors = c.headers }) - if item == "CONTINUE" then self:_drawContinue(c) end + Chrome.stdFrame(w.left, w.top - dy / 8, w.width, w.height) + Font.draw(RomText.plain(LABEL[item]), w.left * 8, w.top * 8 - dy, { colors = c.headers }) + if item == "CONTINUE" then self:_drawContinue(c, dy) end end love.graphics.pop() local w = Menu.windowFor(self.menuType, self.cursor) - local rect = { 9, (w.top - 1) * 8 + 1, 231, (w.top + w.height + 1) * 8 - 1 } + local rect = { 9, (w.top - 1) * 8 + 1 - dy, 231, (w.top + w.height + 1) * 8 - 1 - dy } if self.state == "pressed_b" then rect = { 0, 0, 240, 160 } end composite(canvas, rect) + local last = Menu.windowFor(self.menuType, #self.items) + local Arrow = require("src.ui.game3.rs.scroll_arrow") + if (last.top + last.height + 1) * 8 - dy > 160 then Arrow.draw("down", 0x78, 0x98, self.blink) end + if dy > 0 then Arrow.draw("up", 0x78, 8, self.blink) end end if error then Chrome.stdFrame(3, 15, 24, 4) diff --git a/src/ui/game3/rs/option_menu.lua b/src/ui/game3/rs/option_menu.lua index af8d9c70..abd73ed1 100644 --- a/src/ui/game3/rs/option_menu.lua +++ b/src/ui/game3/rs/option_menu.lua @@ -23,28 +23,16 @@ local function page() return Menu._pages and Menu._pages[#Menu._pages] end -local function portRows() - local skip = {} - for _, key in ipairs(KEYS) do skip[key] = true end - for key in pairs(Menu.EXCLUDE) do skip[key] = true end - local rows = {} - for _, r in ipairs(Rows.build(ctx(), skip)) do - if not skip[r.id] then rows[#rows + 1] = r end - end - return Rows.group(rows, function(title, members) - Menu._pages[#Menu._pages + 1] = {title = title, rows = members, index = 1, scroll = 0} - end) -end - local function topRows() - local rows = {} - for i = 1, NATIVE do rows[i] = {native = i} end - for _, r in ipairs(portRows()) do rows[#rows + 1] = {port = r} end - return rows + local cartRows = {} + for i, key in ipairs(KEYS) do cartRows[key] = {id = key, native = i} end + return Rows.withCart(ctx(), cartRows, function(title, members) + Menu._pages[#Menu._pages + 1] = {title = title, rows = members, index = 1, scroll = 0} + end, Menu.EXCLUDE) end local function rowCount(p) - return p.top and #p.rows or #p.rows + 1 + return #p.rows + 1 end local function clampScroll(p) @@ -162,20 +150,14 @@ function Menu.handleInput(input) local total = rowCount(p) local row = p.rows[p.index] if pressed(input, "a") then - if p.top and row.native == NATIVE then Menu.back() - elseif p.top and row.port then activatePort(row.port) - elseif not p.top then - if row then activatePort(row) else Menu.back() end - end + if not row then Menu.back() + elseif not row.native then activatePort(row) end elseif pressed(input, "b") then Menu.back() elseif pressed(input, "up") then p.index = (p.index - 2) % total + 1 elseif pressed(input, "down") then p.index = p.index % total + 1 - elseif p.top and row.native and row.native < NATIVE then stepNative(row.native, input) - else - local port = p.top and row.port or (not p.top and row) - if port and port.step and (pressed(input, "left") or pressed(input, "right")) then - if port.step(ctx(), pressed(input, "left") and -1 or 1) then persist() end - end + elseif row and row.native then stepNative(row.native, input) + elseif row and row.step and (pressed(input, "left") or pressed(input, "right")) then + if row.step(ctx(), pressed(input, "left") and -1 or 1) then persist() end end local cur = page() if cur then clampScroll(cur) end @@ -228,10 +210,8 @@ local function drawContents() if idx > rowCount(p) then break end local y = baseY + (slot - 1) * 16 local row = p.rows[idx] - if p.top and row.native then - drawNative(row.native, -p.scroll * 16) - elseif p.top then - drawPort(row.port, y) + if row and row.native then + drawNative(row.native, y - d.rows[row.native].y) elseif row then drawPort(row, y) else diff --git a/src/ui/game3/rs/start_menu_data.lua b/src/ui/game3/rs/start_menu_data.lua index 1c27ac70..3dc040b6 100644 --- a/src/ui/game3/rs/start_menu_data.lua +++ b/src/ui/game3/rs/start_menu_data.lua @@ -46,7 +46,7 @@ end Data.window = {left = 23, top = 1, width = 6} Data.textX, Data.textY, Data.rowPitch = 0, 8, 16 -Data.maxVisible, Data.dexNeedsSeen, Data.exitConfirms = 8, true, false +Data.maxVisible, Data.dexNeedsSeen, Data.exitConfirms = 8, true, true function Data.drawCursor(x, y) require("src.ui.game3.rs.menu_cursor").draw(x, y, 48) end diff --git a/src/ui/game3/rse/option_menu.lua b/src/ui/game3/rse/option_menu.lua index d861ffac..69b0fba7 100644 --- a/src/ui/game3/rse/option_menu.lua +++ b/src/ui/game3/rse/option_menu.lua @@ -64,15 +64,7 @@ end local function portRows() local cartRows = {} for _, r in ipairs(OptionMenu.CART) do cartRows[r.id] = { id = r.id, cart = r } end - local rows = {} - for _, r in ipairs(Rows.build(st.ctx)) do - rows[#rows + 1] = cartRows[r.id] or r - cartRows[r.id] = nil - end - for _, r in ipairs(OptionMenu.CART) do - if cartRows[r.id] then rows[#rows + 1] = cartRows[r.id] end - end - return Rows.group(rows, function(title, members) + return Rows.withCart(st.ctx, cartRows, function(title, members) st.pages[#st.pages + 1] = { title = title, rows = members, index = 1, scroll = 0 } end) end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3a66c5db..309a078e 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -149,6 +149,9 @@ local HEAL_FLASH_MAP_GBC = { [0] = 0, [1] = 0, [2] = 1, [3] = 2 } -- engine/overworld/healing_machine.asm:13-14 local HEAL_OBP1_MAP = { [0] = 0, [1] = 0, [2] = 2, [3] = 3 } + +-- home/fade.asm:67 +local OBP_IDENTITY = { [0] = 0, [1] = 1, [2] = 2, [3] = 3 } -- engine/overworld/healing_machine.asm:50 local HEAL_OBP1_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 0, [3] = 3 } @@ -2658,9 +2661,8 @@ function OverworldState:tryBookshelf(fx, fy) Game.stack:push(TextBox.new(Game, t._ElevatorText or Strings("An elevator!"))) elseif kind == "statues" then - -- IndigoPlateauStatues: the plaque, then one of the two lines - -- keyed by the statue's column (XCoord bit 0) - local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2 + -- engine/events/hidden_events/indigo_plateau_statues.asm:5 + local line = (self.player.cellX % 2 == 1) and t._IndigoPlateauStatuesText2 or t._IndigoPlateauStatuesText3 Game.stack:push(TextBox.new(Game, (t._IndigoPlateauStatuesText1 or romText(Game.data, "_IndigoPlateauStatuesText1", "INDIGO PLATEAU")) .. "\f" @@ -3735,6 +3737,24 @@ function OverworldState:openOaksPC(onDone) end)) end +-- audio/pokedex_rating_sfx.asm:26 +local DEX_RATING_SFX = { + { 10, "Denied" }, + { 40, "Pokedex_Rating" }, + { 60, "Get_Item1" }, + { 90, "Caught_Mon" }, + { 120, "Level_Up" }, + { 150, "Get_Key_Item" }, + { math.huge, "Get_Item2" }, +} + +-- audio/pokedex_rating_sfx.asm:1 +function OverworldState.dexRatingSfx(owned) + for _, row in ipairs(DEX_RATING_SFX) do + if owned < row[1] then return row[2] end + end +end + -- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm): -- the completion line with seen AND owned counts, then the per-decade -- rating text. @@ -3761,7 +3781,14 @@ function OverworldState:dexRating(onDone) -- the box to the plain A/B path once the jingle has sounded. Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, { auto = { wait = true, sound = function() - return require("src.core.Sound").play(Game.data, "Pokedex_Rating") + local Sound = require("src.core.Sound") + local name = OverworldState.dexRatingSfx(owned) + local src = Sound.play(Game.data, name) + -- audio/pokedex_rating_sfx.asm:13 + if src and not Sound.ducksMusic(Game.data, name) then + require("src.core.Music").duckForFanfare(src) + end + return src end }, })) end @@ -4930,13 +4957,12 @@ function OverworldState:checkBadgeGate() end return false end - -- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED - require("src.core.Sound").play(Game.data, "Denied") + -- scripts/Route22Gate.asm:79 Game.stack:push(TextBox.new(Game, - (t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!")) - .. (t._Route22GateGuardICantLetYouPassText or ""), function() + TextBox.strip(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!")) + .. TextBox.PAUSE .. (t._Route22GateGuardICantLetYouPassText or ""), function() self:scriptMove(p, "down", 1, nil, { collide = true }) - end)) + end, { pauseSounds = { "Denied" }, pauseSoundWait = true })) return true end end @@ -4947,31 +4973,39 @@ function OverworldState:checkBadgeGate() for _, guard in ipairs(g.guards) do if p.cellY == guard.y and (not guard.maxX or p.cellX <= guard.maxX) and not Game.save.flags[guard.event] then - local badgeName = Game.data.items[guard.badge] - and Game.data.items[guard.badge].name or guard.badge - if Game.save.inventory[guard.badge] then - Flags.set(Game.save, guard.event) - -- Route23OhThatIsTheBadgeText carries sound_get_item_1 - local text = (t["_" .. g.passText] or - Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) - Game.stack:push(TextBox.new(Game, text, - nil, TextBox.soundOpts(Game, "Get_Item1"))) - return false - end - -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED - require("src.core.Sound").play(Game.data, "Denied") - local text = (t["_" .. g.failText] or - Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName) - Game.stack:push(TextBox.new(Game, text, function() - self:scriptMove(p, "down", 1, nil, { collide = true }) - end)) - return true + return not self:route23BadgeCheck(guard.badge, guard.event) end end end return false end +-- scripts/Route23.asm:195 +function OverworldState:route23BadgeCheck(badge, event, onDone) + local gates = Game.data.field.badgeGates + local g = gates and gates[self.map.id] or {} + local t = Game.data.text + local badgeName = Game.data.items[badge] and Game.data.items[badge].name or badge + if Game.save.inventory[badge] then + Flags.set(Game.save, event) + -- scripts/Route23.asm:237 + local text = (t["_" .. (g.passText or "Route23OhThatIsTheBadgeText")] or + Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) + Game.stack:push(TextBox.new(Game, + TextBox.strip(text) .. TextBox.PAUSE .. (t._Route23GoRightAheadText or ""), + onDone, { pauseSounds = { "Get_Item1" }, pauseSoundWait = true })) + return true + end + -- scripts/Route23.asm:229 + local text = (t["_" .. (g.failText or "Route23YouDontHaveTheBadgeYetText")] or + Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName) + Game.stack:push(TextBox.new(Game, text, function() + -- scripts/Route23.asm:123 + self:scriptMove(self.player, "down", 1, onDone, { collide = true }) + end, TextBox.soundOpts(Game, "Denied"))) + return false +end + -- Forced bike/surf tiles (data/maps/force_bike_surf.asm): the Cycling -- Road entrances force you onto the BICYCLE (or turn you back without -- one); the Seafoam current mouths force surfing. @@ -6014,6 +6048,40 @@ local function fieldFxImage(path, image, group) return SpriteRenderer.obpImage(path, colors, name) end +-- engine/overworld/dust_smoke.asm:21 +local OBP1_FLASH = { [0] = 0, [1] = 0, [2] = 0, [3] = 2 } +local FLASH_GRAYS = PaletteFX.permute(PaletteFX.GRAYS, OBP1_FLASH) +local fieldFxFlashColors = setmetatable({}, { __mode = "k" }) +local fieldFxFlashNames = {} + +local function fieldFxFlashImage(path, group) + local colors, key = FLASH_GRAYS, "gray" + if PaletteFX.usesGbcPack() then + local pack = PaletteFX.worldPack() + local obj = pack and pack.spritePalettes and pack.spritePalettes[group] + if obj then + colors = fieldFxFlashColors[obj] + if not colors then + colors = PaletteFX.permute(obj, OBP1_FLASH) + fieldFxFlashColors[obj] = colors + end + key = group + end + end + local version = GameVersion.get() + local names = fieldFxFlashNames[version] + if not names then + names = {} + fieldFxFlashNames[version] = names + end + local name = names[key] + if not name then + name = "fieldfxflash:" .. version .. ":" .. key + names[key] = name + end + return SpriteRenderer.obpImage(path, colors, name) +end + -- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, -- flickering (AnimateBoulderDust XORs the OBJ palette every step) local function fxDust(self, cam) @@ -6027,12 +6095,19 @@ local function fxDust(self, cam) end if self.smokeImg then local da = self.dustAnim - local img = fieldFxImage(smoke.path, self.smokeImg, da.boulder and 7 or 6) + local group = da.boulder and 7 or 6 + local flash + if da.boulder then + flash = da.faded + else + -- engine/overworld/cut2.asm:68 + flash = da.frames % 2 == 1 + end + local img = flash and fieldFxFlashImage(smoke.path, group) + or fieldFxImage(smoke.path, self.smokeImg, group) local dx = da.x * 16 + (da.ox or 0) - cam.x local dy = da.y * 16 + (da.oy or 0) - cam.y - local flicker = math.floor(da.frames / 4) % 2 == 0 - if da.boulder then flicker = not da.faded end - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + love.graphics.setColor(1, 1, 1, 1) for i = 0, 1 do for j = 0, 1 do love.graphics.draw(img, dx + i * 8, dy + j * 8) @@ -6068,8 +6143,9 @@ local function fxCutTree(self, cam) local off = (ca.total or 8) - ca.frames local dx = ca.x * 16 - cam.x local dy = ca.y * 16 - cam.y - local flicker = ca.frames % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + -- engine/overworld/cut2.asm:18 + if ca.frames % 2 == 1 then img = fieldFxFlashImage(tree.path, 6) end + love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) love.graphics.setColor(1, 1, 1, 1) @@ -6223,22 +6299,31 @@ local function entityDrawOrder(a, b) return a.pikachuFollower == true and b.pikachuFollower ~= true end --- Tall-grass feet overdraw queue for the flat path: each distinct grass --- cell an entity stands on or steps into, in first-queued order. A --- scratch pair of coordinate arrays reused every frame (the queue holds a --- few cells, so the duplicate check is a short linear scan) instead of a --- "cx:cy" string key and a { cx, cy } table per cell per frame. -local function queueGrass(self, q, cx, cy) - if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then - return - end +-- engine/overworld/movement.asm:90 +local function grassStrip(self, e) + local map = self.map + local inGrass = (e.cellX ~= nil and e.cellY ~= nil + and map:isGrassCell(e.cellX, e.cellY)) + or (e.targetX ~= nil and e.targetY ~= nil + and map:isGrassCell(e.targetX, e.targetY)) + if not inGrass then return nil end + local sprite, px, py = nil, e.px, e.py + if e.pose then sprite, px, py = e:pose() end + return map.renderer.feetStrip(px or 0, py or 0, sprite) +end + +local function queueGrass(self, q, e) + local x0, y0, x1, y1 = grassStrip(self, e) + if not x0 then return end local n = q.n - local xs, ys = q.x, q.y + local qx0, qy0, qx1, qy1 = q.x0, q.y0, q.x1, q.y1 for i = 1, n do - if xs[i] == cx and ys[i] == cy then return end + if qx0[i] == x0 and qy0[i] == y0 and qx1[i] == x1 and qy1[i] == y1 then + return + end end n = n + 1 - xs[n], ys[n] = cx, cy + qx0[n], qy0[n], qx1[n], qy1[n] = x0, y0, x1, y1 q.n = n end @@ -6289,7 +6374,7 @@ function OverworldState:drawWorld() -- home/fade.asm:52-58 -- home/fade.asm:65-73 local fadeObp = fade.obp0 and fade:obp0() or fadeBgp - PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp)) + PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp) or OBP_IDENTITY) fade.paletteStepped = true elseif self.emote and self.emote.bgp and not battleOverWorld then -- engine/pikachu/pikachu_pic_animation.asm:847 @@ -6498,10 +6583,10 @@ function OverworldState:drawWorld() local grassColors = PaletteFX.usesSpriteObp() and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil local boulderDust = self.dustAnim and self.dustAnim.boulder - local grass = self.grassScratch + local grass = self.grassStripScratch if not grass then - grass = { n = 0, x = {}, y = {} } - self.grassScratch = grass + grass = { n = 0, x0 = {}, y0 = {}, x1 = {}, y1 = {} } + self.grassStripScratch = grass end grass.n = 0 if boulderDust then fxDust(self, cam) end @@ -6517,18 +6602,18 @@ function OverworldState:drawWorld() if not ((self.flyAnim or self.flyArrive or self.playerHidden) and e == self.player) and not self:oamCulled(e) then e:draw(cam.x, cam.y) - queueGrass(self, grass, e.cellX, e.cellY) - queueGrass(self, grass, e.targetX, e.targetY) + queueGrass(self, grass, e) end end love.graphics.setColor(1, 1, 1, 1) - local gxs, gys = grass.x, grass.y + local shake = bgY - cam.y for i = 1, grass.n do - local cx, cy = gxs[i], gys[i] - self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY) + local x0, y0 = grass.x0[i], grass.y0[i] + shake + local x1, y1 = grass.x1[i], grass.y1[i] + shake + self.map.renderer:drawStrip(x0, y0, x1, y1, cam.x, bgY) if grassColors then - self.map.renderer:markCellBottomRedraw(cx, cy, - cam.x, bgY, grassColors) + self.map.renderer:markStripRedraw(x0, y0, x1, y1, + cam.x, bgY, grassColors) end end fxHeal(self, cam) @@ -6588,18 +6673,15 @@ function OverworldState:drawWorld() function() e:draw(cam.x, cam.y) end) -- tall-grass feet overdraw glued to the sprite: same anchor + depth -- so it keeps hiding the feet, color-0-keyed palette so its white - -- gaps still show the sprite through (drawCellBottomRaw lets the + -- gaps still show the sprite through (drawStripRaw lets the -- billboard own the shader; bgY keeps the elevator-shake offset). - if self.map:isGrassCell(e.cellX, e.cellY) then + local x0, y0, x1, y1 = grassStrip(self, e) + if x0 then + local shake = bgY - cam.y self:billboard(fx, fy, vw, vh, colors, true, function() love.graphics.setColor(1, 1, 1, 1) - self.map.renderer:drawCellBottomRaw(e.cellX, e.cellY, cam.x, bgY) - end) - end - if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then - self:billboard(fx, fy, vw, vh, colors, true, function() - love.graphics.setColor(1, 1, 1, 1) - self.map.renderer:drawCellBottomRaw(e.targetX, e.targetY, cam.x, bgY) + self.map.renderer:drawStripRaw(x0, y0 + shake, x1, y1 + shake, + cam.x, bgY) end) end end diff --git a/tests/drivers/bag_use_box_bug2657.lua b/tests/drivers/bag_use_box_bug2657.lua new file mode 100644 index 00000000..8acdbafd --- /dev/null +++ b/tests/drivers/bag_use_box_bug2657.lua @@ -0,0 +1,140 @@ +-- engine/menus/start_sub_menus.asm:298 +-- engine/items/item_effects.asm:2172 +-- tools/run_driver.sh red tests/drivers/bag_use_box_bug2657.lua +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local Strings = require("src.core.Strings") + local TextBox = require("src.render.TextBox") + local Menu = require("src.ui.Menu") + local ChoiceBox = require("src.ui.ChoiceBox") + + local ok = true + local function check(label, pass) + U.log(pass and "PASS" or "FAIL", label) + if not pass then ok = false end + return pass + end + local function finish() + U.log(ok and "PASS bag_use_box_2657" or "FAIL bag_use_box_2657") + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + local function onStack(state) + for _, s in ipairs(game.stack.states) do + if s == state then return true end + end + return false + end + local function boxText(box) + local lines = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return table.concat(lines, " / ") + end + local function waitFor(pred, n) + for _ = 1, n or 300 do + if pred(game.stack:top()) then return game.stack:top() end + U.wait(1) + end + return nil + end + local function stepTo(state, wants, what, dir) + for _ = 1, 30 do + if wants(state) then return true end + U.tap(game, dir or "down") + U.wait(6) + end + check("found " .. what .. " in the menu", false) + return false + end + + U.newGame(game) + if not check("new game reached the overworld", game.overworld ~= nil) then finish() end + game.save.inventory = {} + Bag.add(game.save, "POKE_BALL", 5) + Bag.add(game.save, "TM_TOXIC", 1) + U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up") + U.wait(20) + + U.tap(game, "start") + U.wait(20) + local start = game.stack:top() + local ITEM = Strings("ITEM") + if not check("START opened a menu", start and start.items ~= nil) then finish() end + if stepTo(start, function(m) return m.items[m.index].label == ITEM end, "ITEM") then + U.tap(game, "a") + U.wait(20) + end + local bag = game.stack:top() + if not check("the bag opened", bag and bag.items and bag.items[1] ~= nil) then finish() end + + local function useOn(id, dir) + if not stepTo(bag, function(b) return b.items[b.index].value == id end, id, dir) then finish() end + U.tap(game, "a") + U.wait(10) + local sub = game.stack:top() + if not check(id .. ": USE/TOSS opened", getmetatable(sub) == Menu and #sub.items == 2) then finish() end + U.tap(game, "a") + U.wait(2) + return sub + end + + local sub = useOn("TM_TOXIC") + local booted = waitFor(function(t) + return getmetatable(t) == TextBox and (t.waiting or t.done) + and boxText(t):find("Booted up", 1, true) + end) + if not check("TM: Booted up text printed", booted ~= nil) then + local t = game.stack:top() + U.log("top:", tostring(getmetatable(t) == TextBox), t and t.done, t and t.pages and boxText(t)) + finish() + end + check("TM: USE/TOSS box left painted on the bag", bag.optionBox == sub and not onStack(sub)) + check("TM: hollow cursor on USE", sub.hollowIndex == 1 and sub.index == 1) + U.wait(10) + check("shot 1", U.still(game, DIR .. "/2657_01_booted_up_box_kept.png")) + + local yesno = nil + for _ = 1, 40 do + yesno = getmetatable(game.stack:top()) == ChoiceBox and game.stack:top() or nil + if yesno then break end + U.tap(game, "a") + U.wait(8) + end + if not check("TM: Teach YES/NO up", yesno ~= nil) then finish() end + check("TM: box still painted under YES/NO", bag.optionBox == sub) + U.wait(10) + check("shot 2", U.still(game, DIR .. "/2657_02_teach_yes_no_over_box.png")) + + U.tap(game, "b") + waitFor(function(t) return t == bag end, 120) + U.wait(5) + check("TM: NO returns to the bag list", game.stack:top() == bag) + check("TM: the list's redraw erased the box", bag.optionBox == nil) + check("shot 3", U.still(game, DIR .. "/2657_03_list_redrawn.png")) + + local sub2 = useOn("POKE_BALL", "up") + local refusal = waitFor(function(t) + return getmetatable(t) == TextBox and (t.waiting or t.done) + end) + if check("BALL: refusal text printed", refusal ~= nil) then + U.log("the box reads:", boxText(refusal)) + end + check("BALL: USE/TOSS box left painted on the bag", bag.optionBox == sub2) + U.wait(10) + check("shot 4", U.still(game, DIR .. "/2657_04_refusal_box_kept.png")) + + for _ = 1, 20 do + if game.stack:top() == bag then break end + U.tap(game, "a") + U.wait(8) + end + U.wait(3) + check("BALL: back on the list", game.stack:top() == bag) + check("BALL: box erased", bag.optionBox == nil) + + finish() +end diff --git a/tests/drivers/battle_intro_balls_bug2662.lua b/tests/drivers/battle_intro_balls_bug2662.lua new file mode 100644 index 00000000..687d9961 --- /dev/null +++ b/tests/drivers/battle_intro_balls_bug2662.lua @@ -0,0 +1,156 @@ +-- engine/battle/common_text.asm:10 +-- engine/battle/common_text.asm:43 +local U = require("tests.drivers.util") +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local Version = require("src.core.GameVersion") +local Font = require("src.render.Font") + +return function(game) + local deadline = love.timer.getTime() + 60 + local fails = 0 + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + return ok + end + + local ok, err = xpcall(function() + assert(Version.generation() == 1, "Gen1 required") + local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required") + + local function wait(n) + for _ = 1, n do + assert(love.timer.getTime() < deadline, "#2662 deadline") + U.wait(1) + end + end + + local function snapshot(battle) + local rows, strings = 0, {} + local realRow, realFont, realDraw = battle.drawBallRow, Font.draw, love.graphics.draw + battle.drawBallRow = function() rows = rows + 1 end + Font.draw = function(text) strings[#strings + 1] = tostring(text) return 0 end + love.graphics.draw = function() end + pcall(battle.drawHUDs, battle, 0) + battle.drawBallRow, Font.draw, love.graphics.draw = realRow, realFont, realDraw + local hud = false + for _, s in ipairs(strings) do + if s:find(battle.enemy.name, 1, true) then hud = true end + end + return rows, hud + end + + game:startNewGame({ intro = false }) + game.save.options.textSpeed = 1 + + local function freshParty() + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12), + Pokemon.new(game.data, "PIDGEY", 9) } + end + + local function introText(b) + local cur = b.current and b.current.text + if cur == b.introText then return true end + return b.current == nil and b.queue[1] ~= nil and b.queue[1].text == b.introText + end + + local function watch(label, battle, holdShot) + U.teleport(game, "PALLET_TOWN", 9, 7, "down") + wait(5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + local r = { early = false, during = false, hudEarly = false, rowsEarly = false, + heldFrames = 0, holdShot = false } + local update = battle.update + battle.update = function(self, ...) + update(self, ...) + if (self.introSlide or 0) ~= 0 or game.stack:top() ~= self then return end + local atText = introText(self) + if self.introBalls == true and not atText then r.early = true end + if self.introBalls == true and self.waitingSound then r.during = true end + if not atText and not r.reachedText then + local rows, hud = snapshot(self) + if rows > 0 then r.rowsEarly = true end + if hud then r.hudEarly = true end + if self.waitingSound or (self.waitFrames or 0) > 0 then + r.heldFrames = r.heldFrames + 1 + end + end + if atText then r.reachedText = true end + end + local shotHeld = false + for _ = 1, 3000 do + wait(1) + if game.stack:top() == battle and (battle.introSlide or 0) == 0 + and not shotHeld and (battle.waitingSound or (battle.waitFrames or 0) > 0) + and not introText(battle) then + shotHeld = U.still(game, out .. "/" .. holdShot) + r.holdShot = shotHeld and battle.introBalls ~= true + end + if battle.current and battle.current.text == battle.introText then break end + end + return r + end + + local function waitPrompt(b) + for _ = 1, 600 do + if b.msgPrompt then return true end + wait(1) + end + return false + end + + freshParty() + local wild = BattleState.newWild(game, "RATTATA", 3) + local r = watch("wild", wild, "2662_01_wild_cry_no_balls.png") + check("wild: the cry held the queue after the slide landed", r.heldFrames > 0) + check("wild: captured the cry frame with no ball window", r.holdShot) + check("wild: no ball window before the intro text", not r.early) + check("wild: no ball window while the cry sounds", not r.during) + check("wild: no ball row drawn during the cry", not r.rowsEarly) + check("wild: no enemy HUD drawn during the cry", not r.hudEarly) + check("wild: the ball window is open with the intro text", wild.introBalls == true) + waitPrompt(wild) + check("wild: balls and text shot", U.still(game, out .. "/2662_02_wild_balls_with_text.png")) + + freshParty() + local tr = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) + r = watch("trainer", tr, "2662_03_trainer_sfx_no_balls.png") + check("trainer: the sfx and gap held the queue", r.heldFrames > 0) + check("trainer: captured the sfx frame with no ball window", r.holdShot) + check("trainer: no ball window before the intro text", not r.early) + check("trainer: no ball window while the sfx sounds", not r.during) + check("trainer: no ball rows drawn during the sfx and gap", not r.rowsEarly) + check("trainer: the ball window is open with the intro text", tr.introBalls == true) + waitPrompt(tr) + check("trainer: balls and text shot", U.still(game, out .. "/2662_04_trainer_balls_with_text.png")) + + freshParty() + local ghost = BattleState.newWild(game, "GASTLY", 20) + ghost:makeGhost() + U.teleport(game, "PALLET_TOWN", 9, 7, "down") + wait(5) + ghost.onFinish = function() end + game.overworld:pushBattle(ghost) + local ghostBalls, sawDarn = false, false + for _ = 1, 3000 do + wait(1) + if ghost.introBalls == true then ghostBalls = true end + local cur = ghost.current and ghost.current.text + if cur and cur:find("ID'd", 1, true) then sawDarn = true end + if ghost.msgPrompt then + if sawDarn then break end + wait(8) + U.tap(game, "a") + end + end + check("ghost: GhostCantBeIDdText follows the intro text", sawDarn) + check("ghost: DrawAllPokeballs never runs", not ghostBalls) + if sawDarn then + check("ghost: cant be IDd shot", U.still(game, out .. "/2662_05_ghost_cant_be_idd.png")) + end + end, debug.traceback) + if not ok then print("FAIL #2662 driver error: " .. tostring(err)) fails = fails + 1 end + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/battle_intro_bug317_test.lua b/tests/drivers/battle_intro_bug317_test.lua index aef76614..c5a7dfb8 100644 --- a/tests/drivers/battle_intro_bug317_test.lua +++ b/tests/drivers/battle_intro_bug317_test.lua @@ -6,7 +6,7 @@ -- POKEPORT_DRIVER=tests/drivers/battle_intro_bug317_test.lua POKEPORT_IDENTITY=bug317 SHOT_DIR=/tmp/shots love . return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" local Pokemon = require("src.pokemon.Pokemon") local BattleState = require("src.battle.BattleState") local HudTiles = require("src.render.HudTiles") @@ -19,8 +19,10 @@ return function(game) local TRAINER = "ROUTE3_YOUNGSTER1" local STAND = { x = 13, y = 6, facing = "left" } + local fails = 0 local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) + if not ok then fails = fails + 1 end return ok end @@ -103,8 +105,9 @@ return function(game) local wild = BattleState.newWild(game, "RATTATA", 3) wild.onFinish = function() end if ow then ow:pushBattle(wild) end - for _ = 1, 400 do - if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end + for _ = 1, 600 do + if game.stack:top() == wild and (wild.introSlide or 0) == 0 + and wild.current and wild.current.text == wild.introText then break end U.wait(1) end check("the wild battle reached the screen", @@ -226,8 +229,9 @@ return function(game) if ow then ow:pushBattle(battle) end end - for _ = 1, 400 do - if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + for _ = 1, 600 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 + and battle.current and battle.current.text == battle.introText then break end U.wait(1) end check("a trainer battle is on screen", liveBattle() ~= nil) @@ -249,7 +253,5 @@ return function(game) U.log("own edge before its send-out text prints (#317).") U.log("Screenshots of the wild half: " .. DIR .. "/bug317_*.png") - while true do - coroutine.yield() - end + love.event.quit(fails == 0 and 0 or 1) end diff --git a/tests/drivers/boulder_dust_color_bug2650.lua b/tests/drivers/boulder_dust_color_bug2650.lua new file mode 100644 index 00000000..5277d0df --- /dev/null +++ b/tests/drivers/boulder_dust_color_bug2650.lua @@ -0,0 +1,150 @@ +-- engine/overworld/dust_smoke.asm:21 +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local OW = require("src.world.OverworldController") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local MAP = "SEAFOAM_ISLANDS_1F" + local DIRS = { "right", "left", "down", "up" } + local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } + local PUSH = "left" + + local ok = true + local function check(label, pass) + print((pass and "PASS " or "FAIL ") .. label) + if not pass then ok = false end + return pass + end + local realDraw = love.graphics.draw + local function finish() + love.graphics.draw = realDraw + for _, d in ipairs(DIRS) do game.input.state[d] = false end + print(ok and "PASS boulder_dust_color_2650" or "FAIL boulder_dust_color_2650") + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + + local function upvalue(fn, wanted) + for i = 1, 200 do + local name, value = debug.getupvalue(fn, i) + if not name then return nil end + if name == wanted then return value end + end + end + local fxDust = upvalue(OW.drawWorld, "fxDust") + local flashImage = fxDust and upvalue(fxDust, "fieldFxFlashImage") + if not check("fxDust has an OBP1 flash bake helper", flashImage ~= nil) then finish() end + + U.teleport(game, MAP, 17, 10, "right") + U.wait(10) + PaletteFX.setMode("ogred") + local ow = game.overworld + game.data.encounters[MAP] = nil + local rock = ow and ow:npcAtCell(18, 10) + if not check("boulder present at (18,10) on " .. MAP, + rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then + finish() + end + ow.strengthActive = true + + local function free(x, y) + if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end + local n = ow:npcAtCell(x, y) + return n == nil or n == rock + end + local s = STEP[PUSH] + local cx, cy + for y = 9, 40 do + for x = 0, 60 do + if not cx and free(x, y) and free(x + s[1], y + s[2]) and free(x + 2 * s[1], y + 2 * s[2]) then + cx, cy = x, y + end + end + end + if not check("found an open lane for a push", cx ~= nil) then finish() end + + local function setup() + for _, d in ipairs(DIRS) do game.input.state[d] = false end + ow.dustAnim = nil + ow.boulderTried = nil + rock.cellX, rock.cellY = cx + s[1], cy + s[2] + rock.px, rock.py = rock.cellX * 16, rock.cellY * 16 + rock.moving, rock.targetX, rock.targetY = false, nil, nil + local p = ow.player + p.cellX, p.cellY = cx, cy + p.px, p.py = cx * 16, cy * 16 + p.moving, p.targetX, p.targetY = false, nil, nil + p.facing = PUSH + U.wait(8) + end + local function pushUntilDust() + local first = true + for _ = 1, 300 do + if ow.dustAnim and ow.dustAnim.boulder then return ow.dustAnim end + if first then table.insert(game.input.pressQueue, PUSH); first = false end + game.input.state[PUSH] = true + U.wait(1) + end + return nil + end + + local rec + love.graphics.draw = function(img, ...) + if rec and (img == rec.flash or img == ow.smokeImg) then + local _, _, _, a = love.graphics.getColor() + rec.n = rec.n + 1 + if (img == rec.flash) ~= rec.faded then rec.wrongImg = rec.wrongImg + 1 end + if a ~= 1 then rec.translucent = rec.translucent + 1 end + end + return realDraw(img, ...) + end + local function renderFrame(da, flash) + local update = rawget(game, "update") + game.update = function() end + rec = { flash = flash, faded = da.faded, n = 0, wrongImg = 0, translucent = 0 } + for _ = 1, 4000 do + if rec.n >= 4 then break end + coroutine.yield() + end + game.update = update + local r = rec + rec = nil + return r + end + + setup() + local da = pushUntilDust() + if not check("boulder dust starts after the push", da ~= nil) then finish() end + game.input.state[PUSH] = false + local flash = flashImage(game.data.field.overworldFx.smoke.path, 7) + local frames, flashFrames, wrong, translucent, missing = 0, 0, 0, 0, 0 + while ow.dustAnim == da and frames < 60 do + local r = renderFrame(da, flash) + frames = frames + 1 + if r.faded then flashFrames = flashFrames + 1 end + if r.n < 4 then missing = missing + 1 end + wrong = wrong + r.wrongImg + translucent = translucent + r.translucent + U.wait(1) + end + print(("[driver] dust frames %d flash %d wrong %d translucent %d missing %d") + :format(frames, flashFrames, wrong, translucent, missing)) + check("every dust frame rendered", missing == 0 and frames >= 23) + check("OBP1 %10000000 frames use the white/shade-2 flash bake", flashFrames >= 12 and wrong == 0) + check("dust is never drawn translucent", translucent == 0) + U.wait(30) + + setup() + da = pushUntilDust() + game.input.state[PUSH] = false + if da then + while ow.dustAnim == da and not da.faded do U.wait(1) end + U.still(game, DIR .. "/2650_01_dust_flash_white.png") + while ow.dustAnim == da and da.faded do U.wait(1) end + if ow.dustAnim == da then + U.still(game, DIR .. "/2650_02_dust_identity_floor_ramp.png") + end + end + U.wait(30) + finish() +end diff --git a/tests/drivers/champion_end_text_bug2660.lua b/tests/drivers/champion_end_text_bug2660.lua new file mode 100644 index 00000000..93472bc8 --- /dev/null +++ b/tests/drivers/champion_end_text_bug2660.lua @@ -0,0 +1,107 @@ +-- scripts/ChampionsRoom.asm:65 +-- tools/run_driver.sh red tests/drivers/champion_end_text_bug2660.lua +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + print((ok and "PASS " or "FAIL ") .. label) + return ok + end + local function oneline(s) + return (tostring(s):gsub("[\n\v\f]", " / ")) + end + local function finish() + U.log("done:", failures, "failure(s)") + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local expected = game.data.text and game.data.text._RivalDefeatedText + check("cache carries _RivalDefeatedText", type(expected) == "string") + + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { { id = "PSYCHIC_M", pp = 10, ppUps = 0 } } + if not game.data.moves.PSYCHIC_M then + mon.moves = { { id = "PSYCHIC", pp = 10, ppUps = 0 } } + end + game.save.party = { mon } + game.save.player.name = "RED" + game.save.player.rival = "BLUE" + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil + game.save.flags.EVENT_BEAT_CHAMPION_RIVAL = nil + + U.teleport(game, "CHAMPIONS_ROOM", 4, 7, "up") + + local battle, armed + local sawDefeated, sawLoss, sawMoney + local lossText, lossShot, headShot, lastText = nil, false, false, nil + for _ = 1, 20000 do + local top = game.stack:top() + if getmetatable(top) == BattleState then + if not battle then + battle = top + armed = top.endBattleText + U.log("battle pushed; endBattleText:", oneline(armed)) + for _, m in ipairs(top.enemyParty or {}) do m.hp = 1 end + if top.enemy and top.enemy.mon then top.enemy.mon.hp = 1 end + end + local cur = top.current + local text = cur and cur.text + if text and text ~= lastText then + lastText = text + if text:find("defeated", 1, true) then sawDefeated = sawDefeated or U.frame() end + if text:find("NO!", 1, true) and text:find("That can't be!", 1, true) then + sawLoss = sawLoss or U.frame() + lossText = text + end + if text:find("for winning", 1, true) then sawMoney = sawMoney or U.frame() end + end + local typingLoss = lossText and text == lossText + if typingLoss and not headShot then + if top.lineIndex == 2 and top.msgWaiting + and (top.frame or 0) % 60 < 30 then + headShot = U.still(game, DIR .. "/2660_01_rival_loss_first_page.png") + end + end + if typingLoss and not lossShot + and top.charIndex and top.total and top.charIndex >= top.total then + lossShot = U.still(game, DIR .. "/2660_02_rival_loss_scrolled.png") + end + if sawMoney then break end + if typingLoss and not headShot then + U.wait(1) + else + U.tap(game, "a") + U.wait(3) + end + elseif battle then + break + else + U.tap(game, "a") + U.wait(3) + end + end + + check("champion battle pushed", battle ~= nil) + check("battle armed with _RivalDefeatedText", + type(armed) == "string" and armed:find("That can't be!", 1, true) ~= nil) + check("defeated line printed", sawDefeated ~= nil) + check("rival loss line printed on the battle screen", sawLoss ~= nil) + if lossText then + U.log("loss line:", oneline(lossText)) + check("loss line opens with the rival name tag", lossText:sub(1, 6) == "BLUE: ") + end + check("money line printed", sawMoney ~= nil) + check("loss line sits between defeated and money", + sawDefeated ~= nil and sawLoss ~= nil and sawMoney ~= nil + and sawDefeated < sawLoss and sawLoss < sawMoney) + check("loss line first page screenshotted", headShot) + check("loss line screenshotted", lossShot) + finish() +end diff --git a/tests/drivers/champion_entrance_bug2666.lua b/tests/drivers/champion_entrance_bug2666.lua new file mode 100644 index 00000000..835dde7d --- /dev/null +++ b/tests/drivers/champion_entrance_bug2666.lua @@ -0,0 +1,65 @@ +-- scripts/ChampionsRoom.asm:30 +local U = require('tests.drivers.util') +local Version = require('src.core.GameVersion') +local TextBox = require('src.render.TextBox') + +local WANT = '3,7 3,6 3,5 3,4 4,4 4,3' + +return function(game) + local deadline = love.timer.getTime() + 90 + local ok, err = xpcall(function() + assert(Version.generation() == 1, 'Gen1 edition required') + local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required') + love.audio.setVolume(0) + game:startNewGame({ intro = false }) + + local function run(shootAt) + game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil + U.teleport(game, 'CHAMPIONS_ROOM', 3, 7, 'up') + local ow = assert(game.overworld) + local cells, last = {}, nil + local frames, turnFrame = 0, nil + for _ = 1, 1200 do + assert(love.timer.getTime() < deadline, '#2666 deadline') + local p = ow.player + local key = p.cellX .. ',' .. p.cellY + if key ~= last then + cells[#cells + 1] = key + last = key + if key == '3,4' and not turnFrame then + turnFrame = frames + if shootAt then + assert(U.still(game, out .. '/2666_01_turn_point_left_column.png'), 'capture failed') + end + end + end + if getmetatable(game.stack:top()) == TextBox then + return table.concat(cells, ' '), turnFrame, p.facing + end + U.wait(1) + frames = frames + 1 + end + error('rival text never opened; walked ' .. table.concat(cells, ' ')) + end + + local path, turnFrame, facing = run(false) + if path == WANT then + print('PASS #2666 entrance walk ' .. path) + else + error('entrance walk ' .. path .. ' want ' .. WANT) + end + if facing == 'up' then + print('PASS #2666 player faces the rival') + else + error('player faces ' .. tostring(facing)) + end + assert(turnFrame, 'never reached (3,4)') + + run(true) + U.wait(40) + assert(U.still(game, out .. '/2666_02_rival_hey.png'), 'capture failed') + print('PASS #2666 turn point shot at frame ' .. turnFrame) + end, debug.traceback) + if not ok then print('FAIL #2666 ' .. tostring(err)) end + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/continue_info_counts_bug2684.lua b/tests/drivers/continue_info_counts_bug2684.lua new file mode 100644 index 00000000..a78be60b --- /dev/null +++ b/tests/drivers/continue_info_counts_bug2684.lua @@ -0,0 +1,180 @@ +-- engine/menus/main_menu.asm:357 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Font = require("src.render.Font") + local SaveData = require("src.core.SaveData") + local GameVersion = require("src.core.GameVersion") + local TitleState = require("src.ui.TitleState") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots/2684" + + local fails = 0 + local function check(label, ok, detail) + print((ok and "PASS " or "FAIL ") .. label .. (detail and (" " .. tostring(detail)) or "")) + if not ok then fails = fails + 1 end + return ok + end + local function finish() + love.event.quit(fails == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local rendered = 0 + local hostDraw = love.draw + love.draw = function(...) + local r = hostDraw(...) + rendered = rendered + 1 + return r + end + local function waitRendered(n) + local target = rendered + (n or 2) + for _ = 1, 2400 do + if rendered >= target then return true end + U.wait(1) + end + return false + end + local function waitFor(pred, limit) + for _ = 1, limit or 1800 do + if pred() then return true end + U.wait(1) + end + return false + end + + local function letterbox(img) + local W, H = img:getWidth(), img:getHeight() + local x1, y1, x2, y2 = W, H, -1, -1 + for y = 0, H - 1, 2 do + for x = 0, W - 1, 2 do + local r, g, b = img:getPixel(x, y) + if r > 0.02 or g > 0.02 or b > 0.02 then + if x < x1 then x1 = x end + if x > x2 then x2 = x end + if y < y1 then y1 = y end + if y > y2 then y2 = y end + end + end + end + local bw, bh = x2 - x1 + 1, y2 - y1 + 1 + if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then + return x1, y1, bw / 160, bh / 144 + end + local s = math.min(W / 160, H / 144) + return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s, s + end + local function inkInTile(img, ox, oy, sx, sy, col, row) + local W, H = img:getWidth(), img:getHeight() + local n = 0 + for py = 1, 6 do + for px = 1, 6 do + local x = math.floor(ox + (col * 8 + px + 0.5) * sx) + local y = math.floor(oy + (row * 8 + py + 0.5) * sy) + if x >= 0 and y >= 0 and x < W and y < H then + local r, g, b = img:getPixel(x, y) + if (r + g + b) / 3 < 0.35 then n = n + 1 end + end + end + end + return n + end + + U.wait(5) + local title + local reached = false + for _ = 1, 120 do + title = game.stack:top() + if title ~= nil and title.screenId == "TitleState" + and type(title.openMenu) == "function" then + reached = true + break + end + U.tap(game, "start") + U.wait(10) + end + if not check("reached the title screen", reached) then + print("top state: " .. tostring(title and title.screenId)) + return finish() + end + check("title reached its loop", waitFor(function() + return title.phase == "loop" + end)) + waitRendered(3) + + local saveName = SaveData.saveFilename(GameVersion.get()) + if not love.filesystem.getInfo(saveName) then + love.filesystem.write(saveName, "return {}") + end + local owned = {} + for i = 1, 151 do owned[i] = true end + local inventory = {} + for _, id in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", + "EARTHBADGE" }) do + inventory[id] = 1 + end + local savedLoad = SaveData.load + SaveData.load = function() + return { player = { name = "RED" }, inventory = inventory, + pokedex = { owned = owned }, playTime = 47 * 3600 + 27 * 60 } + end + + U.tap(game, "start") + check("START leaves the title for the main menu", waitFor(function() + local top = game.stack:top() + return title.menuOpen and top ~= title and top and top.titleUiBox ~= nil + end, 1200)) + waitRendered(3) + local menu = game.stack:top() + check("main menu opens on CONTINUE", menu and menu.items + and menu.items[1] and menu.items[1].label == require("src.core.Strings")("CONTINUE")) + U.tap(game, "a") + local info + check("CONTINUE opens the info window", waitFor(function() + info = game.stack:top() + return info ~= menu and info and info.titleUiBox + and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7 + end, 300)) + SaveData.load = savedLoad + if not info or info == menu then return finish() end + + local drawn = {} + local realDraw = Font.draw + Font.draw = function(text, x, y, ...) + drawn[text] = { x, y } + return realDraw(text, x, y, ...) + end + waitRendered(3) + Font.draw = realDraw + + local b, d, t = drawn[" 8"], drawn["151"], drawn[" 47:27"] + check("badge count drawn at hlcoord 17,11", + b and b[1] == 17 * 8 and b[2] == 11 * 8, b and (b[1] .. "," .. b[2])) + check("dex count drawn at hlcoord 16,13", + d and d[1] == 16 * 8 and d[2] == 13 * 8, d and (d[1] .. "," .. d[2])) + check("play time drawn at hlcoord 13,15", + t and t[1] == 13 * 8 and t[2] == 15 * 8, t and (t[1] .. "," .. t[2])) + + local shot = SHOT_DIR .. "/2684_01_continue_counts_flush_right.png" + if check("info window shot", U.still(game, shot)) then + local f = io.open(shot, "rb") + local bytes = f and f:read("*a") + if f then f:close() end + local ok, img = pcall(function() + return love.image.newImageData(love.filesystem.newFileData(bytes, "s.png")) + end) + if check("shot decodes", ok and img) then + local ox, oy, sx, sy = letterbox(img) + check("badge 8 inks col 18 row 11", inkInTile(img, ox, oy, sx, sy, 18, 11) > 0) + check("col 17 row 11 stays blank", inkInTile(img, ox, oy, sx, sy, 17, 11) == 0) + check("dex 151 inks col 18 row 13", inkInTile(img, ox, oy, sx, sy, 18, 13) > 0) + check("col 15 row 13 stays blank", inkInTile(img, ox, oy, sx, sy, 15, 13) == 0) + check("time inks col 18 row 15", inkInTile(img, ox, oy, sx, sy, 18, 15) > 0) + end + end + + game.stack:pop() + waitRendered(2) + print(fails == 0 and "PASS continue_info_counts_bug2684" or "FAIL continue_info_counts_bug2684") + return finish() +end diff --git a/tests/drivers/dex_rating_complete_bug2674.lua b/tests/drivers/dex_rating_complete_bug2674.lua new file mode 100644 index 00000000..6c92b0ab --- /dev/null +++ b/tests/drivers/dex_rating_complete_bug2674.lua @@ -0,0 +1,98 @@ +-- engine/events/pokedex_rating.asm:33 +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local Sound = require("src.core.Sound") + local Timing = require("src.core.Timing") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + + local ok = true + local function check(label, pass) + print((pass and "PASS " or "FAIL ") .. label) + if not pass then ok = false end + return pass + end + local realPlay = Sound.play + local plays = {} + Sound.play = function(data, name, ...) + plays[#plays + 1] = name + return realPlay(data, name, ...) + end + local function finish() + Sound.play = realPlay + print(ok and "PASS dex_rating_complete_2674" or "FAIL dex_rating_complete_2674") + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + + U.teleport(game, "OAKS_LAB", 5, 3, "up") + U.wait(10) + local ow = game.overworld + if not check("overworld is up", ow ~= nil) then finish() end + local full = {} + for i = 1, 151 do full[i] = true end + game.save.pokedex = { seen = full, owned = full } + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + local closed = false + ow:dexRating(function() closed = true end) + local box = game.stack:top() + if not check("rating box pushed", getmetatable(box) == TextBox) then finish() end + + local SPACE = 0x7F + local target + for _ = 1, 6000 do + U.wait(1) + if box.waiting and box.contAdvance and #box.shown == 2 and #box.shown[2] == 18 then + target = true + break + end + if box.waiting and not box.contAdvance and (box.preWait or 0) == 0 then + U.tap(game, "a") + end + if box.done then break end + end + if not check("reached the cont wait under an 18-tile line", target) then finish() end + check("the cont line is the Own150To151 text", + (box.pages[box.pageIndex][box.lineIndex] or ""):find("complete", 1, true) ~= nil) + check("arrow is up over cell (18,16)", box:arrowVisible() and box.fixedGen1Rows) + local early = false + for _, name in ipairs(plays) do + if name == "Get_Item2" or name == "Pokedex_Rating" then early = true end + end + check("no rating jingle yet", not early) + for _ = 1, 120 do + if box.blink % 60 < 30 then break end + U.wait(1) + end + U.still(game, DIR .. "/2674_01_arrow_replaces_bang.png") + + for _ = 1, Timing.TEXT_PRE_ADVANCE + 2 do U.wait(1) end + U.tap(game, "a") + U.wait(2) + check("scrolled line carries a space in column 18", + box.shown[1] and box.shown[1][18] == SPACE) + + for _ = 1, 6000 do + if box.done and not box.auto then break end + U.wait(1) + end + check("box finished typing and the jingle wait is over", box.done and not box.auto) + local rated + for _, name in ipairs(plays) do + if name == "Get_Item2" then rated = true end + if name == "Pokedex_Rating" then rated = false break end + end + check("151 owned plays SFX_GET_ITEM_2", rated == true) + for _ = 1, 120 do + if box.blink % 60 < 30 then break end + U.wait(1) + end + U.still(game, DIR .. "/2674_02_complete_without_bang.png") + + U.tap(game, "a") + U.wait(5) + check("A closes the rating", closed) + finish() +end diff --git a/tests/drivers/diploma_og_obj_bug2676.lua b/tests/drivers/diploma_og_obj_bug2676.lua new file mode 100644 index 00000000..99ccd318 --- /dev/null +++ b/tests/drivers/diploma_og_obj_bug2676.lua @@ -0,0 +1,170 @@ +-- engine/events/diploma.asm:44 +return function(game) + local U = dofile("tests/drivers/util.lua") + local P = require("src.render.PaletteFX") + local Diploma = require("src.ui.Diploma") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + + local fails = 0 + local function check(label, ok, detail) + print((ok and "PASS " or "FAIL ") .. label + .. (detail ~= nil and (" [" .. tostring(detail) .. "]") or "")) + if not ok then fails = fails + 1 end + return ok + end + + local rendered = 0 + local hostDraw = love.draw + love.draw = function(...) + local r = hostDraw(...) + rendered = rendered + 1 + return r + end + local function waitRendered(n) + local target = rendered + (n or 2) + for _ = 1, 2400 do + if rendered >= target then return true end + U.wait(1) + end + return false + end + + local function near(R, G, B, c) + return math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12 + and math.abs(B - c[3]) <= 12 + end + + local function letterbox(img) + local W, H = img:getWidth(), img:getHeight() + local x1, y1, x2, y2 = W, H, -1, -1 + for y = 0, H - 1 do + for x = 0, W - 1 do + local r, g, b = img:getPixel(x, y) + if r > 0.02 or g > 0.02 or b > 0.02 then + if x < x1 then x1 = x end + if x > x2 then x2 = x end + if y < y1 then y1 = y end + if y > y2 then y2 = y end + end + end + end + local bw, bh = x2 - x1 + 1, y2 - y1 + 1 + if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then + return x1, y1, bh / 144 + end + local s = math.min(W / 160, H / 144) + return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s + end + + local function load(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData( + love.filesystem.newFileData(bytes, "shot.png")) + end) + if not ok then return nil end + return img + end + + local function census(img, objRamp, bgRamp, bx1, by1, bx2, by2) + local W, H = img:getWidth(), img:getHeight() + local ox, oy, s = letterbox(img) + local c = { obj = 0, bg = 0, grey = 0, ink = 0, nonwhite = 0 } + for gy = by1, by2 - 1 do + for gx = bx1, bx2 - 1 do + local x = ox + math.floor((gx + 0.5) * s) + local y = oy + math.floor((gy + 0.5) * s) + if x >= 0 and x < W and y >= 0 and y < H then + local r, g, b = img:getPixel(x, y) + local R = math.floor(r * 255 + 0.5) + local G = math.floor(g * 255 + 0.5) + local B = math.floor(b * 255 + 0.5) + if R < 30 and G < 30 and B < 30 then c.ink = c.ink + 1 end + if not (R > 230 and G > 230 and B > 230) then + c.nonwhite = c.nonwhite + 1 + end + if R == G and G == B and R > 60 and R < 240 then c.grey = c.grey + 1 end + for _, k in ipairs(objRamp or {}) do + if near(R, G, B, k) then c.obj = c.obj + 1 break end + end + for _, k in ipairs(bgRamp or {}) do + if near(R, G, B, k) then c.bg = c.bg + 1 break end + end + end + end + end + return c + end + + U.wait(5) + U.teleport(game, "CELADON_MANSION_3F", 2, 4, "up") + game.save.player.name = "RED" + local d = Diploma.new(game) + game.stack:push(d) + + game.save.options.colors = "ogred" + P.applyOptions(game.save.options) + check("COLORS ogred uses the sprite OBJ path", P.usesSpriteObp() == true, + P.mode) + local objRamp = { P.ogObjBase()[2], P.ogObjBase()[3] } + local bgRamp = { P.ogBg()[2], P.ogBg()[3] } + waitRendered(4) + local maxRedraws = 0 + for _ = 1, 4 do + waitRendered(1) + local n = #P.uiSpriteRedraws() + if n > maxRedraws then maxRedraws = n end + end + check("ogred records the pic and the text overlay for replay", + maxRedraws == 2, maxRedraws) + + local shot1 = SHOT_DIR .. "/2676_01_ogred_player_obj_green.png" + if check("shot ogred", U.shot(game, shot1)) then + local img = load(shot1) + if check("ogred shot decodes", img ~= nil) then + local body = census(img, objRamp, bgRamp, 115, 80, 152, 136) + check("ogred player wears the OBJ ramp", body.obj > 200, body.obj) + check("ogred player has no BG-red pixels", body.bg == 0, body.bg) + check("ogred player has no DMG grey", body.grey == 0, body.grey) + local fist = census(img, objRamp, nil, 152, 100, 155, 116) + check("ogred fist reaches columns 152-154", fist.obj > 0, fist.obj) + local r = census(img, objRamp, nil, 120, 96, 128, 104) + check("ogred the r of your stays in front of the player", r.ink > 4, + r.ink) + local gf = census(img, objRamp, nil, 115, 128, 152, 136) + check("ogred GAME FREAK stays in front of the player", gf.ink > 20, + gf.ink) + end + end + + P.applyOptions({ colors = "gbc" }) + waitRendered(4) + local gbcRedraws = 0 + for _ = 1, 4 do + waitRendered(1) + local n = #P.uiSpriteRedraws() + if n > gbcRedraws then gbcRedraws = n end + end + check("gbc records no OBJ replay", gbcRedraws == 0, gbcRedraws) + local shot2 = SHOT_DIR .. "/2676_02_gbc_fist_uncropped.png" + if check("shot gbc", U.shot(game, shot2)) then + local img = load(shot2) + if check("gbc shot decodes", img ~= nil) then + local body = census(img, objRamp, nil, 115, 80, 152, 136) + check("gbc player never wears the OG OBJ ramp", body.obj == 0, body.obj) + local fist = census(img, nil, nil, 152, 100, 155, 116) + check("gbc fist reaches columns 152-154", fist.nonwhite > 0, + fist.nonwhite) + end + end + + game.save.options.colors = "ogred" + P.applyOptions(game.save.options) + waitRendered(2) + print(fails == 0 and "PASS diploma_2676_all" or "FAIL diploma_2676_all") + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/e4_exit_after_text_bug2658.lua b/tests/drivers/e4_exit_after_text_bug2658.lua new file mode 100644 index 00000000..09ef2457 --- /dev/null +++ b/tests/drivers/e4_exit_after_text_bug2658.lua @@ -0,0 +1,90 @@ +-- scripts/AgathasRoom.asm:2 +-- scripts/AgathasRoom.asm:108 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local CLOSED, OPEN = 0x3b, 0x0e + local ok = true + + local function check(label, cond) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then ok = false end + return cond + end + + local function finish() + U.wait(5) + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { { id = "PSYCHIC_M", pp = 63 } } + game.save.party = { mon } + game.save.flags.EVENT_AUTOWALKED_INTO_AGATHAS_ROOM = true + game.save.flags.EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 = nil + game.save.defeatedTrainers = game.save.defeatedTrainers or {} + game.save.defeatedTrainers["AGATHAS_ROOM_obj_1"] = nil + + U.teleport(game, "AGATHAS_ROOM", 5, 3, "up") + U.wait(30) + local ow = game.overworld + if not check("2658 overworld up in AGATHAS_ROOM", ow and ow.map.id == "AGATHAS_ROOM") then + return finish() + end + check("2658 exit shut before the fight", ow.map:blockAt(2, 0) == CLOSED) + + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top ~= ow and top and top.enemy then battle = top break end + U.tap(game, "a") + U.wait(2) + end + if not check("2658 talking to Agatha starts the battle", battle ~= nil) then + return finish() + end + + for _ = 1, 20000 do + if game.stack:top() ~= battle then + local inStack = false + for _, s in ipairs(game.stack.states or {}) do + if s == battle then inStack = true break end + end + if not inStack then break end + end + if battle.enemy and battle.enemy.mon and (battle.enemy.mon.hp or 0) > 1 then + battle.enemy.mon.hp = 1 + end + U.tap(game, "a") + U.wait(2) + end + local box + for _ = 1, 3000 do + local top = game.stack:top() + if top ~= battle and getmetatable(top) == TextBox then box = top break end + U.wait(1) + end + if not check("2658 after-battle text box shows in the overworld", box ~= nil) then + return finish() + end + U.wait(240) + check("2658 Agatha beaten", game.save.flags.EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 and true or false) + check("2658 exit still shut while after-battle text is up", + ow.map:blockAt(2, 0) == CLOSED) + U.still(game, DIR .. "/2658_01_after_text_exit_shut.png") + + for _ = 1, 600 do + if game.stack:top() == ow then break end + U.tap(game, "a") + U.wait(4) + end + check("2658 after-battle text closed", game.stack:top() == ow) + U.wait(10) + check("2658 exit opens after the text closes", ow.map:blockAt(2, 0) == OPEN) + U.still(game, DIR .. "/2658_02_exit_open_after_text.png") + + finish() +end diff --git a/tests/drivers/evolution_double_bug2664.lua b/tests/drivers/evolution_double_bug2664.lua new file mode 100644 index 00000000..e3a4cecd --- /dev/null +++ b/tests/drivers/evolution_double_bug2664.lua @@ -0,0 +1,119 @@ +-- engine/pokemon/evos_moves.asm:156 +-- engine/battle/end_of_battle.asm:42 +-- POKEPORT_DRIVER=tests/drivers/evolution_double_bug2664.lua POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + local Music = require("src.core.Music") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/evo2664" + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + + local ok, err = pcall(function() + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + local pin = function(_, b) return b end + local dratini = Pokemon.new(game.data, "DRATINI", 29, pin) + local def = game.data.pokemon.DRATINI + dratini.exp = Growth.expForLevel(def.growthRate, 30, game.data.growth_rates) - 1 + dratini.moves = { { id = dratini.moves[1].id, pp = dratini.moves[1].pp } } + local tentacool = Pokemon.new(game.data, "TENTACOOL", 30, pin) + tentacool.moves = { tentacool.moves[1] } + game.save.party = { dratini, tentacool } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local restores = 0 + local realRestore = Music.restoreMap + Music.restoreMap = function(...) + restores = restores + 1 + return realRestore(...) + end + + local battle = BattleState.newWild(game, "RATTATA", 2) + battle.rng = function(a, _) return a end + battle.enemy.mon.hp = 1 + battle.enemy.mon.stats.speed = 1 + battle.leveledUp = { [tentacool] = true } + ow:pushBattle(battle) + + for _ = 1, 400 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(3) + end + if not check("reached the FIGHT menu", battle.phase == "menu") then return end + U.tap(game, "a") + for _ = 1, 60 do + if battle.phase == "moveSelect" then break end + U.wait(1) + end + U.tap(game, "a") + + local function inStack(state) + for i, s in ipairs(game.stack.states) do + if s == state then return i end + end + end + + local firstIntro, secondIntro, shot = nil, nil, false + local belowSecond, restoresAtSecond, baseAtSecond, battleAtSecond + for _ = 1, 20000 do + local top = game.stack:top() + if top and top.evoIntro then + if not firstIntro then + firstIntro = top + elseif top ~= firstIntro and not secondIntro then + secondIntro = top + local i = inStack(top) + belowSecond = game.stack.states[i - 1] + restoresAtSecond = restores + baseAtSecond = game.stack:visibleBase() + battleAtSecond = inStack(battle) + end + end + if secondIntro and not shot then + local i = inStack(secondIntro) + if i and i == #game.stack.states - 1 then + shot = U.still(game, DIR .. "/2664_01_second_intro_on_blank.png") + end + end + if not inStack(battle) then break end + U.tap(game, "a") + U.wait(2) + end + + check("first mon evolved", dratini.species == "DRAGONAIR") + check("second mon evolved", tentacool.species == "TENTACRUEL") + if check("second IsEvolvingText box opened", secondIntro ~= nil) then + check("second intro sits on an opaque blank screen", + belowSecond ~= nil and belowSecond.evoClear == true + and belowSecond.isOpaque == true) + check("battle screen is not drawn under the second intro", + battleAtSecond ~= nil and baseAtSecond > battleAtSecond) + check("no map music between the two evolutions", restoresAtSecond == 0) + end + check("shot of the second intro captured", shot) + check("battle closed after the evolutions", inStack(battle) == nil) + local stray = false + for _, s in ipairs(game.stack.states) do + if s.evoClear or s.evoIntro then stray = true end + end + check("no evolution layers left on the stack", not stray) + Music.restoreMap = realRestore + end) + if not ok then + print("FAIL driver error: " .. tostring(err)) + failed = true + end + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/game3_shaderfx_shots.lua b/tests/drivers/game3_shaderfx_shots.lua index bebdde07..6d43ac3a 100644 --- a/tests/drivers/game3_shaderfx_shots.lua +++ b/tests/drivers/game3_shaderfx_shots.lua @@ -161,7 +161,7 @@ return function(game) local top = Option._pages[1] local gi for i, row in ipairs(top.rows) do - if row.port and row.port.id == "group.graphics" then gi = i end + if row.id == "group.graphics" then gi = i end end if check(gi ~= nil, version .. " RS OPTION has a visible GRAPHICS row") then top.index = gi diff --git a/tests/drivers/grass_feet_strip_bug2688.lua b/tests/drivers/grass_feet_strip_bug2688.lua new file mode 100644 index 00000000..e3911f82 --- /dev/null +++ b/tests/drivers/grass_feet_strip_bug2688.lua @@ -0,0 +1,144 @@ +-- data/sprites/facings.asm:51 +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local Assets = require("src.render.Assets") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "." + + local fails = 0 + local function check(cond, label) + if cond then print("PASS " .. label) else + fails = fails + 1 + print("FAIL " .. label) + end + end + + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + game.save.options = game.save.options or {} + local savedSpeed = game.speedOverride + game.speedOverride = 1 + + local function grab() + local got + love.graphics.captureScreenshot(function(id) got = id end) + for _ = 1, 600 do + if got then return got end + coroutine.yield() + end + return nil + end + + local function frameRows(sprite) + local ok, id = pcall(Assets.imageData, sprite.def.image) + if not ok or not id then return 0, 15 end + local first, last + for y = 0, math.min(sprite.frameHeight, id:getHeight()) - 1 do + for x = 0, math.min(sprite.frameWidth, id:getWidth()) - 1 do + local r = id:getPixel(x, y) + if r < 0.83 then + first = first or y + last = y + break + end + end + end + return first or 0, last or 15 + end + + local function differs(a, b, x, y) + local r1, g1, b1 = a:getPixel(x, y) + local r2, g2, b2 = b:getPixel(x, y) + return math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) > 0.05 + end + + local function measure(tag) + local ow = game.overworld + local r = ow.map.renderer + U.wait(4) + local withGrass = grab() + r.drawStrip = function() end + r.markStripRedraw = function() end + U.wait(2) + local noGrass = grab() + ow.playerHidden = true + U.wait(2) + local noPlayer = grab() + ow.playerHidden = false + r.drawStrip = nil + r.markStripRedraw = nil + U.wait(2) + if not (withGrass and noGrass and noPlayer) then + check(false, tag .. " captured three frames") + return + end + local w, h = withGrass:getDimensions() + local x0, y0, x1, y1 + for y = 0, h - 1 do + for x = 0, w - 1 do + if differs(noGrass, noPlayer, x, y) then + x0 = math.min(x0 or x, x); x1 = math.max(x1 or x, x) + y0 = math.min(y0 or y, y); y1 = math.max(y1 or y, y) + end + end + end + if not y0 then + check(false, tag .. " found the player sprite on screen") + return + end + local first, last = frameRows(ow.player.sprite) + local scale = (y1 - y0 + 1) / (last - first + 1) + local top = y0 - first * scale + local rows = {} + for y = math.floor(top), math.floor(top + 20 * scale) do + if y >= 0 and y < h then + for x = x0, x1 do + if differs(withGrass, noGrass, x, y) then + local row = math.floor((y - top) / scale) + rows[row] = (rows[row] or 0) + 1 + end + end + end + end + local above, mid, low = 0, 0, 0 + for row, n in pairs(rows) do + if row < 8 then above = above + n + elseif row < 12 then mid = mid + n + else low = low + n end + end + U.log(tag, "scale=" .. scale, "rows0-7=" .. above, "rows8-11=" .. mid, + "rows12-15=" .. low) + check(above == 0, tag .. " grass leaves sprite rows 0-7 alone") + check(mid > 0, tag .. " grass covers sprite rows 8-11") + check(low > 0, tag .. " grass covers sprite rows 12-15") + end + + local modes = { + { "gbc", "sgb" }, { "redpp", "advanced" }, { "ogred", "ogred" }, { "og", "og" }, + } + for _, m in ipairs(modes) do + game.save.options.colors = m[1] + PaletteFX.setMode(m[1]) + U.teleport(game, "ROUTE_1", 10, 6, "down") + U.wait(20) + check(game.overworld.map:isGrassCell(game.overworld.player.cellX, + game.overworld.player.cellY), + m[2] .. " player stands in tall grass") + measure(m[2]) + end + + for i, m in ipairs(modes) do + game.save.options.colors = m[1] + PaletteFX.setMode(m[1]) + U.teleport(game, "ROUTE_1", 10, 6, "down") + U.wait(20) + U.shot(game, DIR .. "/2688_0" .. i .. "_" .. m[2] .. "_grass_from_shoulders_down.png") + end + + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + game.speedOverride = savedSpeed + U.wait(2) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/indigo_statues_bug2683.lua b/tests/drivers/indigo_statues_bug2683.lua new file mode 100644 index 00000000..62738bda --- /dev/null +++ b/tests/drivers/indigo_statues_bug2683.lua @@ -0,0 +1,80 @@ +local U = require('tests.drivers.util') +local Version = require('src.core.GameVersion') +local TextBox = require('src.render.TextBox') + +local function flatten(t, out) + out = out or {} + if type(t) == 'string' then out[#out + 1] = t + elseif type(t) == 'table' then for _, v in ipairs(t) do flatten(v, out) end end + return out +end + +return function(game) + local deadline = love.timer.getTime() + 60 + local ok, err = xpcall(function() + assert(Version.generation() == 1, 'Gen1 edition required') + local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required') + love.audio.setVolume(0) + local function wait(n) + for _ = 1, n do + assert(love.timer.getTime() < deadline, '#2683 deadline') + U.wait(1) + end + end + local function untilReady(fn, limit, label) + for _ = 1, limit do if fn() then return end; wait(1) end + error(label) + end + local text = game.data.text + local want = { + odd = assert(text._IndigoPlateauStatuesText2, 'Text2 missing from cache'), + even = assert(text._IndigoPlateauStatuesText3, 'Text3 missing from cache'), + } + game:startNewGame({ intro = false }) + U.teleport(game, 'INDIGO_PLATEAU', 9, 8, 'down') + local ow = assert(game.overworld) + local map = ow.map + local spots = {} + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:inBounds(cx, cy) and map:inBounds(cx, cy + 1) + and map:cellTile(cx, cy) == 0x30 and map:isWalkableCell(cx, cy + 1) then + local key = (cx % 2 == 1) and 'odd' or 'even' + if not spots[key] then spots[key] = { cx, cy + 1 } end + end + end + end + assert(spots.odd and spots.even, 'no statue spots found on INDIGO_PLATEAU') + for i, key in ipairs({ 'odd', 'even' }) do + local x, y = spots[key][1], spots[key][2] + U.teleport(game, 'INDIGO_PLATEAU', x, y, 'up') + ow = assert(game.overworld) + untilReady(function() + return game.stack:top() == ow and not ow.transitioning and not ow.player.moving + end, 400, 'field did not settle') + assert(ow.player.cellX == x and ow.player.facing == 'up', 'player not placed facing the statue') + U.tap(game, 'a') + untilReady(function() return getmetatable(game.stack:top()) == TextBox end, 120, 'statue text never opened') + local box = game.stack:top() + local joined = table.concat(flatten(box.pages), ' ') + local expect = TextBox.strip(TextBox.substitute(game, want[key])):gsub('[\n\f]', ' ') + local firstWord = expect:match('%S+%s+%S+') + assert(joined:find(firstWord, 1, true), key .. ' X ' .. x .. ' printed the wrong caption: ' .. joined) + untilReady(function() + if box.pageIndex == #box.pages and box.waiting then return true end + if box.waiting then U.tap(game, 'a') end + return false + end, 1200, 'caption page never settled') + local name = string.format('2683_%02d_%s_x%d_%s.png', i, key, x, + key == 'odd' and 'ultimate_goal' or 'highest_authority') + assert(U.still(game, out .. '/' .. name), 'capture failed') + print('PASS #2683 ' .. Version.get() .. ' ' .. key .. ' X=' .. x .. ' shows ' .. (key == 'odd' and 'Text2' or 'Text3')) + for _ = 1, 20 do + if game.stack:top() ~= box then break end + U.tap(game, 'a'); wait(4) + end + end + end, debug.traceback) + if not ok then print('FAIL #2683 ' .. tostring(err)) end + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/lance_side_trigger_bug2669.lua b/tests/drivers/lance_side_trigger_bug2669.lua new file mode 100644 index 00000000..643b9255 --- /dev/null +++ b/tests/drivers/lance_side_trigger_bug2669.lua @@ -0,0 +1,57 @@ +-- scripts/LancesRoom.asm:54 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local ok = true + + local function check(label, cond) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then ok = false end + return cond + end + + local function finish() + U.wait(5) + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + + local function findLance(ow) + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name == "LANCESROOM_LANCE" then return npc end + end + end + + local function approach(fromX, fromY, dir, label, shotName) + U.teleport(game, "LANCES_ROOM", fromX, fromY, dir) + local ow = game.overworld + if not check(label .. " overworld up in LANCES_ROOM", ow and ow.map.id == "LANCES_ROOM") then + return false + end + local lance = findLance(ow) + if not check(label .. " Lance present", lance ~= nil) then return false end + check(label .. " Lance starts facing down", lance.facing == "down") + U.hold(game, dir, 4) + local opened = false + for _ = 1, 600 do + if game.stack:top() ~= ow then opened = true break end + U.wait(1) + end + check(label .. " pre-battle text opens", opened) + U.wait(40) + check(label .. " Lance still faces down during his text", lance.facing == "down") + if lance.facing ~= "down" then U.log(label, "lance facing", lance.facing) end + U.still(game, DIR .. "/" .. shotName) + return true + end + + game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) } + game.save.flags.EVENT_BEAT_LANCE = nil + game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true + + approach(5, 2, "up", "2669 side (5,1)", "2669_01_side_tile_lance_faces_down.png") + approach(6, 3, "up", "2669 front (6,2)", "2669_02_front_tile_lance_faces_down.png") + + finish() +end diff --git a/tests/drivers/lance_victory_music_bug2659.lua b/tests/drivers/lance_victory_music_bug2659.lua new file mode 100644 index 00000000..d655268d --- /dev/null +++ b/tests/drivers/lance_victory_music_bug2659.lua @@ -0,0 +1,85 @@ +-- POKEPORT_DRIVER=tests/drivers/lance_victory_music_bug2659.lua POKEPORT_SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Music = require("src.core.Music") + local BattleState = require("src.battle.BattleState") + local Timing = require("src.core.Timing") + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + + local played = {} + local realPlayBattle = Music.playBattle + Music.playBattle = function(data, kind, trainerId, song) + played[#played + 1] = { call = "battle", kind = kind } + return realPlayBattle(data, kind, trainerId, song) + end + local realPlayVictory = Music.playVictory + Music.playVictory = function(data, kind, trainerId) + played[#played + 1] = { call = "victory", kind = kind } + return realPlayVictory(data, kind, trainerId) + end + local function lastKind(call) + local k + for _, p in ipairs(played) do + if p.call == call then k = p.kind end + end + return k + end + + U.newGame(game) + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { { id = "SWIFT", pp = 20, maxPp = 20, ppUps = 0 } } + game.save.party = { mon } + game.save.player.name = "RED" + U.teleport(game, "LANCES_ROOM", 5, 3, "up") + U.wait(20) + local ow = game.overworld + check("overworld up in LANCES_ROOM", ow ~= nil) + if not ow then love.event.quit(1) return end + + local battle = BattleState.newTrainer(game, "OPP_LANCE", 1) + battle.onFinish = function() end + for i, m in ipairs(battle.enemyParty) do + m.hp = (i == 1) and 1 or 0 + end + battle.enemy.shownHP = 1 + battle.enemy.shownPx = Timing.hpBarPixels(1, math.max(1, battle.enemy.mon.stats.hp)) + ow:pushBattle(battle) + + for _ = 1, 900 do + if game.stack:top() == battle and battle.musicKind then break end + U.wait(1) + end + check("lance battle on screen", game.stack:top() == battle) + check("lance battle theme is gym (play_battle_music.asm)", + battle.musicKind == "gym" and lastKind("battle") == "gym") + check("lance is not a wGymLeaderNo fight", not battle.isGymLeader) + + local won = false + for _ = 1, 3000 do + if lastKind("victory") then won = true break end + U.tap(game, "a") + U.wait(6) + end + check("lance defeated, victory theme requested", won) + check("lance victory theme is trainer (TrainerBattleVictory)", + lastKind("victory") == "trainer") + local b = game.data.audio and game.data.audio.battle + check("MUSIC_DEFEATED_TRAINER is playing", + b ~= nil and Music.current() == b.trainerWin + and b.trainerWin ~= b.gymWin) + + U.wait(40) + U.shot(game, DIR .. "/2659_01_lance_defeated_trainer_theme.png") + + Music.playBattle, Music.playVictory = realPlayBattle, realPlayVictory + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/lance_walk_in_bug2665.lua b/tests/drivers/lance_walk_in_bug2665.lua new file mode 100644 index 00000000..b169eb0e --- /dev/null +++ b/tests/drivers/lance_walk_in_bug2665.lua @@ -0,0 +1,77 @@ +-- scripts/LancesRoom.asm:96 +-- scripts/LancesRoom.asm:111 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local ok = true + + local function check(label, cond) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then ok = false end + return cond + end + + local function finish() + U.wait(5) + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + + local mon = Pokemon.new(game.data, "MEWTWO", 100) + game.save.party = { mon } + game.save.flags.EVENT_BEAT_LANCE = nil + game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = nil + + U.teleport(game, "LANCES_ROOM", 24, 16, "up") + local ow = game.overworld + if not check("2665 overworld up in LANCES_ROOM", ow and ow.map.id == "LANCES_ROOM") then + return finish() + end + + local shots = { + ["21,16"] = "2665_01_left_off_the_stairs.png", + ["18,20"] = "2665_02_down_the_right_corridor.png", + ["12,23"] = "2665_03_left_along_the_bottom.png", + ["6,16"] = "2665_04_up_the_middle_column.png", + } + local cells = { "24,16" } + local lastKey = "24,16" + for _ = 1, 20000 do + local p = ow.player + local key = p.cellX .. "," .. p.cellY + if key ~= lastKey then + cells[#cells + 1] = key + lastKey = key + if shots[key] then + U.still(game, DIR .. "/" .. shots[key]) + shots[key] = nil + end + end + if #(ow.scriptMoves or {}) == 0 and #cells > 1 and not p.moving then break end + U.wait(1) + end + + local expected = { "24,16" } + local x, y = 24, 16 + local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 } } + for _, seg in ipairs({ { "left", 6 }, { "down", 7 }, { "left", 12 }, { "up", 12 } }) do + for _ = 1, seg[2] do + x, y = x + D[seg[1]][1], y + D[seg[1]][2] + expected[#expected + 1] = x .. "," .. y + end + end + check("2665 first step leaves the stairs going left", cells[2] == "23,16") + check("2665 walk-in follows the reversed WalkToLance list cell for cell", + table.concat(cells, " ") == table.concat(expected, " ")) + if table.concat(cells, " ") ~= table.concat(expected, " ") then + U.log("got", table.concat(cells, " ")) + end + check("2665 lands on (6,11)", ow.player.cellX == 6 and ow.player.cellY == 11) + check("2665 door locks on landing", game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR == true) + check("2665 door blocks closed", ow.map:blockAt(2, 6) == 0x72 and ow.map:blockAt(3, 6) == 0x73) + U.wait(10) + U.still(game, DIR .. "/2665_05_landed_door_locked.png") + + finish() +end diff --git a/tests/drivers/mart_split_stack_bug2671.lua b/tests/drivers/mart_split_stack_bug2671.lua new file mode 100644 index 00000000..1fb9b48c --- /dev/null +++ b/tests/drivers/mart_split_stack_bug2671.lua @@ -0,0 +1,128 @@ +-- engine/events/pokemart.asm:152 +-- engine/items/inventory.asm:59 +-- POKEPORT_DRIVER=tests/drivers/mart_split_stack_bug2671.lua POKEPORT_IDENTITY=bsa1005-red-2671 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local QuantityBox = require("src.ui.QuantityBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + local function topIs(cls) return getmetatable(top()) == cls end + local function waitFor(pred, frames) + for _ = 1, frames or 300 do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function rows() + local out = {} + for _, r in ipairs(Bag.rows(game.save, game.data)) do + out[#out + 1] = r.id .. "x" .. tostring(r.count) + end + return table.concat(out, ",") + end + local function finish() + U.log(failures == 0 and "PASS mart_split_stack_bug2671" + or ("FAIL mart_split_stack_bug2671 (" .. failures .. " failures)")) + love.event.quit(failures == 0 and 0 or 1) + U.wait(600) + end + + local MONEY = 104276 + local ok, err = pcall(function() + game.save.money = MONEY + game.save.inventory = { X_ACCURACY = 2, POTION = 3 } + game.save.bagOrder = { "X_ACCURACY", "POTION" } + U.teleport(game, "CELADON_MART_5F", 5, 5, "up") + U.wait(10) + + for _ = 1, 60 do + if topIs(Menu) then break end + U.tap(game, "a") + U.wait(4) + end + if not check("the X item clerk's mart menu opens", topIs(Menu)) then return end + local menu = top() + + U.tap(game, "a") + if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 300)) then return end + local list = top() + check("the cursor is on X ACCURACY", list.items[list.index].value == "X_ACCURACY") + U.wait(4) + U.tap(game, "a") + if not check("the quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end + local qty = top() + check("the selector max stays 99", qty.max == 99) + U.wait(4) + U.tap(game, "down") + U.wait(4) + check("DOWN wraps to x99", qty.qty == 99) + U.still(game, DIR .. "/2671_01_buy_x99.png") + + U.tap(game, "a") + if not check("YES/NO opens", waitFor(function() + if topIs(TextBox) and top().waiting then U.tap(game, "a") end + return topIs(ChoiceBox) + end, 600)) then return end + U.wait(4) + U.tap(game, "a") + local receipt + check("the clerk hands the items over", waitFor(function() + local t = top() + if getmetatable(t) == TextBox and t.pages then + local text = table.concat(t.pages[1] or {}, " ") + if text:find("Here you are", 1, true) then receipt = t return true end + if text:find("carry", 1, true) then return true end + end + return false + end, 600) and receipt ~= nil) + check("94050 is paid", game.save.money == MONEY - 94050) + check("the bag holds 101 X ACCURACY", game.save.inventory.X_ACCURACY == 101) + check("the overflow sits in a new last slot: " .. rows(), + rows() == "X_ACCURACYx99,POTIONx3,X_ACCURACYx2") + waitFor(function() return receipt.done end, 600) + U.still(game, DIR .. "/2671_02_here_you_are.png") + + for _ = 1, 30 do + if top() == list then break end + U.tap(game, "a") + U.wait(4) + end + U.tap(game, "b") + if not check("B returns to BUY/SELL/QUIT", waitFor(function() + if topIs(TextBox) and top().waiting then U.tap(game, "a") end + return top() == menu + end, 600)) then return end + U.wait(4) + U.tap(game, "down") + U.wait(4) + U.tap(game, "a") + if not check("SELL opens the bag list", waitFor(function() return topIs(ListMenu) end, 300)) then return end + local sellList = top() + U.wait(30) + local counts = {} + for _, it in ipairs(sellList.items) do + if not it.cancel then counts[#counts + 1] = it.value .. "x" .. tostring(it.count) end + end + check("the sell list shows two X ACCURACY rows: " .. table.concat(counts, ","), + table.concat(counts, ",") == "X_ACCURACYx99,POTIONx3,X_ACCURACYx2") + U.still(game, DIR .. "/2671_03_sell_list_split_rows.png") + end) + if not ok then + failures = failures + 1 + U.log("FAIL driver error:", tostring(err)) + end + finish() +end diff --git a/tests/drivers/oak_naming_empty_start_bug2687.lua b/tests/drivers/oak_naming_empty_start_bug2687.lua new file mode 100644 index 00000000..38d2647b --- /dev/null +++ b/tests/drivers/oak_naming_empty_start_bug2687.lua @@ -0,0 +1,89 @@ +-- engine/movie/oak_speech/oak_speech2.asm:20 +return function(game) + local U = dofile("tests/drivers/util.lua") + local NamingScreen = require("src.ui.NamingScreen") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + local failed = false + + local function check(label, ok) + if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end + return ok + end + + local function finish() + print(failed and "FAIL oak_naming_empty_start_bug2687" + or "PASS oak_naming_empty_start_bug2687") + love.event.quit(failed and 1 or 0) + while true do coroutine.yield() end + end + + local function namingScreen() + local states = game.stack.states or {} + for i = #states, 1, -1 do + if getmetatable(states[i]) == NamingScreen then return states[i] end + end + return nil + end + + local function topIsGrid(ns) + return ns ~= nil and game.stack:top() == ns and not ns.choosing + end + + U.wait(5) + U.tap(game, "start") + U.wait(10) + local title = game.stack:top() + for _ = 1, 60 do + U.tap(game, "a") + U.wait(5) + if game.stack:top() ~= title then break end + end + U.tap(game, "a") + U.wait(10) + + local ns + for _ = 1, 3000 do + ns = namingScreen() + if ns and ns.choosing then break end + if game.overworld and game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(2) + end + if not check("reached_player_name_menu", ns ~= nil and ns.choosing) then finish() end + U.wait(30) + U.shot(game, DIR .. "/2687_01_name_menu.png") + + U.tap(game, "a") + U.wait(30) + check("new_name_opens_empty_grid", topIsGrid(ns) and #ns.glyphs == 0) + ns.row, ns.col = 3, 4 + U.shot(game, DIR .. "/2687_02_empty_grid_before_start.png") + + U.tap(game, "start") + check("empty_start_keeps_grid_up", game.stack:top() == ns) + check("empty_start_whites_out", (ns.whiteout or 0) > 0) + U.still(game, DIR .. "/2687_03_white_reentry.png") + + U.wait(60) + check("whiteout_ends", (ns.whiteout or 0) == 0) + check("grid_back_with_cursor_on_A", topIsGrid(ns) and ns.row == 1 and ns.col == 1) + U.shot(game, DIR .. "/2687_04_grid_back_cursor_on_A.png") + + U.tap(game, "start") + U.wait(60) + check("second_empty_start_still_on_grid", topIsGrid(ns) and #ns.glyphs == 0) + + ns.row, ns.col = 1, 1 + U.tap(game, "a") + U.wait(5) + check("typed_letter_A", #ns.glyphs == 1 and ns.glyphs[1] == "A") + U.shot(game, DIR .. "/2687_05_typed_A.png") + U.tap(game, "start") + U.wait(30) + check("typed_name_confirms", namingScreen() == nil) + check("player_named_A", game.save.player.name == "A") + U.shot(game, DIR .. "/2687_06_your_name_is_A.png") + + finish() +end diff --git a/tests/drivers/oak_shrink_bug2685.lua b/tests/drivers/oak_shrink_bug2685.lua new file mode 100644 index 00000000..faf9e7b7 --- /dev/null +++ b/tests/drivers/oak_shrink_bug2685.lua @@ -0,0 +1,96 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local Oak = require("src.ui.OakSpeech") + local TB = require("src.render.TextBox") + local PF = require("src.render.PaletteFX") + local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + failed = failed or not ok + return ok + end + local origMode = PF.mode + + while game.stack:top() do game.stack:pop() end + game.save.options.textSpeed = 1 + local speech = Oak.new(game, function() end) + game.stack:push(speech) + local legend + for i, step in ipairs(speech.steps) do if step.id == "legend" then legend = i end end + + for _ = 1, 4000 do + if speech.step == legend then break end + U.tap(game, "a") + U.wait(2) + end + check("reached legend step", speech.step == legend) + + local arrowOnLast, aPresses = false, 0 + for _ = 1, 2000 do + if speech.shrink then break end + local top = game.stack:top() + if getmetatable(top) == TB then + if top.waiting then + U.tap(game, "a") + aPresses = aPresses + 1 + else + if top.done and top:arrowVisible() then arrowOnLast = true end + U.wait(1) + end + else + U.wait(1) + end + end + check("legend last page has no arrow", not arrowOnLast) + check("legend closes into the shrink with no A press", speech.shrink ~= nil) + + local firstSeen = {} + local function tag() + if speech.pic == speech.playerPic then return "red" end + if speech.pic == speech.shrinkPic1 then return "p1" end + if speech.pic == speech.shrinkPic2 then return "p2" end + if speech.walkVisible then return "walk" end + return "none" + end + local shots = {} + local replayOg, bakedOg = false, false + for _ = 1, 400 do + local s = speech.shrink + if not s then break end + local f = s.frame + local t = tag() + if f > 0 and not firstSeen[t] then firstSeen[t] = f end + if f == Oak.SHRINK_PIC1_AT + 20 and not shots.p1 then + shots.p1 = U.still(game, dir .. "/2685_01_shrinkpic1_held.png") + elseif f == Oak.SHRINK_PIC2_AT + 16 and not shots.p2 then + shots.p2 = U.still(game, dir .. "/2685_02_shrinkpic2_held.png") + elseif f == Oak.SHRINK_WALK_AT + 10 and not shots.walk then + shots.walk = U.still(game, dir .. "/2685_03_walk_sprite_" .. origMode .. ".png") + PF.setMode("ogred") + U.wait(1) + elseif f == Oak.SHRINK_WALK_AT + 20 and not shots.walkOg then + shots.walkOg = U.still(game, dir .. "/2685_04_walk_sprite_ogred_green.png") + local r = PF.uiSpriteRedraws() + replayOg = r[1] ~= nil + bakedOg = r[1] ~= nil and r[1].image ~= speech.walkSheet + elseif f == Oak.SHRINK_FADE_AT + 10 and not shots.fade then + shots.fade = U.still(game, dir .. "/2685_05_fade_ogred_sprite_lightened.png") + else + U.wait(1) + end + end + PF.setMode(origMode) + + U.log("first frames red/p1/p2/walk:", tostring(firstSeen.red), tostring(firstSeen.p1), + tostring(firstSeen.p2), tostring(firstSeen.walk)) + check("RedPicFront held >= 30 frames", (firstSeen.p1 or 0) - 1 >= 30) + check("ShrinkPic1 held >= 30 frames", + firstSeen.p1 and firstSeen.p2 and firstSeen.p2 - firstSeen.p1 >= 30) + check("ShrinkPic2 held >= 25 frames", + firstSeen.p2 and firstSeen.walk and firstSeen.walk - firstSeen.p2 >= 25) + check("OG RED walk sprite is OBJ-baked and replayed", replayOg and bakedOg) + check("shrink finished", speech.shrink == nil) + check("shots written", shots.p1 and shots.p2 and shots.walk and shots.walkOg and shots.fade) + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/oak_starter_ball_hide_bug2686.lua b/tests/drivers/oak_starter_ball_hide_bug2686.lua new file mode 100644 index 00000000..3e017e91 --- /dev/null +++ b/tests/drivers/oak_starter_ball_hide_bug2686.lua @@ -0,0 +1,141 @@ +-- scripts/OaksLab.asm:918 +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + + local MAP = "OAKS_LAB" + local OWN = "OAKSLAB_SQUIRTLE_POKE_BALL" + local RIVAL = "OAKSLAB_BULBASAUR_POKE_BALL" + local LIMIT = 120000 + local failed = false + + local function check(label, ok) + if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end + return ok + end + + local function finish() + print(failed and "FAIL oak_starter_ball_hide_bug2686" + or "PASS oak_starter_ball_hide_bug2686") + love.event.quit(failed and 1 or 0) + while true do coroutine.yield() end + end + + local function ballShown(name) + local ow = game.overworld + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.name == name then return true end + end + return false + end + + local function boxText() + local states = game.stack.states or {} + for i = #states, 1, -1 do + local s = states[i] + if getmetatable(s) == TextBox then + local out = {} + for _, page in ipairs(s.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " "):lower(), s + end + end + return "", nil + end + + local function scriptRunning() + local ow = game.overworld + return ow and ow.runner and ow.runner:isRunning() + end + + local save = game.save + save.player.name = (save.player.name and save.player.name ~= "") + and save.player.name or "RED" + save.party = {} + save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true + save.flags.EVENT_GOT_STARTER = nil + save.objectToggles = save.objectToggles or {} + save.objectToggles[MAP] = save.objectToggles[MAP] or {} + local tog = save.objectToggles[MAP] + tog.OAKSLAB_OAK1 = true + tog.OAKSLAB_OAK2 = false + tog.OAKSLAB_RIVAL = true + tog.OAKSLAB_CHARMANDER_POKE_BALL = true + tog.OAKSLAB_SQUIRTLE_POKE_BALL = true + tog.OAKSLAB_BULBASAUR_POKE_BALL = true + + U.teleport(game, MAP, 7, 4, "up") + U.wait(20) + check("squirtle_ball_on_table_before_pick", ballShown(OWN)) + check("rival_counter_ball_on_table_before_pick", ballShown(RIVAL)) + + U.tap(game, "a") + U.wait(10) + + local sawEnergetic = false + for _ = 1, LIMIT do + local txt = boxText() + if txt:find("energetic", 1, true) then sawEnergetic = true break end + if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + check("energetic_line_reached", sawEnergetic) + if not sawEnergetic then finish() end + check("ball_gone_when_energetic_line_opens", not ballShown(OWN)) + check("rival_ball_still_there_on_energetic_line", ballShown(RIVAL)) + check("no_starter_yet_on_energetic_line", #save.party == 0) + U.wait(90) + U.shot(game, DIR .. "/2686_01_energetic_line_ball_gone.png") + + local sawReceived = false + for _ = 1, LIMIT do + local txt = boxText() + if txt:find("received", 1, true) then sawReceived = true break end + if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + check("received_line_reached", sawReceived) + check("ball_gone_on_received_line", not ballShown(OWN)) + U.wait(120) + U.shot(game, DIR .. "/2686_02_received_line_ball_gone.png") + + local sawNick, nickBox = false, nil + for _ = 1, LIMIT do + local txt, box = boxText() + if txt:find("nickname", 1, true) then sawNick, nickBox = true, box break end + if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + check("nickname_prompt_reached", sawNick) + if not sawNick then finish() end + check("ball_gone_on_nickname_prompt", not ballShown(OWN)) + check("rival_ball_still_there_on_nickname_prompt", ballShown(RIVAL)) + for _ = 1, LIMIT do + if nickBox.choicePushed then break end + U.wait(1) + end + U.wait(20) + U.shot(game, DIR .. "/2686_03_nickname_prompt_ball_gone.png") + for _ = 1, LIMIT do + local _, box = boxText() + if box ~= nickBox then break end + if U.frame() % 6 == 0 then U.tap(game, "b") else U.wait(1) end + end + + for _ = 1, LIMIT do + if not scriptRunning() and game.stack:top() == game.overworld then break end + local _, box = boxText() + if box and box.done and not box.choice then U.tap(game, "a") else U.wait(1) end + end + U.wait(20) + check("script_finished", not scriptRunning()) + check("player_got_squirtle", + save.party[1] ~= nil and save.party[1].species == "SQUIRTLE") + check("own_ball_still_hidden_after_pick", not ballShown(OWN)) + check("rival_took_bulbasaur_ball", not ballShown(RIVAL)) + check("charmander_ball_left_on_table", + ballShown("OAKSLAB_CHARMANDER_POKE_BALL")) + U.shot(game, DIR .. "/2686_04_after_rival_pick.png") + finish() +end diff --git a/tests/drivers/og_sprite_obp0_bug2689.lua b/tests/drivers/og_sprite_obp0_bug2689.lua new file mode 100644 index 00000000..c343d6b9 --- /dev/null +++ b/tests/drivers/og_sprite_obp0_bug2689.lua @@ -0,0 +1,48 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local Probe = dofile("tests/drivers/shot_probe.lua") + local PaletteFX = require("src.render.PaletteFX") + local GameVersion = require("src.core.GameVersion") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "." + + local fails = 0 + local function check(cond, label) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. label) + end + + local savedVer = GameVersion.get() + game.save.options = game.save.options or {} + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + + local function visit(version, shotName) + GameVersion.set(version) + U.teleport(game, "OAKS_LAB", 5, 5, "up") + U.wait(60) + local base = PaletteFX.ogObjBase() + local shot = Probe.grab() + if not shot then + check(false, version .. " screen probe available") + else + local counts = Probe.count(shot, { + light = base[2], dark = base[3], + }, 1) + U.log(version, "OBJ light px", counts.light, "OBJ dark px", counts.dark) + check(counts.dark == 0, version .. " no dark OBJ shade on overworld sprites") + check(counts.light > 0, version .. " light OBJ shade drawn on overworld sprites") + end + local colors = PaletteFX.ogObjWorld() + check(colors[2] == base[1] and colors[3] == base[2] and colors[4] == base[4], + version .. " overworld bake is rOBP0 $D0") + check(U.shot(game, DIR .. "/" .. shotName), version .. " shot reached disk") + end + + local ok, err = pcall(function() + visit("red", "2689_01_red_oaks_lab_white_faces.png") + visit("blue", "2689_02_blue_oaks_lab_white_faces.png") + end) + if not ok then check(false, "driver ran: " .. tostring(err)) end + GameVersion.set(savedVer) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/party_hp_columns_bug2672.lua b/tests/drivers/party_hp_columns_bug2672.lua new file mode 100644 index 00000000..cce6babd --- /dev/null +++ b/tests/drivers/party_hp_columns_bug2672.lua @@ -0,0 +1,106 @@ +-- engine/menus/party_menu.asm:71 +-- engine/menus/party_menu.asm:93 +-- engine/pokemon/status_screen.asm:46 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local PartyMenu = require("src.ui.PartyMenu") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Font = require("src.render.Font") + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + + local party = {} + for i, sp in ipairs({ "NIDOKING", "BULBASAUR", "MAGIKARP" }) do + local mon = Pokemon.new(game.data, sp, 50) + mon.stats.hp = 266 + mon.hp = 266 + party[i] = mon + end + game.save.party = party + game.save.player.name = game.save.player.name or "RED" + + U.teleport(game, "PALLET_TOWN", 10, 12, "down") + U.wait(10) + + local function render(drawFn) + local c = love.graphics.newCanvas(160, 144) + love.graphics.setCanvas(c) + love.graphics.clear(1, 1, 1, 1) + love.graphics.setColor(1, 1, 1, 1) + drawFn() + love.graphics.setCanvas() + love.graphics.setColor(1, 1, 1, 1) + return c:newImageData() + end + + local function ink(id, x0, y0, x1, y1) + local n = 0 + for y = y0, y1 do + for x = x0, x1 do + local r, g, b = id:getPixel(x, y) + if r + g + b < 2.7 then n = n + 1 end + end + end + return n + end + + local function sameInk(a, b, x0, y0, x1, y1) + for y = y0, y1 do + for x = x0, x1 do + local ra, ga, ba = a:getPixel(x, y) + local rb, gb, bb = b:getPixel(x, y) + if (ra + ga + ba < 2.7) ~= (rb + gb + bb < 2.7) then return false end + end + end + return true + end + + Screens.push(game, "PartyMenu", {}) + U.wait(12) + local pm = game.stack:top() + check("party_list_open", getmetatable(pm) == PartyMenu) + + if getmetatable(pm) == PartyMenu then + local id = render(function() pm:draw() end) + for i = 1, #party do + local hy = PartyMenu.entryY(i) + 8 + check(("row%d_col3_blank_under_name_start"):format(i), + ink(id, 24, hy, 31, hy + 7) == 0) + check(("row%d_hp_tile_at_col4"):format(i), ink(id, 32, hy, 39, hy + 7) > 0) + check(("row%d_cap_at_col12"):format(i), ink(id, 96, hy, 103, hy + 7) > 0) + local frac = render(function() + love.graphics.setColor(0, 0, 0, 1) + Font.draw(("%3d/%3d"):format(party[i].hp, party[i].stats.hp), 104, hy) + end) + check(("row%d_col13_is_fraction_only"):format(i), + sameInk(id, frac, 104, hy, 111, hy + 7)) + end + U.still(game, DIR .. "/2672_01_party_hp_bar_gap_before_fraction.png") + end + + game.stack:pop() + U.wait(4) + Screens.push(game, "PartyMenu", { tmhm = { move = "SWORDS_DANCE", kind = "TM" } }) + U.wait(12) + local tm = game.stack:top() + check("tm_list_open", getmetatable(tm) == PartyMenu and tm.tmhm ~= nil) + if getmetatable(tm) == PartyMenu then + local id = render(function() tm:draw() end) + for i = 1, #party do + local hy = PartyMenu.entryY(i) + 8 + check(("tm_row%d_learn_text_starts_col12"):format(i), + ink(id, 88, hy, 95, hy + 7) == 0 and ink(id, 96, hy, 103, hy + 7) > 0) + end + U.still(game, DIR .. "/2672_02_tm_able_not_able_col12.png") + end + + print(failed and "FAIL party_hp_columns_bug2672" or "PASS party_hp_columns_bug2672") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/party_hp_palette_bug274_test.lua b/tests/drivers/party_hp_palette_bug274_test.lua index a0f31bd8..64573963 100644 --- a/tests/drivers/party_hp_palette_bug274_test.lua +++ b/tests/drivers/party_hp_palette_bug274_test.lua @@ -6,15 +6,17 @@ -- POKEPORT_IDENTITY=bug274 SHOT_DIR=/tmp/shots love . return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" local PaletteFX = require("src.render.PaletteFX") local PartyMenu = require("src.ui.PartyMenu") local Pokemon = require("src.pokemon.Pokemon") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") + local failed = false local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) + if not ok then failed = true end return ok end @@ -159,7 +161,7 @@ return function(game) :format(i, row.hp, row.pal), z ~= nil and z.colors == pal(row.pal)) check(("row %d's block covers the bar's cap + six fill tiles"):format(i), - z ~= nil and z.x == 48 and z.w == 56 and z.y == (i * 2 - 1) * 8 + z ~= nil and z.x == 40 and z.w == 56 and z.y == (i * 2 - 1) * 8 and z.h == 8) end @@ -227,14 +229,12 @@ return function(game) check("the icon column is drawn (not blank)", flesh > 0) -- (b) the bars: each row's fill must be ITS OWN bar color and none of the - -- other two. Fill tiles run x 56..103 (drawHPBar at tile 5: "HP" 40, - -- ":[" 48, six fill tiles 56..103, cap 104). for i, row in ipairs(ROWS) do local y0 = (i * 2 - 1) * 8 local own = pal(row.pal) -- the three bar palettes share color 0/1/3; only color 2, the fill -- shade, tells them apart - local mine = countColor(id, own and own[3], 56, y0, 103, y0 + 7) + local mine = countColor(id, own and own[3], 48, y0, 95, y0 + 7) U.log((" row %d (%d/48 HP) %s fill pixels: %d") :format(i, row.hp, row.pal, mine)) if row.fill then @@ -243,7 +243,7 @@ return function(game) for _, other in ipairs({ "GREENBAR", "YELLOWBAR", "REDBAR" }) do if other ~= row.pal then local c = pal(other) - local n = countColor(id, c and c[3], 56, y0, 103, y0 + 7) + local n = countColor(id, c and c[3], 48, y0, 95, y0 + 7) check(("row %d's bar carries no %s"):format(i, other), n == 0) end end @@ -275,7 +275,5 @@ return function(game) U.log("OPTION -> COLORS cycles modes; OG and CLASSIC are mono by design.") U.log("Captures: bug274_party_advanced.png and bug274_party_sgb.png in " .. DIR) - while true do - coroutine.yield() - end + love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/party_icon_hollow_bug2682.lua b/tests/drivers/party_icon_hollow_bug2682.lua new file mode 100644 index 00000000..ad0d1344 --- /dev/null +++ b/tests/drivers/party_icon_hollow_bug2682.lua @@ -0,0 +1,114 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PartyMenu = require("src.ui.PartyMenu") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + return ok + end + local function finish() + love.event.quit(fails == 0 and 0 or 1) + while true do coroutine.yield() end + end + + if not U.newGame(game) then + check(false, "2682 reached the overworld") + finish() + end + game.save.options = game.save.options or {} + game.save.options.textSpeed = 5 + game.save.inventory.SOULBADGE = true + + local surfer = Pokemon.new(game.data, "SQUIRTLE", 30) + surfer.moves = { { id = "SURF", pp = 15 } } + local other = Pokemon.new(game.data, "PIDGEY", 30) + game.save.party = { surfer, other } + game.partyMenuSavedIndex = 1 + + U.teleport(game, "PALLET_TOWN", 4, 13, "down") + local ow = game.overworld + ow.player.surfing = false + check(ow:useSurfFieldMove() == "ok", "2682 (4,13) faces surfable water") + + U.tap(game, "start") + U.wait(12) + local menu = game.stack:top() + local row + for i, it in ipairs(menu and menu.items or {}) do + if tostring(it.label):upper():find("MON", 1, true) then row = i break end + end + if not check(row ~= nil, "2682 START menu lists POKEMON") then finish() end + for _ = 2, row do U.tap(game, "down"); U.wait(2) end + U.tap(game, "a") + U.wait(12) + local pm = game.stack:top() + if not check(getmetatable(pm) == PartyMenu, "2682 party menu open") then finish() end + + local b0 = pm.blink + U.wait(20) + check(pm.blink ~= b0, "2682 cursor icon animates while the list takes input") + + U.tap(game, "a") + U.wait(8) + if not check(pm.submenu, "2682 A opens the field-move submenu") then finish() end + local frozen = pm.blink + U.wait(40) + check(pm.blink == frozen, "2682 icon frozen behind the submenu") + U.still(game, DIR .. "/2682_01_submenu_icon_frozen.png") + U.wait(10) + check(pm.blink == frozen, "2682 icon still frozen ten frames later") + U.still(game, DIR .. "/2682_02_submenu_icon_frozen_later.png") + + U.tap(game, "b") + U.wait(1) + check(not pm.submenu and pm.blink <= 1, "2682 B back to the list restarts the icon") + U.wait(9) + U.tap(game, "down") + check(pm.index == 2 and pm.blink <= 1, "2682 cursor move restarts the icon on its rest frame") + U.wait(4) + U.tap(game, "up") + U.wait(4) + check(pm.index == 1, "2682 cursor back on the surfer") + + U.tap(game, "a") + U.wait(8) + local sub + for i, it in ipairs(pm.subItems or {}) do + if it.action == "surf" then sub = i break end + end + if not check(sub ~= nil, "2682 SURF listed in the submenu") then finish() end + for _ = 2, sub do U.tap(game, "down"); U.wait(2) end + U.tap(game, "a") + U.wait(6) + local top = game.stack:top() + check(top ~= pm and top and top.pages ~= nil, "2682 got-on text is up") + check(game.stack.states[game.stack:visibleBase()] == pm, + "2682 party list is the backdrop of the got-on text") + check(not pm.submenu and pm.chosenHollow == true, + "2682 party cursor stays hollow under the got-on text") + local textBlink = pm.blink + local printed = false + for _ = 1, 600 do + if top.done or top.waiting then printed = true break end + U.wait(1) + end + check(printed, "2682 got-on text finished printing") + U.wait(4) + check(pm.blink == textBlink, "2682 icon frozen under the got-on text") + U.still(game, DIR .. "/2682_03_surf_text_hollow_cursor.png") + + for _ = 1, 400 do + local t = game.stack:top() + if not (t and t.pages) then break end + U.tap(game, "a") + U.wait(3) + end + U.wait(60) + check(ow.player.surfing == true, "2682 SURF mounted after the text") + + finish() +end diff --git a/tests/drivers/rage_building_bug2652.lua b/tests/drivers/rage_building_bug2652.lua new file mode 100644 index 00000000..cf6b7867 --- /dev/null +++ b/tests/drivers/rage_building_bug2652.lua @@ -0,0 +1,135 @@ +-- engine/battle/core.asm:3227 +-- engine/battle/core.asm:4913 +local U = require("tests.drivers.util") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Version = require("src.core.GameVersion") + +return function(game) + local deadline = love.timer.getTime() + 25 + local old = {} + for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do old[k] = rawget(game, k) end + local keys = { "save", "saveOptions", "saveLiveOptions", "writeSlot", "writeCartSlot" } + local writers = {} + for _, k in ipairs(keys) do writers[k] = SaveData[k] end + local volume = love.audio.getVolume() + local fails = 0 + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + return ok + end + + local ok, err = xpcall(function() + assert(Version.generation() == 1, "Gen1 required") + local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required") + local function forbidden() error("#2652 unexpected persistent write") end + for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = forbidden end + for _, k in ipairs(keys) do SaveData[k] = forbidden end + love.audio.setVolume(0) + + local function wait(n) + for _ = 1, n do + assert(love.timer.getTime() < deadline, "#2652 deadline") + U.wait(1) + end + end + local function fullyTyped(b) + local row = b.shown and b.shown[#b.shown] + return b.current and row and b.codes and #row == #b.codes and b.lineIndex == #b.lines + and b.total and b.total > 0 and b.charIndex >= b.total and not b.scrollPx and not b.msgWaiting + end + local function move(id) + local def = assert(game.data.moves[id], "required imported move " .. id) + return { id = id, pp = def.pp, maxPp = def.pp } + end + + game:startNewGame({ intro = false }) + game.save.options.animations = false + game.save.options.textSpeed = 1 + U.teleport(game, "PALLET_TOWN", 9, 7, "down") + local player = Pokemon.new(game.data, "WARTORTLE", 30) + player.moves = { move("SURF") } + game.save.party = { player } + local battle = Battle.newWild(game, "CHARMELEON", 50) + battle.enemy.mon.moves = { move("RAGE") } + battle.enemy.curMoves = battle.enemy.mon.moves + battle.enemy.rageMove = battle.enemy.curMoves[1] + battle.rng = function(a) return a or 0 end + + local seen = {} + local seenRows = {} + local update = battle.update + battle.update = function(self, ...) + update(self, ...) + local row = self.current + if row and row.text and not seenRows[row] then + seenRows[row] = true + seen[#seen + 1] = row.text + end + end + game.stack:push(battle) + + local function advanceUntil(fn, label) + for _ = 1, 3000 do + if fn() then return true end + if battle.current and not battle.current.auto and fullyTyped(battle) then U.tap(game, "a") end + wait(1) + end + error(label) + end + local function settledOn(fragment) + return function() + return battle.current and battle.current.text + and battle.current.text:find(fragment, 1, true) and fullyTyped(battle) + end + end + local function pickSurf() + advanceUntil(function() return battle.phase == "menu" end, "menu did not settle") + U.tap(game, "a") + advanceUntil(function() return battle.phase == "moveSelect" end, "move list missing") + U.tap(game, "a") + end + local function find(needle, from) + for i = from or 1, #seen do + if seen[i]:find(needle, 1, true) then return i end + end + end + + pickSurf() + local surfAt = #seen + 1 + advanceUntil(settledOn("RAGE is building!"), "rage page never settled") + assert(U.still(game, out .. "/2654_01_rage_after_super_effective.png"), "capture failed") + U.tap(game, "a") + advanceUntil(settledOn("ATTACK rose!"), "rose page never settled") + assert(U.still(game, out .. "/2654_02_attack_rose.png"), "capture failed") + + local used = find("used SURF", surfAt) + local super = find("super", used) + local rage = find("RAGE is building!", used) + local rose = find("ATTACK rose!", used) + check("#2654 Surf, super effective, rage and rose pages all shown", used and super and rage and rose) + check("#2654 super effective prints before RAGE is building", super and rage and super < rage) + check("#2654 ATTACK rose prints after RAGE is building", rage and rose and rage < rose) + check("#2654 rage raised the enemy attack stage", (battle.enemy.stages.attack or 0) >= 1) + + advanceUntil(function() return battle.phase == "menu" end, "turn 1 did not finish") + battle.enemy.mon.hp, battle.enemy.shownHP = 1, 1 + local koFrom = #seen + 1 + pickSurf() + advanceUntil(settledOn("fainted!"), "faint page never settled") + assert(U.still(game, out .. "/2652_03_ko_faint_no_rage.png"), "capture failed") + local koUsed = find("used SURF", koFrom) + check("#2652 the KO turn reached Surf and the faint page", koUsed ~= nil) + check("#2652 no RAGE is building page on the fainting hit", koUsed and find("RAGE is building!", koUsed) == nil) + check("#2652 no ATTACK rose page on the fainting hit", koUsed and find("rose!", koUsed) == nil) + + U.teleport(game, "PALLET_TOWN", 9, 7, "down") + end, debug.traceback) + for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = old[k] end + for _, k in ipairs(keys) do SaveData[k] = writers[k] end + love.audio.setVolume(volume) + if not ok then print("FAIL #2652 " .. tostring(err)) end + love.event.quit((ok and fails == 0) and 0 or 1) +end diff --git a/tests/drivers/route23_go_right_ahead_bug2649.lua b/tests/drivers/route23_go_right_ahead_bug2649.lua new file mode 100644 index 00000000..5dfd1a61 --- /dev/null +++ b/tests/drivers/route23_go_right_ahead_bug2649.lua @@ -0,0 +1,157 @@ +-- scripts/Route23.asm:237 +local U = require('tests.drivers.util') +local Version = require('src.core.GameVersion') +local TextBox = require('src.render.TextBox') + +local function pageText(box, i) + local page = box.pages and box.pages[i] + return page and table.concat(page, ' ') or '' +end + +return function(game) + local deadline = love.timer.getTime() + 90 + local ok, err = xpcall(function() + assert(Version.generation() == 1, 'Gen1 edition required') + local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required') + love.audio.setVolume(0) + local function wait(n) + for _ = 1, n do + assert(love.timer.getTime() < deadline, '#2649 deadline') + U.wait(1) + end + end + local function untilReady(fn, limit, label) + for _ = 1, limit do if fn() then return end; wait(1) end + error(label) + end + local function isBox(s) return s and getmetatable(s) == TextBox end + assert(game.data.text._Route23GoRightAheadText, '_Route23GoRightAheadText missing from cache') + + game:startNewGame({ intro = false }) + game.save.inventory.CASCADEBADGE = 1 + U.teleport(game, 'ROUTE_23', 7, 137, 'up') + local ow = assert(game.overworld) + untilReady(function() + return game.stack:top() == ow and not ow.transitioning and not ow.player.moving + end, 400, 'field did not settle') + + U.hold(game, 'up', 4) + untilReady(function() return isBox(game.stack:top()) end, 200, 'guard text never opened') + local box = game.stack:top() + assert(#box.pages == 3, 'step path pages: ' .. #box.pages) + assert(pageText(box, 2):find('Oh! That is the', 1, true), 'page 2 is not the badge line') + assert(pageText(box, 3):find('OK then! Please,', 1, true), 'page 3 is not the go-ahead line') + print('PASS #2649 step path shows three pages ending in OK then! Please, go right ahead!') + + local sawFanfare = false + untilReady(function() + if box.pauseSrc or (box.pauseMark and box.pageIndex == 2) then sawFanfare = true end + if box.pageIndex == 2 and box.waiting and not box.pauseSrc then return true end + if box.pageIndex == 1 and box.waiting then U.tap(game, 'a') end + return false + end, 1200, 'badge page never settled') + assert(sawFanfare, 'Get_Item1 mark never fired on the badge page') + print('PASS #2649 Get_Item1 fires after Oh! That is the CASCADEBADGE!') + assert(U.still(game, out .. '/2649_01_badge_page_after_fanfare.png'), 'capture failed') + + untilReady(function() + if box.pageIndex < 3 and box.waiting then U.tap(game, 'a') end + return box.pageIndex == 3 and (box.done or box.waiting) + end, 600, 'go-ahead page never typed: page ' .. tostring(box.pageIndex)) + assert(U.still(game, out .. '/2649_02_step_go_right_ahead.png'), 'capture failed') + print('PASS #2649 step path go-ahead page typed in the same box') + + untilReady(function() + if game.stack:top() == box then U.tap(game, 'a') end + return game.stack:top() == ow + end, 300, 'step box never closed') + assert(ow.player.cellY == 136, 'player was turned back: y=' .. ow.player.cellY) + assert(game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'pass flag not set') + print('PASS #2649 step path lets the player through and sets EVENT_PASSED_CASCADEBADGE_CHECK') + + U.teleport(game, 'ROUTE_23', 7, 136, 'right') + ow = assert(game.overworld) + untilReady(function() + return game.stack:top() == ow and not ow.transitioning and not ow.player.moving + end, 400, 'field did not settle for talk') + U.tap(game, 'a') + untilReady(function() return isBox(game.stack:top()) end, 200, 'talk text never opened') + local seenOh, goBox = false, nil + untilReady(function() + local top = game.stack:top() + if isBox(top) then + for i = 1, #top.pages do + if pageText(top, i):find('Oh! That is the', 1, true) then seenOh = true end + if seenOh and pageText(top, i):find('OK then! Please,', 1, true) then goBox = top end + end + if goBox and goBox.pageIndex == #goBox.pages and (goBox.done or goBox.waiting) then + return true + end + if top.waiting or (top.done and not top.pauseSrc) then U.tap(game, 'a') end + end + return false + end, 2400, 'talk path never reached the go-ahead text') + assert(U.still(game, out .. '/2649_03_talk_go_right_ahead.png'), 'capture failed') + print('PASS #2649 talking to the guard also ends in OK then! Please, go right ahead!') + assert(#goBox.pages == 3, 'talk path pages: ' .. #goBox.pages) + assert(pageText(goBox, 2):find('Oh! That is the', 1, true), 'talk path: page 2 is not the badge line') + assert(goBox.pauseAt, 'talk path: no fanfare mark in the box') + print('PASS #2649 talk path is one box: badge line, fanfare mark, go-ahead page') + untilReady(function() + if game.stack:top() ~= ow then U.tap(game, 'a') end + return game.stack:top() == ow + end, 300, 'talk box never closed') + + local function failBox(label) + local fb = game.stack:top() + assert(isBox(fb), label .. ': no box') + assert(fb.auto and fb.auto.sound, label .. ': SFX_DENIED not armed after the text') + assert(not fb.autoStarted, label .. ': sound fired before the text typed') + untilReady(function() + if fb.autoStarted then return true end + if fb.waiting then U.tap(game, 'a') end + return false + end, 1200, label .. ': SFX_DENIED never fired after the text') + assert(fb.done, label .. ': sound fired before the last page typed') + print('PASS #2649 ' .. label .. ' plays SFX_DENIED after the text') + untilReady(function() + if game.stack:top() == fb then U.tap(game, 'a') end + return game.stack:top() == ow + end, 300, label .. ': fail box never closed') + untilReady(function() return not ow.player.moving and #ow.scriptMoves == 0 end, + 200, label .. ': walk-down never settled') + end + + game.save.inventory.CASCADEBADGE = nil + game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK = nil + U.teleport(game, 'ROUTE_23', 7, 137, 'up') + ow = assert(game.overworld) + untilReady(function() + return game.stack:top() == ow and not ow.transitioning and not ow.player.moving + end, 400, 'field did not settle for the step fail') + U.hold(game, 'up', 4) + untilReady(function() return isBox(game.stack:top()) end, 200, 'step fail text never opened') + failBox('step fail') + assert(ow.player.cellY == 137, 'step fail: not pushed back down, y=' .. ow.player.cellY) + assert(ow.player.facing == 'down', 'step fail: facing ' .. tostring(ow.player.facing)) + assert(not game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'step fail set the pass flag') + print('PASS #2649 step fail walks the player back down') + + U.teleport(game, 'ROUTE_23', 8, 137, 'up') + ow = assert(game.overworld) + untilReady(function() + return game.stack:top() == ow and not ow.transitioning and not ow.player.moving + end, 400, 'field did not settle for the talk fail') + local canStepDown = ow.map:isWalkableCell(8, 138) + U.tap(game, 'a') + untilReady(function() return isBox(game.stack:top()) end, 200, 'talk fail text never opened') + failBox('talk fail') + assert(ow.player.facing == 'down', 'talk fail: facing ' .. tostring(ow.player.facing)) + assert(ow.player.cellY == (canStepDown and 138 or 137), + 'talk fail: y=' .. ow.player.cellY .. ' walkable=' .. tostring(canStepDown)) + assert(not game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'talk fail set the pass flag') + print('PASS #2649 talk fail runs Route23MovePlayerDownScript') + end, debug.traceback) + if not ok then print('FAIL #2649 ' .. tostring(err)) end + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/rs_menus_parity.lua b/tests/drivers/rs_menus_parity.lua new file mode 100644 index 00000000..105076f7 --- /dev/null +++ b/tests/drivers/rs_menus_parity.lua @@ -0,0 +1,176 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/rs_menus_parity" + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + print((failures == 0 and "PASS" or "FAIL") .. " rs_menus_parity failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) + U.wait(10) +end + +local function shot(game, name) + U.wait(3) + U.shot(game, DIR .. "/" .. name .. ".png") + U.wait(2) +end + +local function until_(n, fn) + for _ = 1, n do + if fn() then return true end + U.wait(1) + end + return fn() +end + +local function demoMenus(game) + local Boot = require("src.ui.game3.boot") + local Menu = require("src.ui.game3.rs.main_menu") + local Pal = require("src.core.game3.pal_fade") + local origDraw = Boot.draw + local info = { name = "BRENDAN", gender = 0, hours = 12, minutes = 34, badges = 3, dexCount = 27, + mysteryEvents = true, frameType = 0 } + local cases = { + { "00-main-no-save", { hasContinue = false } }, + { "01-main-saved", { hasContinue = true, continueInfo = info, menuType = Menu.TYPE.HAS_SAVED_GAME } }, + { "02-main-mystery-events", { hasContinue = true, continueInfo = info, menuType = Menu.TYPE.HAS_MYSTERY_EVENT } }, + } + for _, c in ipairs(cases) do + local demo = Menu.new(c[2]) + demo.pal, demo.state = Pal.new(), "input" + Boot.draw = function() demo:draw() end + check(demo.items[#demo.items] == "EXIT", c[1] .. " ends with EXIT") + for _ = 1, #demo.items - 1 do + demo:frame({ new = { down = true } }) + demo.state = "input" + end + shot(game, c[1] .. "-exit") + local w = Menu.windowFor(demo.menuType, demo.cursor) + check((w.top + w.height + 1) * 8 - demo.scroll <= 160, c[1] .. " EXIT window on screen") + end + Boot.draw = origDraw +end + +local function realMainMenuExit(game) + local Boot = require("src.ui.game3.boot") + local BootModules = require("src.ui.game3.boot_modules") + BootModules.startMenu(Boot, game.boot) + local menu = game.boot.custom.menu + check(until_(300, function() return menu.state == "input" end), "real RS main menu reaches input") + for _ = 1, #menu.items do U.tap(game, "down"); U.wait(2) end + check(menu.items[menu.cursor] == "EXIT", "cursor on EXIT") + shot(game, "03-real-main-menu-exit") + local exited = false + game.returnToLauncher = function() exited = true end + U.tap(game, "a") + check(until_(200, function() return exited end), "A on EXIT returns to the launcher") + game.returnToLauncher = nil +end + +local function enterWorld(game) + local MapIds = require("src.core.game3.map_ids") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Runtime = require("src.core.game3.runtime") + local Warp = require("src.core.game3.warp") + local Message = require("src.ui.game3.message") + local function settle() + until_(600, function() + if Message.isOpen and Message.isOpen() then U.tap(game, "b") end + return not (Warp.isBusy() or (Message.isOpen and Message.isOpen())) + end) + end + pcall(function() game:_handleBootAction({ action = "new_game", name = "RUBY", gender = 0 }) end) + U.wait(60) + settle() + pcall(function() + Map.load(nil, game, MapIds.forConst("MAP_LITTLEROOT_TOWN"), { x = 10, y = 10, facing = "down" }) + end) + local s = Runtime.getSession() + if s then s.x, s.y, s.facing = 10, 10, "down" end + Player.cellX, Player.cellY, Player.px, Player.py = 10, 10, 160, 160 + Player.targetX, Player.targetY, Player.facing = 10, 10, "down" + U.wait(60) + settle() + return s +end + +local function moveTo(game, page, target) + for _ = 1, 64 do + if page.index == target then return true end + U.tap(game, page.index < target and "down" or "up") + U.wait(1) + end + return page.index == target +end + +local function options(game, session) + local Menu = require("src.ui.game3.rs.option_menu") + local Option = require("src.ui.game3.screens").get("option", session) + check(Option == Menu, "ruby OPTION is the RS menu") + Option.show({ session = session, game = game }) + until_(120, function() return Menu._state == "input" end) + local top = Menu._pages[1] + check(top.rows[1].id == "group.speed", "top page starts with SPEED like Emerald") + shot(game, "10-option-top") + moveTo(game, top, #top.rows + 1) + shot(game, "11-option-top-cancel") + for _, id in ipairs({ "group.battle", "group.graphics", "group.speed", "group.audio" }) do + local gi + for i, r in ipairs(top.rows) do if r.id == id then gi = i end end + if check(gi ~= nil, id .. " on the top page") then + moveTo(game, top, gi) + U.tap(game, "a") + U.wait(4) + local page = Menu._pages[#Menu._pages] + local natives = 0 + for _, r in ipairs(page.rows) do if r.native then natives = natives + 1 end end + check(#Menu._pages == 2 and natives > 0, id .. " page carries native cart rows") + shot(game, "12-option-" .. id:gsub("^group%.", "")) + U.tap(game, "b") + U.wait(4) + end + end + U.tap(game, "b") + check(until_(120, function() return not Menu.isOpen() end), "B saves and closes OPTION") +end + +local function startMenuExit(game, session) + local StartMenu = require("src.ui.game3.start_menu") + local Boot = require("src.ui.game3.boot") + U.wait(10) + StartMenu.show({ session = session, game = game }) + U.wait(4) + for i, e in ipairs(StartMenu.ENTRIES) do if e.id == "exit" then StartMenu.cursor = i end end + shot(game, "20-start-menu-exit") + U.tap(game, "a") + U.wait(4) + check(StartMenu._confirmExit == true, "START EXIT asks to return to the main menu") + shot(game, "21-start-menu-exit-confirm") + U.tap(game, "up") + U.wait(2) + U.tap(game, "a") + check(until_(300, function() return game.phase == "boot" and game.boot and game.boot.phase == Boot.PHASE.TITLE end), + "YES returns to the title screen") + U.wait(60) + shot(game, "22-after-exit-title") +end + +return function(game) + until_(900, function() return game.phase == "boot" and game.boot end) + if not check(game.boot ~= nil, "boot reached") then return finish() end + local ok, err = xpcall(function() + demoMenus(game) + realMainMenuExit(game) + local session = enterWorld(game) + options(game, session) + startMenuExit(game, session) + end, debug.traceback) + if not ok then check(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/rs_option_pages.lua b/tests/drivers/rs_option_pages.lua index d956690d..c44fb997 100644 --- a/tests/drivers/rs_option_pages.lua +++ b/tests/drivers/rs_option_pages.lua @@ -73,7 +73,7 @@ local function phase1(game, ShaderFX, ShaderFXMenu) end shot(game, "01-native-first-page") local top = Menu._pages[1] - for i = 1, 7 do check(top.rows[i].native == i, "native row " .. i .. " on page 1") end + check(top.rows[1].id == "group.speed", "page 1 opens on Emerald's SPEED group") U.tap(game, "select") U.wait(4) @@ -81,12 +81,12 @@ local function phase1(game, ShaderFX, ShaderFXMenu) moveTo(game, top, 8) shot(game, "02-scrolled-port-rows") - moveTo(game, top, #top.rows) + moveTo(game, top, #top.rows + 1) shot(game, "03-port-rows-bottom") local groups = {} for i, row in ipairs(top.rows) do - if row.port and row.port.group then groups[#groups + 1] = { i = i, id = row.port.id } end + if row.group then groups[#groups + 1] = { i = i, id = row.id } end end check(#groups >= 4, "RS OPTION shows " .. #groups .. " port pages") local n = 3 diff --git a/tests/drivers/shrink_test.lua b/tests/drivers/shrink_test.lua index 6b8a6e57..2bba61a8 100644 --- a/tests/drivers/shrink_test.lua +++ b/tests/drivers/shrink_test.lua @@ -4,61 +4,80 @@ -- Pallet Town. return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Oak = require("src.ui.OakSpeech") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + failed = failed or not ok + return ok + end U.wait(5) - U.tap(game, "start") -- skip intro movie + U.tap(game, "start") U.wait(10) - U.tap(game, "a") -- title -> menu - U.wait(5) - -- with an existing save the menu is CONTINUE / NEW GAME / OPTION + local title = game.stack:top() + for _ = 1, 60 do + U.tap(game, "a") + U.wait(5) + if game.stack:top() ~= title then break end + end local ok, saved = pcall(function() return require("src.core.SaveData").load() ~= nil end) if ok and saved then U.tap(game, "down") U.wait(3) end - U.tap(game, "a") -- NEW GAME + U.tap(game, "a") U.wait(10) - -- mash through the speech + both naming screens (preset 1) local function top() return game.stack:top() end local function speechState() for _, s in ipairs(game.stack.states or {}) do - if s.shrink ~= nil or s.oakPic ~= nil then return s end + if getmetatable(s) == Oak then return s end end end - for _ = 1, 400 do - local s = speechState() - if s and s.step and s.step >= 9 then break end + local s + for _ = 1, 4000 do + s = speechState() + if s and s.shrink then break end U.tap(game, "a") U.wait(2) end + check("shrink beat entered", s ~= nil and s.shrink ~= nil) + if not (s and s.shrink) then love.event.quit(1) return end - -- the shrink beat: captures aimed at each timeline window, with the - -- exact frame logged so the windows can be verified - local s = speechState() local function frameNow() - return (s and s.shrink and s.shrink.frame) or -1 + return (s.shrink and s.shrink.frame) or -1 end - U.log("shrink beat entered:", tostring(s ~= nil and s.shrink ~= nil), - "frame:", frameNow()) - U.shot(game, DIR .. "/shrink_1_redpic.png") -- frames 1-4: RedPicFront - U.log("shot1 frame:", frameNow()) - U.wait(3) - U.shot(game, DIR .. "/shrink_2_pic1.png") -- frames 5-8: ShrinkPic1 - U.log("shot2 frame:", frameNow()) - U.wait(8) - U.shot(game, DIR .. "/shrink_3_pic2.png") -- frames 9-28: ShrinkPic2 - U.log("shot3 frame:", frameNow()) - U.wait(25) - U.shot(game, DIR .. "/shrink_4_sprite.png") -- frames 29-78: walk sprite - U.log("shot4 frame:", frameNow()) - U.wait(43) - U.shot(game, DIR .. "/shrink_5_fade.png") -- frames 79-102: fade - U.log("shot5 frame:", frameNow()) - U.wait(15) - U.wait(30) - U.shot(game, DIR .. "/shrink_6_overworld.png") + local function at(target, name, wantPic, wantWalk) + for _ = 1, 400 do + if not s.shrink or s.shrink.frame >= target then break end + U.wait(1) + end + local f = frameNow() + local shot = U.still(game, DIR .. "/" .. name .. ".png") + U.log(name .. " frame:", f) + check(name .. " lands at frame " .. target .. " (got " .. f .. ")", f == target) + check(name .. " shows the expected beat", s.pic == wantPic and (s.walkVisible or false) == wantWalk) + check(name .. " shot reached disk", shot) + end + + at(Oak.SHRINK_PIC1_AT - 1, "shrink_1_redpic", s.playerPic, false) + at(Oak.SHRINK_PIC1_AT + 2, "shrink_2_pic1", s.shrinkPic1, false) + at(Oak.SHRINK_PIC2_AT + 2, "shrink_3_pic2", s.shrinkPic2, false) + at(Oak.SHRINK_WALK_AT + 2, "shrink_4_sprite", nil, true) + at(Oak.SHRINK_FADE_AT + 2, "shrink_5_fade", nil, true) + check("shrink_5_fade is mid fade", s.fadeLevel ~= nil) + + for _ = 1, 400 do + if top() == game.overworld and game.overworld then break end + U.wait(1) + end + U.wait(10) + local shot = U.shot(game, DIR .. "/shrink_6_overworld.png") U.log("after speech: top==overworld:", tostring(top() == game.overworld), "map:", game.overworld and game.overworld.map and game.overworld.map.id or "?") + check("speech hands off to the overworld", game.overworld ~= nil and top() == game.overworld) + check("shrink_6_overworld shot reached disk", shot) + love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/take_down_recoil_bug2653.lua b/tests/drivers/take_down_recoil_bug2653.lua new file mode 100644 index 00000000..629bfaa0 --- /dev/null +++ b/tests/drivers/take_down_recoil_bug2653.lua @@ -0,0 +1,153 @@ +-- engine/battle/move_effects/recoil.asm:65 +-- engine/battle/core.asm:3226 +local U = require("tests.drivers.util") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Version = require("src.core.GameVersion") + +return function(game) + local deadline = love.timer.getTime() + 25 + local old = {} + for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do old[k] = rawget(game, k) end + local keys = { "save", "saveOptions", "saveLiveOptions", "writeSlot", "writeCartSlot" } + local writers = {} + for _, k in ipairs(keys) do writers[k] = SaveData[k] end + local volume = love.audio.getVolume() + local fails = 0 + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + return ok + end + + local ok, err = xpcall(function() + assert(Version.generation() == 1, "Gen1 required") + local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required") + local function forbidden() error("#2653 unexpected persistent write") end + for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = forbidden end + for _, k in ipairs(keys) do SaveData[k] = forbidden end + love.audio.setVolume(0) + + local function wait(n) + for _ = 1, n do + assert(love.timer.getTime() < deadline, "#2653 deadline") + U.wait(1) + end + end + local function fullyTyped(b) + local row = b.shown and b.shown[#b.shown] + return b.current and row and b.codes and #row == #b.codes and b.lineIndex == #b.lines + and b.total and b.total > 0 and b.charIndex >= b.total and not b.scrollPx and not b.msgWaiting + end + local function move(id) + local def = assert(game.data.moves[id], "required imported move " .. id) + return { id = id, pp = def.pp, maxPp = def.pp } + end + + game:startNewGame({ intro = false }) + game.save.options.animations = false + game.save.options.textSpeed = 1 + U.teleport(game, "PALLET_TOWN", 9, 7, "down") + + local function runTurn(pauseAt) + game.save.party = { Pokemon.new(game.data, "TAUROS", 40) } + game.save.party[1].moves = { move("TAKE_DOWN") } + local battle = Battle.newWild(game, "SNORLAX", 50) + battle.enemy.mon.moves = { move("SPLASH") } + battle.enemy.curMoves = battle.enemy.mon.moves + battle.rng = function(a) return a or 0 end + + local r = { step = 0 } + local update = battle.update + battle.update = function(self, ...) + if r.paused then return end + if r.armed then + r.step = r.step + 1 + local p, e = self.player, self.enemy + if e.shownHP ~= r.lastEnemy then + r.enemyLastMove = r.step + r.lastEnemy = e.shownHP + end + if not r.playerFirstMove and p.shownHP < r.pStart then r.playerFirstMove = r.step end + if not r.eEnd and e.mon.hp < r.eStart then r.eEnd = e.mon.hp end + if not r.midStep and r.eEnd and e.shownHP <= math.floor((r.eStart + r.eEnd) / 2) then + r.midStep = r.step + r.midPlayerHP = p.shownHP + end + local row = self.current + if row and row.text and row.text:find("recoil", 1, true) and not r.recoilStep then + r.recoilStep = r.step + r.recoilPlayerHP = p.shownHP + end + if pauseAt and r.midStep == r.step then r.paused = true end + end + update(self, ...) + end + game.stack:push(battle) + + local function advanceUntil(fn, label) + for _ = 1, 3000 do + if fn() then return true end + if not r.paused and battle.current and not battle.current.auto and fullyTyped(battle) then + U.tap(game, "a") + end + wait(1) + end + error(label) + end + + advanceUntil(function() return battle.phase == "menu" end, "menu did not settle") + r.pStart, r.eStart = battle.player.mon.hp, battle.enemy.mon.hp + r.lastEnemy = battle.enemy.shownHP + U.tap(game, "a") + advanceUntil(function() return battle.phase == "moveSelect" end, "move list missing") + r.armed = true + U.tap(game, "a") + advanceUntil(function() return battle.player.mon.hp < r.pStart end, "recoil never applied") + r.eEnd, r.pEnd = r.eEnd or battle.enemy.mon.hp, battle.player.mon.hp + if pauseAt then + advanceUntil(function() return r.paused end, "never reached the counted mid-drain step") + r.shotOk = U.still(game, out .. "/2653_01_target_draining_user_bar_full.png") + r.shotPlayerHP, r.shotEnemyHP = battle.player.shownHP, battle.enemy.shownHP + r.paused = false + end + advanceUntil(function() + return battle.current and battle.current.text + and battle.current.text:find("recoil", 1, true) and fullyTyped(battle) + end, "recoil text never settled") + if pauseAt then + r.textShotOk = U.still(game, out .. "/2653_02_recoil_text_after_user_bar.png") + r.textPlayerHP = battle.player.shownHP + end + advanceUntil(function() return battle.phase == "menu" end, "turn did not finish") + game.stack:pop() + return r + end + + local count = runTurn(nil) + check("#2653 the hit and the recoil both landed", count.eEnd < count.eStart and count.pEnd < count.pStart) + check("#2653 user bar holds full through the target's drain", + count.playerFirstMove and count.enemyLastMove and count.playerFirstMove > count.enemyLastMove) + check("#2653 user bar still full at mid target drain", count.midStep ~= nil and count.midPlayerHP == count.pStart) + check("#2653 user bar finished before the recoil text", + count.recoilStep ~= nil and count.recoilPlayerHP == count.pEnd) + + local shoot = runTurn(count.midStep ~= nil) + print(("#2653 mid target drain at logic step %s (count pass) / %s (shot pass)"):format( + tostring(count.midStep), tostring(shoot.midStep))) + check("#2653 mid-drain shot captured", shoot.shotOk == true) + check("#2653 shot frame: target mid-drain, user bar full", + shoot.shotPlayerHP == shoot.pStart and shoot.shotEnemyHP and shoot.shotEnemyHP < shoot.eStart + and shoot.shotEnemyHP > shoot.eEnd) + check("#2653 recoil text shot captured with the user bar down", + shoot.textShotOk == true and shoot.textPlayerHP == shoot.pEnd) + + U.teleport(game, "PALLET_TOWN", 9, 7, "down") + end, debug.traceback) + for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = old[k] end + for _, k in ipairs(keys) do SaveData[k] = writers[k] end + love.audio.setVolume(volume) + if not ok then print("FAIL #2653 " .. tostring(err)) end + love.event.quit((ok and fails == 0) and 0 or 1) +end diff --git a/tests/drivers/trainer_sight_order_bug1948_test.lua b/tests/drivers/trainer_sight_order_bug1948_test.lua index 50151401..46a6b919 100644 --- a/tests/drivers/trainer_sight_order_bug1948_test.lua +++ b/tests/drivers/trainer_sight_order_bug1948_test.lua @@ -12,8 +12,10 @@ return function(game) local PaletteFX = require("src.render.PaletteFX") local SpriteRenderer = require("src.render.SpriteRenderer") + local failed = false local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) + if not ok then failed = true end return ok end @@ -24,7 +26,7 @@ return function(game) U.wait(30) local ow = game.overworld check("overworld is up", ow ~= nil) - if not ow then while true do coroutine.yield() end end + if not ow then love.event.quit(1) return end local npc for _, e in ipairs(ow.entities) do @@ -82,7 +84,7 @@ return function(game) U.shot(game, DIR .. "/bug1948_wipe_ogred.png")) local ok, last = replayRecord() local sp = ow.player.sprite - local want = SpriteRenderer.obpImage(sp.def.image, PaletteFX.ogObj()) + local want = SpriteRenderer.obpImage(sp.def.image, PaletteFX.ogObjWorld()) local dmg = SpriteRenderer.obpImage(sp.def.image, PaletteFX.dmgObj()) check("the ogred wipe replay queued a redraw", ok and last ~= nil) check("the replayed survivor is the ogObj bake, not the DMG bake", @@ -108,7 +110,5 @@ return function(game) U.log("green, NOT DMG gray.") U.log("bug1948_wipe_black.png: solid black, nobody left.") - while true do - coroutine.yield() - end + love.event.quit(failed and 1 or 0) end diff --git a/tests/engine/bag_split_stack_bug2671.lua b/tests/engine/bag_split_stack_bug2671.lua new file mode 100644 index 00000000..ec3ba999 --- /dev/null +++ b/tests/engine/bag_split_stack_bug2671.lua @@ -0,0 +1,315 @@ +-- engine/items/inventory.asm:59 +-- engine/events/pokemart.asm:152 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local function glyphs(text) + local n = 0 + for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + n = n + 1 + end + return n +end + +local realFont = package.loaded["src.render.Font"] +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function() end, + drawCode = function() end, + drawBox = function() end, + width = function(text) return glyphs(text) * 8 end, + split = function(text) + local out = {} + for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = s + end + return out + end, + spansFitting = function(spans, pixels) + return math.min(#spans, math.floor(pixels / 8)) + end, + encode = function(text) return { text } end, + advanceOf = function() return 8 end, +} + +local RELOAD = { + "src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu", + "src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox", +} +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end + +local GameVersion = require("src.core.GameVersion") +local Bag = require("src.inventory.Bag") +local ShopMenu = require("src.ui.ShopMenu") +local TextBox = require("src.render.TextBox") +local SaveData = require("src.core.SaveData") + +local BAG_FULL = "You can't carry\nany more items." +local BOUGHT = "Here you are!\nThank you!" + +local function boxText(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " ") +end + +local function rowsOf(save, data) + local out = {} + for _, r in ipairs(Bag.rows(save, data)) do + out[#out + 1] = r.id .. "x" .. tostring(r.count) + end + return table.concat(out, ",") +end + +local function newGame(money, inventory, order) + local stack = {} + local game + game = { + data = { + items = { + X_ACCURACY = { name = "X ACCURACY", price = 950, index = 46 }, + POTION = { name = "POTION", price = 300, index = 20 }, + }, + text = { + _PokemartItemBagFullText = BAG_FULL, + _PokemartBoughtItemText = BOUGHT, + }, + constants = {}, + field = {}, + }, + save = { money = money, inventory = inventory, bagOrder = order }, + input = { + wasPressed = function() return false end, + isDown = function() return false end, + }, + } + game.stack = { + push = function(_, s) stack[#stack + 1] = s end, + pop = function() return table.remove(stack) end, + top = function() return stack[#stack] end, + } + return game, stack +end + +local function drain(game) + for _ = 1, 600 do + local top = game.stack:top() + if not (top and top.stay and not top.stayShown) then return end + top:update(1 / 60) + end +end + +local function fill(game, n) + for i = 1, n do + local id = "FILLER_" .. i + game.save.inventory[id] = 1 + game.save.bagOrder[#game.save.bagOrder + 1] = id + end +end + +local function buy(game, stack, id, qty) + local menu = ShopMenu.new(game, { id }, function() end) + menu.items[1].onSelect() + drain(game) + local list = stack[#stack] + list.onChoose({ value = id }) + local qtyBox = stack[#stack] + qtyBox.onDone(qty) + local confirm = stack[#stack] + game.stack:pop() + confirm.choice(true) + return stack[#stack] +end + +GameVersion.set("red") + +do + local game = newGame(0, { X_ACCURACY = 2, POTION = 5 }, { "X_ACCURACY", "POTION" }) + eq(Bag.add(game.save, "X_ACCURACY", 99, game.data), true, + "2 + 99 X ACCURACY fits with a free slot") + eq(game.save.inventory.X_ACCURACY, 101, "the total is 101") + eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx2", + "the first slot maxes at 99 and the overflow lands at the end") + eq(Bag.slots(game.save, game.data), 3, "three bag slots") + + Bag.remove(game.save, "X_ACCURACY", 2, game.data, 2) + eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", + "tossing the overflow row removes that slot") + + Bag.add(game.save, "X_ACCURACY", 2, game.data) + Bag.remove(game.save, "X_ACCURACY", 99, game.data, 1) + eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx2", + "emptying the first slot keeps the later slot where it was") +end + +do + local game = newGame(0, { X_ACCURACY = 99 }, { "X_ACCURACY" }) + fill(game, 19) + eq(Bag.add(game.save, "X_ACCURACY", 1, game.data), false, + "a 20-slot bag refuses the overflow slot") + eq(game.save.inventory.X_ACCURACY, 99, "and changes nothing") + eq(#game.save.bagOrder, 20, "no slot is added") +end + +do + local game = newGame(0, { X_ACCURACY = 149 }, { "X_ACCURACY", "X_ACCURACY" }) + fill(game, 18) + eq(Bag.add(game.save, "X_ACCURACY", 10, game.data), false, + "a full bag refuses once the maxed first slot overflows") + eq(game.save.inventory.X_ACCURACY, 149, "and changes nothing") +end + +do + local game, stack = newGame(104276, { X_ACCURACY = 2 }, { "X_ACCURACY" }) + local box = buy(game, stack, "X_ACCURACY", 99) + if check(getmetatable(box) == TextBox, "mart: a text box follows the YES") then + check(boxText(box):find("Here you are!", 1, true) ~= nil, + "mart: the clerk hands the items over") + check(boxText(box):find("can't carry", 1, true) == nil, + "mart: no bag-full refusal") + end + eq(game.save.money, 104276 - 94050, "mart: 94050 is paid") + eq(rowsOf(game.save, game.data), "X_ACCURACYx99,X_ACCURACYx2", + "mart: two X ACCURACY rows") +end + +do + local game, stack = newGame(3000, { X_ACCURACY = 99 }, { "X_ACCURACY" }) + fill(game, 19) + local box = buy(game, stack, "X_ACCURACY", 1) + if check(getmetatable(box) == TextBox, "mart full: a text box follows the YES") then + check(boxText(box):find("can't carry", 1, true) ~= nil, + "mart full: the bag-full refusal") + end + eq(game.save.money, 3000, "mart full: no money changes hands") +end + +do + local game, stack = newGame(0, { X_ACCURACY = 101, POTION = 5 }, + { "X_ACCURACY", "POTION", "X_ACCURACY" }) + local menu = ShopMenu.new(game, {}, function() end) + menu.items[2].onSelect() + drain(game) + local list = stack[#stack] + local counts = {} + for _, it in ipairs(list.items) do + if not it.cancel then counts[#counts + 1] = tostring(it.count) end + end + eq(table.concat(counts, ","), "99,5,2", "sell list shows each slot's own count") + list.index = 3 + list.onChoose(list.items[3]) + local qtyBox = stack[#stack] + eq(qtyBox.max, 2, "selling from the overflow row caps at that row") + qtyBox.onDone(2) + local confirm = stack[#stack] + game.stack:pop() + confirm.choice(true) + eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the sold row is gone") + eq(game.save.money, 950, "paid half price for two") +end + +do + local game = newGame(0, { X_ACCURACY = 101, POTION = 5 }, + { "X_ACCURACY", "POTION", "X_ACCURACY" }) + local back = assert(SaveData.decode(SaveData.encode(game.save))) + SaveData.validate(back, game.data) + eq(rowsOf(back, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx2", + "split slots survive a save round trip") +end + +do + local pc, order = { POTION = 99 }, { "POTION" } + local data = newGame(0, {}, {}).data + eq(Bag.addTo(pc, order, "POTION", 50, data, Bag.storeSlots(pc, data), 50), true, + "PC deposit past 99 splits the box slot") + eq(table.concat(order, ","), "POTION,POTION", "two box slots") + local full, fullOrder = { POTION = 1 }, { "POTION" } + for i = 1, 49 do + full["FILLER_" .. i] = 1 + fullOrder[#fullOrder + 1] = "FILLER_" .. i + end + full.POTION = 99 + eq(Bag.addTo(full, fullOrder, "POTION", 1, data, Bag.storeSlots(full, data), 50), false, + "a 50-slot box refuses the overflow slot") +end + +do + local game = newGame(0, { X_ACCURACY = 101, POTION = 5 }, + { "X_ACCURACY", "POTION", "X_ACCURACY" }) + Bag.remove(game.save, "X_ACCURACY", 5, game.data, 1) + eq(rowsOf(game.save, game.data), "X_ACCURACYx94,POTIONx5,X_ACCURACYx2", + "tossing from the first slot leaves 94 and 2") + eq(Bag.slots(game.save, game.data), 3, "both X ACCURACY slots stay") + local back = assert(SaveData.decode(SaveData.encode(game.save))) + SaveData.validate(back, game.data) + eq(rowsOf(back, game.data), "X_ACCURACYx94,POTIONx5,X_ACCURACYx2", + "a 94/2 split survives a save round trip") + Bag.remove(game.save, "X_ACCURACY", 1, game.data) + eq(rowsOf(game.save, game.data), "X_ACCURACYx93,POTIONx5,X_ACCURACYx2", + "a slotless remove takes from the first slot") + Bag.add(game.save, "X_ACCURACY", 10, game.data) + eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx6", + "adding tops the first slot up and carries the rest to the next") + Bag.swap(game.save, 1, 3, game.data) + eq(rowsOf(game.save, game.data), "X_ACCURACYx6,POTIONx5,X_ACCURACYx99", + "SELECT on two slots of one item moves the 99 to the second pick") + Bag.swap(game.save, 1, 2, game.data) + eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx6,X_ACCURACYx99", + "swapping different items keeps each slot's count") + Bag.swap(game.save, 3, 2, game.data) + eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx99,X_ACCURACYx6", + "the 99 follows the second pick when the sum exceeds 99") + Bag.remove(game.save, "X_ACCURACY", 90, game.data, 2) + Bag.swap(game.save, 2, 3, game.data) + eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx15", + "SELECT merges two slots that fit in one") + eq(game.save.bagStacks, nil, "a merged stack carries no per-slot list") +end + +do + local game, stack = newGame(0, { X_ACCURACY = 101, POTION = 5 }, + { "X_ACCURACY", "POTION", "X_ACCURACY" }) + local menu = ShopMenu.new(game, {}, function() end) + menu.items[2].onSelect() + drain(game) + local list = stack[#stack] + local function sellRow(row, qty) + list.index = row + list.onChoose(list.items[row]) + local qtyBox = stack[#stack] + qtyBox.onDone(qty) + local confirm = stack[#stack] + game.stack:pop() + confirm.choice(true) + end + list.scroll = 1 + sellRow(3, 1) + eq(list.scroll, 1, "selling part of a row keeps the list scroll") + eq(list.index, 3, "and the cursor") + sellRow(3, 1) + eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the emptied row is gone") + eq(list.scroll, 0, "an emptied row resets the sell list scroll") + eq(list.index, 1, "and puts the cursor on the first row") +end + +do + GameVersion.set("gold") + local game = newGame(0, { POTION = 99 }, { "POTION" }) + game.data.items.POTION.pocket = "ITEM" + eq(Bag.add(game.save, "POTION", 1, game.data), false, + "Gen 2 pockets keep one row per id") + eq(#game.save.bagOrder, 1, "no duplicate row in Gen 2") + GameVersion.set("red") +end + +package.loaded["src.render.Font"] = realFont +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/bag_use_toss_hollow_cursor_2063.lua b/tests/engine/bag_use_toss_hollow_cursor_2063.lua index dab01fb3..8c8d1983 100644 --- a/tests/engine/bag_use_toss_hollow_cursor_2063.lua +++ b/tests/engine/bag_use_toss_hollow_cursor_2063.lua @@ -10,7 +10,9 @@ love = love or require("tests.love_stub") package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } package.loaded["src.core.Music"] = { playMap = function() end } package.loaded["src.render.TextBox"] = { - new = function(_, text, done) return { textBox = true, text = text, done = done } end, + new = function(_, text, done, opts) + return { textBox = true, text = text, done = done, opts = opts } + end, soundOpts = function() return nil end, } package.loaded["src.ui.BagMenu"] = nil @@ -114,6 +116,75 @@ do end end +-- engine/menus/start_sub_menus.asm:361 +local function useLeavesBox(id, label) + local game = freshGame() + local list = openOn(game, byId(id)) + if not check(list ~= nil, label .. ": the bag opened on the row") then return end + pressA(game, list) + local box = game.stack:top() + if not check(getmetatable(box) == Menu, label .. ": the USE/TOSS box is up") then return end + eq(box.index, 1, label .. ": the cursor sits on USE") + game.input.pressed = "a" + box:update(1 / 60) + game.input.pressed = nil + check(game.stack:top() ~= box, label .. ": USE takes the box off the stack") + check(game.stack:top() and game.stack:top().textBox, + label .. ": the use flow prints its text") + check(list.optionBox == box, label .. ": the box stays painted under the text") + eq(box.hollowIndex, 1, label .. ": with the hollow '▷' left on USE") + local drawn = false + local realDraw = box.draw + box.draw = function(self) drawn = true end + list.drawItemBox = function() end + list:draw() + list.drawItemBox = nil + box.draw = realDraw + check(drawn, label .. ": drawing the list repaints the leftover box") + game.stack:pop() + drawn = false + box.draw = function(self) drawn = true end + list.drawItemBox = function() end + list:draw() + list.drawItemBox = nil + box.draw = realDraw + check(not drawn, label .. ": the first frame back on the list has no box") + check(list.optionBox == nil, label .. ": the redraw erases it before any update") + check(list.hollowIndex == nil, label .. ": and the list's '▶' is filled again") + list:update(1 / 60) + check(list.optionBox == nil, label .. ": the list's next update keeps it erased") +end +useLeavesBox("FIX_TM", "TM booted-up text") +useLeavesBox("FIX_POTION", "potion refusal text") + +-- engine/menus/start_sub_menus.asm:416 +do + local game = freshGame() + local list = openOn(game, byId("FIX_TM")) + if check(list ~= nil, "TM YES: the bag opened on the TM row") then + pressA(game, list) + local box = game.stack:top() + game.input.pressed = "a" + box:update(1 / 60) + game.input.pressed = nil + local text = game.stack:top() + check(list.optionBox == box, "TM YES: the box is painted under the prompt") + local Screens = require("src.ui.Screens") + local realPush, pushed = Screens.push, nil + Screens.push = function(g, id) + pushed = id + g.stack:push({ partyMenu = true }) + end + local choice = text and text.opts and text.opts.choice + if check(choice ~= nil, "TM YES: the prompt carries the YES/NO choice") then + choice(true) + end + Screens.push = realPush + eq(pushed, "PartyMenu", "TM YES: the party menu opens") + check(list.optionBox == nil, "TM YES: the party menu wipes the leftover box") + end +end + -- CANCEL exits like B and never opens the box (home/list_menu.asm:105-110) do local game = freshGame() diff --git a/tests/engine/battle_arrow_cell_bug2674.lua b/tests/engine/battle_arrow_cell_bug2674.lua new file mode 100644 index 00000000..44cc2e12 --- /dev/null +++ b/tests/engine/battle_arrow_cell_bug2674.lua @@ -0,0 +1,143 @@ +-- home/text.asm:262 +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local SPACE = 0x7F +local LAST = Font.encode("Z")[1] +T.check(LAST ~= SPACE, "fixture charmap encodes the 18th glyph") + +local function newBattle(opts) + opts = opts or {} + local save = SaveData.newGame() + save.player.name = "RED" + save.party = { Pokemon.new(Data, "FIXMON_A", 10, function(_, b) return b end) } + if opts.gold then save.version, save.generation = "gold", 2 end + if opts.wide then save.options = save.options or {}; save.options.battleLayout = "wide" end + local pressA = false + local game = { + data = Data, save = save, + stack = { top = function() return nil end, push = function() end }, + input = { wasPressed = function(_, k) return pressA and (k == "a") end, + isDown = function() return false end }, + } + local battle = BattleState.newWild(game, "FIXMON_C", 10) + battle.queue, battle.nextInsert = {}, 0 + battle.phase = "messages" + return battle, function(v) pressA = v end +end + +local function step(battle, until_) + for _ = 1, 2000 do + if until_() then return true end + battle:updateQueue() + battle:tickTextScroll() + end + return until_() +end + +local function drawnAt(battle, frame) + battle.frame = frame + local real, realBox = Font.drawCode, Font.drawBox + local hits = {} + Font.drawCode = function(code, x, y) hits[#hits + 1] = { code = code, x = x, y = y } end + Font.drawBox = function() end + local ok, err = pcall(function() battle:drawTextArea() end) + Font.drawCode, Font.drawBox = real, realBox + T.check(ok, "drawTextArea runs (" .. tostring(err) .. ")") + local at, row = {}, 0 + for _, h in ipairs(hits) do + if h.y == 128 then row = row + 1 end + if h.x == 144 and h.y == 128 then at[#at + 1] = h.code end + end + return at, row +end + +local TEXT = "YOUR DEX IS\nENTIRELY COMPLETEZ\vCONGRATULATIONSZ" + +do + local battle, setA = newBattle() + battle.queue[1] = { text = TEXT } + T.check(step(battle, function() return battle.msgWaiting end), + "red reaches the cont wait under an 18-tile line") + T.eq(battle.shown[2] and battle.shown[2][18], LAST, + "red bottom line's 18th glyph is typed") + battle.msgPreWait = 0 + local lit, row = drawnAt(battle, 0) + T.check(row >= 17, "the bottom line is drawn") + T.eq(#lit, 0, "red arrow replaces the 18th glyph while lit") + local dark, rowDark = drawnAt(battle, 30) + T.check(rowDark >= 17, "the bottom line is drawn on blink-off") + T.eq(#dark, 0, "red blink-off shows a blank cell, not the glyph") + setA(true) + battle:updateQueue() + setA(false) + T.eq(battle.msgWaiting, nil, "red A clears the cont wait") + T.eq(battle.shown[1] and battle.shown[1][18], SPACE, + "red cont leaves a space in the scrolled line") +end + +do + local battle, setA = newBattle({ gold = true }) + battle.queue[1] = { text = TEXT } + step(battle, function() return battle.msgWaiting end) + battle.msgPreWait = 0 + setA(true) + battle:updateQueue() + setA(false) + T.eq(battle.shown[1] and battle.shown[1][18], LAST, + "gold keeps column 18 (its arrow sits on the border row)") +end + +do + local battle, setA = newBattle({ wide = true }) + battle.queue[1] = { text = TEXT } + step(battle, function() return battle.msgWaiting end) + battle.msgPreWait = 0 + setA(true) + battle:updateQueue() + setA(false) + T.eq(battle.shown[1] and battle.shown[1][18], LAST, + "wide layout keeps column 18 (its arrow sits at column 36)") +end + +do + local battle, setA = newBattle() + battle.queue[1] = { text = "ITS A TEST OF\nTHE ARROW CELLS ZZ" } + T.check(step(battle, function() return battle.msgPrompt end), + "a typed-out page raises the prompt arrow") + T.eq(battle.shown[2][18], LAST, "the prompt page's 18th glyph is typed") + local lit, row = drawnAt(battle, 0) + T.check(row >= 17, "the prompt page's bottom line is drawn") + T.eq(#lit, 0, "prompt arrow replaces the 18th glyph") + battle.msgPromptWait = 0 + setA(true) + battle:updateQueue() + setA(false) + T.eq(battle.msgPrompt, nil, "A dismisses the prompt") + T.eq(battle.shown[2][18], SPACE, "PromptText writes a space over the arrow cell") + local held = drawnAt(battle, 0) + T.eq(held[1], SPACE, "the held page shows the blank, not the glyph") +end + +do + local battle = newBattle() + battle.queue[1] = { text = "SHORT LINE\nSHORT TOO\vNEXT" } + step(battle, function() return battle.msgWaiting end) + T.check(#battle.shown[2] < 18, "a short bottom line has no 18th glyph") + battle.msgPreWait = 0 + T.eq(#drawnAt(battle, 30), 0, "nothing drawn in the arrow cell for a short line") +end + +T.finish("battle arrow cell bug 2674") diff --git a/tests/engine/battle_intro_balls_after_cry_bug2662.lua b/tests/engine/battle_intro_balls_after_cry_bug2662.lua new file mode 100644 index 00000000..d55d6886 --- /dev/null +++ b/tests/engine/battle_intro_balls_after_cry_bug2662.lua @@ -0,0 +1,160 @@ +-- engine/battle/common_text.asm:10 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") +local GameVersion = require("src.core.GameVersion") + +local SOUND_FRAMES = 40 +local soundLeft = 0 +local function fakeSource() + soundLeft = SOUND_FRAMES + return { isPlaying = function() return soundLeft > 0 end, + stop = function() soundLeft = 0 end } +end + +local real = {} +for _, k in ipairs({ "playCry", "play", "playMove", "playMoveCry", "stopLoop" }) do + real[k] = Sound[k] +end +local realMusic = { playBattle = Music.playBattle, play = Music.play } +Sound.playCry = function() return fakeSource() end +Sound.play = function(_, name) + if name == "Trainer_Appeared" then return fakeSource() end +end +Sound.playMove = function() end +Sound.playMoveCry = function() end +Sound.stopLoop = function() end +Music.playBattle = function() end +Music.play = function() end + +local function makeGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20), Pokemon.new(Data, "FIXMON_B", 5) } + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end } } +end + +local function snapshot(battle) + local rows, strings = 0, {} + local realRow, realDraw = battle.drawBallRow, Font.draw + battle.drawBallRow = function() rows = rows + 1 end + Font.draw = function(text) strings[#strings + 1] = tostring(text) return 0 end + local ok, err = pcall(battle.drawHUDs, battle, 0) + battle.drawBallRow, Font.draw = realRow, realDraw + local enemyHud = false + for _, s in ipairs(strings) do + if s:find(battle.enemy.name, 1, true) then enemyHud = true end + end + return ok, err, rows, enemyHud +end + +local function run(label, battle) + battle.onFinish = function() end + battle:enter() + T.check(battle.introBalls ~= true, label .. ": no ball window at enter") + local frames, soundOverlap = {}, false + local ballsAt, textAt, sawRows + for f = 1, 400 do + if soundLeft > 0 then soundLeft = soundLeft - 1 end + battle:update(1 / 60) + local cur = battle.current and battle.current.text + local atText = cur == battle.introText + if atText and not textAt then textAt = f end + if battle.introBalls == true and not ballsAt then + ballsAt = f + if soundLeft > 0 then soundOverlap = true end + end + local ok, err, rows, hud = snapshot(battle) + T.check(ok, label .. ": drawHUDs runs on frame " .. f .. (ok and "" or (": " .. tostring(err)))) + frames[f] = { balls = battle.introBalls == true, rows = rows, hud = hud, + landed = (battle.introSlide or 0) == 0 } + if atText and rows > 0 then sawRows = true end + if textAt then break end + end + local early, earlyRows, earlyHud = false, false, false + for f, fr in ipairs(frames) do + if textAt and f < textAt - 1 then + if fr.balls then early = true end + if fr.rows > 0 then earlyRows = true end + end + if textAt and f < textAt and fr.landed and fr.hud then earlyHud = true end + end + return { early = early, earlyRows = earlyRows, earlyHud = earlyHud, + overlap = soundOverlap, ballsAt = ballsAt, textAt = textAt, + sawRows = sawRows } +end + +GameVersion.set("red") + +do + local r = run("wild", BattleState.newWild(makeGame(), "FIXMON_C", 5)) + T.check(r.textAt ~= nil, "wild: the intro text comes up") + T.check(not r.early, "wild: the ball window stays shut until the intro text") + T.check(not r.overlap, "wild: the ball window opens after the cry has finished") + T.eq(r.ballsAt, r.textAt and r.textAt - 1, "wild: balls go up right before the intro text") + T.check(not r.earlyRows, "wild: no ball row is drawn during the cry") + T.check(not r.earlyHud, "wild: no enemy HUD is drawn during the cry") + T.check(r.sawRows, "wild: the ball row is drawn with the intro text") +end + +do + local r = run("trainer", BattleState.newTrainer(makeGame(), "OPP_FIX_YOUNGSTER", 1)) + T.check(r.textAt ~= nil, "trainer: the intro text comes up") + T.check(not r.early, "trainer: the ball window stays shut through the sfx and gap") + T.check(not r.overlap, "trainer: the ball window opens after the sfx has finished") + T.eq(r.ballsAt, r.textAt and r.textAt - 1, "trainer: balls go up right before the intro text") + T.check(not r.earlyRows, "trainer: no ball rows during the sfx and gap") + T.check(r.sawRows, "trainer: the ball rows are drawn with the intro text") +end + +do + local ghost = BattleState.newWild(makeGame(), "FIXMON_C", 5) + ghost:makeGhost() + local r = run("ghost", ghost) + T.check(r.ballsAt == nil and not r.sawRows, "ghost: DrawAllPokeballs never runs") + T.check(not r.earlyHud, "ghost: no enemy HUD under the intro text") + local nextText = ghost.queue[1] and ghost.queue[1].text + T.check(type(nextText) == "string" and nextText:find("ID'd", 1, true) ~= nil, + "ghost: GhostCantBeIDdText follows the intro text") +end + +do + local soul = BattleState.newWild(makeGame(), "FIXMON_C", 5) + soul:makeUnveiledGhost() + local r = run("unveiled", soul) + T.check(r.ballsAt == nil and not r.sawRows, "unveiled ghost: DrawAllPokeballs never runs") +end + +do + local demo = BattleState.newWild(makeGame(), "FIXMON_C", 5) + demo:makeOldManDemo() + local r = run("red demo", demo) + T.check(r.sawRows, "red old man demo: DrawAllPokeballs runs (no wBattleType check)") +end + +GameVersion.set("yellow") +do + local demo = BattleState.newWild(makeGame(), "FIXMON_C", 5) + demo:makeOldManDemo() + local r = run("yellow demo", demo) + T.check(r.ballsAt == nil and not r.sawRows, "yellow old man demo: wBattleType skips DrawAllPokeballs") +end +GameVersion.set("red") + +for k, v in pairs(real) do Sound[k] = v end +Music.playBattle, Music.play = realMusic.playBattle, realMusic.play + +T.finish() diff --git a/tests/engine/boulder_dust_obp1_bug2650.lua b/tests/engine/boulder_dust_obp1_bug2650.lua new file mode 100644 index 00000000..d9a0ad22 --- /dev/null +++ b/tests/engine/boulder_dust_obp1_bug2650.lua @@ -0,0 +1,179 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") + +local T = require("tests.harness").suite("Boulder dust OBP1 flash #2650") +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local Assets = require("src.render.Assets") +local Game = require("src.core.Game") +local OW = require("src.world.OverworldController") +local ImageWriter = require("src.import.ImageWriter") + +local bytes = { + smoke = {0,24,26,102,4,66,11,129,86,137,26,46,76,18,56,56}, + redTree = {170,0,65,20,168,106,85,124,162,198,87,103,239,255,93,127, + 170,40,213,252,162,214,73,227,226,214,85,236,236,126,189,88, + 172,47,66,23,170,66,86,2,165,12,90,12,166,15,81,7, + 42,224,85,192,234,64,213,64,106,48,213,56,104,242,149,224}, + yellowTree = {170,0,65,20,168,106,85,124,130,254,87,127,239,255,93,127, + 170,40,213,252,162,254,85,255,250,254,85,252,236,126,189,88, + 172,47,66,23,170,66,86,2,165,12,90,12,166,15,81,7, + 42,224,85,192,234,64,213,64,106,48,213,56,104,242,149,224}, +} + +local ID = {}; ID.__index = ID +function ID:getDimensions() return self.w, self.h end +function ID:getWidth() return self.w end +function ID:getHeight() return self.h end +function ID:getPixel(x, y) return unpack(self.p[y * self.w + x + 1] or {0,0,0,0}) end +function ID:setPixel(x, y, ...) self.p[y * self.w + x + 1] = {...} end +function ID:mapPixel(fn) + for y = 0, self.h - 1 do + for x = 0, self.w - 1 do self:setPixel(x, y, fn(x, y, self:getPixel(x, y))) end + end +end +function ID:setFilter() end +local function imageData(w, h) return setmetatable({w = w, h = h, p = {}}, ID) end +local paths, draws, allocations, tint = {}, {}, 0, {1,1,1,1} +love.image.newImageData = function(a, b) + if type(a) == "number" then return imageData(a, b) end + local source = assert(paths[a], "missing fixture " .. tostring(a)) + local out = imageData(source.w, source.h) + for y = 0, source.h - 1 do + for x = 0, source.w - 1 do out:setPixel(x, y, source:getPixel(x, y)) end + end + return out +end +love.graphics.newImage = function(a) + allocations = allocations + 1 + return type(a) == "string" and love.image.newImageData(a) or a +end +love.graphics.setShader = function() end +love.graphics.setColor = function(...) tint = {...} end +love.graphics.draw = function(image, ...) + draws[#draws + 1] = {image = image, args = {...}, alpha = tint[4]} +end + +local function upvalue(fn, wanted) + for i = 1, 100 do + local name, value = debug.getupvalue(fn, i) + if not name then break end + if name == wanted then return value end + end +end +local fxDust = assert(upvalue(OW.drawWorld, "fxDust")) +local fxCutTree = assert(upvalue(OW.drawWorld, "fxCutTree")) +for i = 1, 100 do + local name = debug.getupvalue(fxDust, i) + if name == "Game" then debug.setupvalue(fxDust, i, Game); break end +end + +local definitions = { + smoke = {path = "assets/generated/fx/b2650_smoke.png", w = 8, h = 8}, + cutTree = {path = "assets/generated/fx/b2650_tree.png", w = 16, h = 16}, +} +local function fixtures(version) + for key, def in pairs(definitions) do + local raw = key == "cutTree" and bytes[version == "yellow" and "yellowTree" or "redTree"] or bytes[key] + paths[def.path] = ImageWriter.decode2bpp(raw, def.w, def.h, true) + end +end +local function state() + return setmetatable({ map = {id = "VICTORY_ROAD_1F"}, camera = {x = 0, y = 0} }, {__index = OW}) +end +Game.data = {field = {overworldFx = definitions}, palettes = {palettes = {}}, maps = {}} + +local FLASH = { [0] = 0, [1] = 0, [2] = 0, [3] = 2 } +local function colorOf(r) return r > .83 and 0 or r > .5 and 1 or r > .17 and 2 or 3 end + +local function checkPixels(rows, key, colors, label) + local source = paths[definitions[key].path] + local wrong, opaqueZero, translucent, visible = 0, 0, 0, 0 + for _, row in ipairs(rows) do + local q = type(row.args[1]) == "table" and row.args[1] + for y = q and q.y or 0, (q and q.y or 0) + (q and q.h or source.h) - 1 do + for x = q and q.x or 0, (q and q.x or 0) + (q and q.w or source.w) - 1 do + local sr, _, _, sa = source:getPixel(x, y) + local c = colorOf(sr) + local p = {row.image:getPixel(x, y)} + if sa == 0 or c == 0 then + if p[4] ~= 0 then opaqueZero = opaqueZero + 1 end + else + local want = colors[c + 1] + for i = 1, 3 do + if math.abs(p[i] - want[i] / 255) > .00001 then wrong = wrong + 1; break end + end + if p[4] ~= 1 then translucent = translucent + 1 end + visible = visible + 1 + end + end + end + T.eq(row.alpha, 1, label .. " drawn opaque, no alpha stand-in") + end + T.eq(wrong, 0, label .. " OBP1 %10000000 shade remap") + T.eq(opaqueZero, 0, label .. " color 0 stays transparent") + T.eq(translucent, 0, label .. " nonzero colors fully opaque") + T.check(visible > 0, label .. " nonempty") +end + +local function flashOf(colors) + return { colors[FLASH[0] + 1], colors[FLASH[1] + 1], colors[FLASH[2] + 1], colors[FLASH[3] + 1] } +end + +local cases = { + {name = "boulder", key = "smoke", group = 7, draw = fxDust, count = 4, + flash = function(s) s:startDustAnim(2, 3, nil, "right"); s.dustAnim.faded = true end, + lit = function(s) s:startDustAnim(2, 3, nil, "right"); s.dustAnim.faded = false end}, + {name = "cut grass", key = "smoke", group = 6, draw = fxDust, count = 4, + flash = function(s) s:startDustAnim(2, 3); s.dustAnim.frames = 31 end, + lit = function(s) s:startDustAnim(2, 3); s.dustAnim.frames = 30 end}, + {name = "cut tree", key = "cutTree", group = 6, draw = fxCutTree, count = 2, + flash = function(s) s:startCutTreeAnim(2, 3); s.cutAnim.frames = 7 end, + lit = function(s) s:startCutTreeAnim(2, 3); s.cutAnim.frames = 6 end}, +} + +local function render(s, case, which) + s.dustAnim, s.cutAnim = nil, nil + case[which](s); draws = {}; case.draw(s, s.camera) + return draws +end +local function rawOf(s, case) return s[case.key == "smoke" and "smokeImg" or "cutTreeImg"] end + +local s = state() +local seen = {} +for _, version in ipairs({"red", "blue", "yellow"}) do + GameVersion.set(version); fixtures(version) + PaletteFX.setShadeMap(nil); PaletteFX.setDarkWorld(false) + for _, case in ipairs(cases) do + for _, mode in ipairs({"og", "gbc", "ogred"}) do + PaletteFX.setMode(mode) + local label = version .. " " .. mode .. " " .. case.name + local rows = render(s, case, "flash") + T.eq(#rows, case.count, label .. " draw count") + T.check(rows[1].image ~= rawOf(s, case), label .. " flash frame is not the identity image") + checkPixels(rows, case.key, flashOf(PaletteFX.GRAYS), label .. " flash") + local img, before = rows[1].image, allocations + T.eq(render(s, case, "flash")[1].image, img, label .. " flash bake cached") + T.eq(allocations, before, label .. " repeated flash draw allocates no image") + rows = render(s, case, "lit") + T.eq(rows[1].image, rawOf(s, case), label .. " identity frame is the raw image") + T.eq(rows[1].alpha, 1, label .. " identity frame opaque") + seen[version .. mode .. case.name] = img + end + PaletteFX.setMode("redpp") + local label = version .. " redpp " .. case.name + local obj = PaletteFX.worldPack().spritePalettes[case.group] + local rows = render(s, case, "flash") + checkPixels(rows, case.key, flashOf(obj), label .. " flash") + local img, before = rows[1].image, allocations + T.eq(render(s, case, "flash")[1].image, img, label .. " flash bake cached") + T.eq(allocations, before, label .. " repeated flash draw allocates no image") + rows = render(s, case, "lit") + checkPixels(rows, case.key, obj, label .. " identity") + T.check(rows[1].image ~= img, label .. " identity and flash bakes differ") + end +end +T.check(seen["yellowogredcut tree"] ~= seen["redogredcut tree"], "yellow tree flash does not alias red bake") + +PaletteFX.setMode("gbc") +T.finish() diff --git a/tests/engine/champion_entrance_walk_bug2666.lua b/tests/engine/champion_entrance_walk_bug2666.lua new file mode 100644 index 00000000..f04903cc --- /dev/null +++ b/tests/engine/champion_entrance_walk_bug2666.lua @@ -0,0 +1,54 @@ +-- scripts/ChampionsRoom.asm:30 +-- home/map_objects.asm:179 +-- home/overworld.asm:1844 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local story = require("data.scripts.story") + +local queued = {} +local ow = { + player = { cellX = 3, cellY = 7 }, + npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } }, + queueScript = function(_, script, extra) + queued[#queued + 1] = { script = script, extra = extra } + end, +} +story.CHAMPIONS_ROOM.onEnter({ save = { flags = {} } }, ow) +eq(#queued, 2, "entrance walk and rival script are queued") + +local walk = queued[1] and queued[1].script or {} +local rle = { { "up", 1 }, { "right", 1 }, { "up", 3 } } +local expected = {} +for i = #rle, 1, -1 do expected[#expected + 1] = rle[i] end +eq(#walk, #expected, "entrance walk has one row per RLE entry") +for i, e in ipairs(expected) do + local row = walk[i] or {} + eq(row[1], "move_player", "row " .. i .. " is move_player") + eq(row[2], e[1], "row " .. i .. " direction plays the RLE back to front") + eq(row[3], e[2], "row " .. i .. " count plays the RLE back to front") +end + +local dx = { up = 0, down = 0, left = -1, right = 1 } +local dy = { up = -1, down = 1, left = 0, right = 0 } +local x, y = 3, 7 +local cells = { x .. "," .. y } +local lastDir +for _, row in ipairs(walk) do + if row[1] == "move_player" then + for _ = 1, row[3] do + x, y = x + dx[row[2]], y + dy[row[2]] + cells[#cells + 1] = x .. "," .. y + end + lastDir = row[2] + end +end +eq(table.concat(cells, " "), "3,7 3,6 3,5 3,4 4,4 4,3", + "player walks up the left carpet column and sidesteps just below the rival") +eq(lastDir, "up", "player ends facing up at the rival") + +T.finish("champion_entrance_walk_bug2666") diff --git a/tests/engine/continue_info_layout_2684.lua b/tests/engine/continue_info_layout_2684.lua new file mode 100644 index 00000000..94509cb9 --- /dev/null +++ b/tests/engine/continue_info_layout_2684.lua @@ -0,0 +1,76 @@ +-- engine/menus/main_menu.asm:357 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local love = _G.love or require("tests.love_stub") +_G.love = love + +local S = require("tests.harness").suite("continue info layout") +local check, eq = S.check, S.eq + +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local TitleState = require("src.ui.TitleState") + +local savedGetInfo = love.filesystem.getInfo +local savedLoad = SaveData.load +local savedDraw, savedBox = Font.draw, Font.drawBox + +local owned = {} +for i = 1, 151 do owned[i] = true end +local inventory = {} +for _, id in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", + "EARTHBADGE" }) do + inventory[id] = 1 +end +local fakeSave = { + player = { name = "RED" }, + inventory = inventory, + pokedex = { owned = owned }, + playTime = 47 * 3600 + 27 * 60, +} + +love.filesystem.getInfo = function() return { type = "file" } end +SaveData.load = function() return fakeSave end + +local stack = setmetatable({}, { __index = StateStack }) +stack:init() +local game = { data = {}, stack = stack } +local title = TitleState.new(game, {}) +title.game = game +title:openMenu() +local menu = game.stack:top() +check(menu and menu.items and menu.items[1], "main menu opened with items") +menu.items[1].onSelect() +local info = game.stack:top() +check(info and info ~= menu and info.draw, "CONTINUE pushed the info window") + +local drawn = {} +Font.draw = function(text, x, y) drawn[#drawn + 1] = { text, x, y } end +Font.drawBox = function() end +local ok, err = pcall(function() info:draw() end) +Font.draw, Font.drawBox = savedDraw, savedBox +love.filesystem.getInfo = savedGetInfo +SaveData.load = savedLoad +check(ok, "ContinueInfo:draw runs: " .. tostring(err)) + +local function at(text) + for _, d in ipairs(drawn) do + if d[1] == text then return d[2], d[3] end + end +end + +local bx, by = at(" 8") +eq(bx, 17 * 8, "badge count starts at hlcoord 17 (main_menu.asm:370)") +eq(by, 11 * 8, "badge count on row 11") +local dx, dy = at("151") +eq(dx, 16 * 8, "dex count starts at hlcoord 16 (main_menu.asm:372)") +eq(dy, 13 * 8, "dex count on row 13") +local tx, ty = at(" 47:27") +eq(tx, 13 * 8, "play time starts at hlcoord 13 (main_menu.asm:374)") +eq(ty, 15 * 8, "play time on row 15") +eq(bx + 2 * 8, tx + 6 * 8, "badge count ends flush with the play time") +eq(dx + 3 * 8, tx + 6 * 8, "dex count ends flush with the play time") + +S.finish() diff --git a/tests/engine/diploma_test.lua b/tests/engine/diploma_test.lua index 383e5904..699d93e3 100644 --- a/tests/engine/diploma_test.lua +++ b/tests/engine/diploma_test.lua @@ -39,6 +39,109 @@ local ok, err = pcall(function() end) check(ok, "Diploma:draw() runs without error: " .. tostring(err)) +local PaletteFX = require("src.render.PaletteFX") +local Sprites = require("src.pokemon.Sprites") +local SpriteRenderer = require("src.render.SpriteRenderer") +local GameVersion = require("src.core.GameVersion") + +local fakePic = setmetatable({ w = 40, h = 56 }, { + __index = { + getDimensions = function(s) return s.w, s.h end, + getWidth = function(s) return s.w end, + getHeight = function(s) return s.h end, + }, +}) +local origPlayerPic, origObp = Sprites.playerPic, SpriteRenderer.obpImage +local origScissor, origDraw = love.graphics.setScissor, love.graphics.draw +local origMode, origIsBlue = PaletteFX.mode, GameVersion.isBlue +local bakes, scissors, picDraws = {}, {}, {} +Sprites.playerPic = function() return "fake/player.png", false end +SpriteRenderer.obpImage = function(path, ramp, group) + bakes[#bakes + 1] = { path = path, ramp = ramp, group = group } + return fakePic +end +love.graphics.setScissor = function(...) + scissors[#scissors + 1] = { ... } +end +love.graphics.draw = function(img, a, b) + if img == fakePic then picDraws[#picDraws + 1] = { a, b } end +end + +local function renderIn(mode, pass) + bakes, scissors, picDraws = {}, {}, {} + PaletteFX.mode = mode + PaletteFX.clearSpriteRedraws() + PaletteFX.setPass(pass) + local okR, errR = pcall(Diploma.render, Game) + check(okR, "Diploma.render under " .. tostring(mode) .. "/" + .. tostring(pass) .. ": " .. tostring(errR)) + local redraws = {} + for _, r in ipairs(PaletteFX.uiSpriteRedraws()) do redraws[#redraws + 1] = r end + PaletteFX.setPass(nil) + PaletteFX.clearSpriteRedraws() + return redraws +end + +local function noNarrowScissor(label) + for _, s in ipairs(scissors) do + local x, w = s[1], s[3] + check(x == nil or x + w >= 155, + label .. ": scissor never clips the pic short of x 155") + end +end + +local r = renderIn("sgb", "ui") +noNarrowScissor("sgb") +eq(#picDraws, 1, "sgb draws the player pic once") +eq(picDraws[1] and picDraws[1][1], 115, "pic x 115 (diploma.asm:44 +33)") +eq(picDraws[1] and picDraws[1][2], 80, "pic y 80") +eq(bakes[1] and bakes[1].group, "diploma", "sgb keeps the OBP0 $90 DMG bake") +eq(#r, 0, "sgb records no OBJ-ramp redraw") + +GameVersion.isBlue = function() return false end +r = renderIn("ogred", "ui") +noNarrowScissor("ogred") +eq(bakes[1] and bakes[1].group, "diploma_og", "OG RED bakes the OBJ ramp") +local c = PaletteFX.OG_RED_SOFT_OBJ +local ramp = bakes[1] and bakes[1].ramp or {} +check(ramp[2] == c[1] and ramp[3] == c[2] and ramp[4] == c[3], + "OG RED OBP0 $90 maps colors 1-3 to OBJ shades 0, 1, 2") +check(r[1] and r[1].image == fakePic and r[1].x == 115 and r[1].y == 80, + "OG RED replays the pic at (115, 80) after the zone pass") +check(r[2] and r[2].clip and r[2].clip[1] == 115 and r[2].clip[2] == 80 + and r[2].clip[3] == 40 and r[2].clip[4] == 56, + "OG RED replays the BG text over the pic, clipped to the pic") + +local canvasSets = 0 +local origSetCanvas = love.graphics.setCanvas +love.graphics.setCanvas = function(c) + if c then canvasSets = canvasSets + 1 end + return origSetCanvas(c) +end +renderIn("ogred", "ui") +eq(canvasSets, 0, "OG RED reuses the cached text overlay on a second draw") +Game.save.player.name = "RED" +renderIn("ogred", "ui") +eq(canvasSets, 1, "a changed player name rebuilds the text overlay once") +Game.save.player.name = "ASH" +love.graphics.setCanvas = origSetCanvas + +GameVersion.isBlue = function() return true end +r = renderIn("ogred", "ui") +eq(bakes[1] and bakes[1].group, "diploma_og_blue", + "OG BLUE bakes under a version-distinct cache group") +check(bakes[1] and bakes[1].ramp[3] == PaletteFX.GBC_OBJ_BLUE[2], + "OG BLUE uses Blue's OBJ ramp") +GameVersion.isBlue = origIsBlue + +r = renderIn("ogred", nil) +eq(bakes[1] and bakes[1].group, "diploma", "printer path keeps the DMG bake") +eq(#r, 0, "printer path records no redraw") + +Sprites.playerPic, SpriteRenderer.obpImage = origPlayerPic, origObp +love.graphics.setScissor, love.graphics.draw = origScissor, origDraw +PaletteFX.mode = origMode + -- Confirm dismissal on A or B press Input.pressed = { a = true } diploma:update() diff --git a/tests/engine/evolution_double_after_battle_bug2664.lua b/tests/engine/evolution_double_after_battle_bug2664.lua new file mode 100644 index 00000000..cf1686f9 --- /dev/null +++ b/tests/engine/evolution_double_after_battle_bug2664.lua @@ -0,0 +1,164 @@ +-- engine/pokemon/evos_moves.asm:156 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, + playPress = function() end, + stopLoop = function() end, +} + +local Fixtures = require("tests.modkit.fixtures") +local Music = require("src.core.Music") +local Evolution = require("src.pokemon.Evolution") +local EvolutionState = require("src.ui.EvolutionState") +local Input = require("src.core.Input") +local Pokemon = require("src.pokemon.Pokemon") +local StateStack = require("src.core.StateStack") +local TextBox = require("src.render.TextBox") + +local Data = Fixtures.fresh() +require("src.render.Font").load(Data) + +local restores = 0 +Music.restoreMap = function() restores = restores + 1 end + +local A_KEY, B_KEY = "z", "x" + +local function newGame(count) + local game = { data = Data } + local party = {} + for i = 1, count do party[i] = Pokemon.new(Data, "FIXMON_A", 16) end + game.save = { + party = party, + player = { name = "RED", id = 1 }, + options = { textSpeed = 5 }, + flags = {}, + pokedex = { seen = {}, owned = {} }, + } + game.stack = setmetatable({}, { __index = StateStack }) + game.stack:init() + game.input = Input + Input:init() + local under = { isOpaque = true, update = function() end, draw = function() end } + game.stack:push(under) + return game, party, under +end + +local function step(game, key) + if key then Input:keypressed(key) end + game.input:step() + game.stack:update(1 / 60) + if key then Input:keyreleased(key) end +end + +local function indexOf(game, state) + for i, s in ipairs(game.stack.states) do + if s == state then return i end + end +end + +local function leftovers(game) + local n = 0 + for _, s in ipairs(game.stack.states) do + if s.evoClear or s.evoIntro or getmetatable(s) == EvolutionState then + n = n + 1 + end + end + return n +end + +local function runBatch(cancelFirst) + local game, party, battle = newGame(2) + restores = 0 + local finished = false + local leveled = { [party[1]] = true, [party[2]] = true } + Evolution.checkParty(game, function() finished = true end, leveled) + local intros, seen = 0, {} + local secondIntro + local restoresBeforeSecond + for frame = 1, 6000 do + if finished then break end + local top = game.stack:top() + if top and top.evoIntro and not seen[top] then + seen[top] = true + intros = intros + 1 + if intros == 2 then + secondIntro = { + below = game.stack.states[indexOf(game, top) - 1], + base = game.stack:visibleBase(), + battleAt = indexOf(game, battle), + } + restoresBeforeSecond = restores + end + end + local key = A_KEY + if cancelFirst and intros == 1 and getmetatable(top) == EvolutionState + and top.t > 100 and not top.done then + key = B_KEY + elseif frame % 2 == 0 then + key = nil + end + step(game, key) + end + return { + game = game, party = party, battle = battle, finished = finished, + intros = intros, second = secondIntro, + restoresBeforeSecond = restoresBeforeSecond, + } +end + +for _, cancelFirst in ipairs({ false, true }) do + local label = cancelFirst and "cancelled first mon" or "two evolutions" + local r = runBatch(cancelFirst) + eq(r.intros, 2, label .. ": both mons get an IsEvolvingText box") + if check(r.second ~= nil, label .. ": the second intro box went up") then + check(r.second.below and r.second.below.evoClear and r.second.below.isOpaque, + label .. ": the second intro draws over an opaque blank screen") + check(r.second.base > r.second.battleAt, + label .. ": the battle screen is not drawn under the second intro") + eq(r.restoresBeforeSecond, 0, + label .. ": no map music between the two evolutions") + end + check(r.finished, label .. ": the batch finished") + eq(r.game.stack:top(), r.battle, label .. ": the battle is top again") + eq(leftovers(r.game), 0, label .. ": no evolution layers are left") + eq(restores, 0, label .. ": the in-battle batch never restores map music") + eq(r.party[2].species, "FIXMON_B", label .. ": the second mon evolved") + eq(r.party[1].species, cancelFirst and "FIXMON_A" or "FIXMON_B", + label .. ": the first mon's species") +end + +for _, via in ipairs({ "ITEM", "TRADE" }) do + local game, party, menu = newGame(1) + restores = 0 + local finished, topAtDone = false, nil + Evolution.evolve(game, party[1], "FIXMON_B", function() + finished = true + topAtDone = game.stack:top() + end, via) + local sawClear = false + local realClear = Evolution.clearScreen + Evolution.clearScreen = function(g, species) + realClear(g, species) + local top = g.stack:top() + if top and top.evoClear and top.isOpaque then sawClear = true end + end + for frame = 1, 6000 do + if finished then break end + step(game, frame % 2 == 1 and A_KEY or nil) + end + check(finished, via .. ": the single evolution finished") + eq(topAtDone, menu, via .. ": the caller's screen is top when onDone runs") + eq(leftovers(game), 0, via .. ": no evolution layers are left") + eq(restores, 1, via .. ": map music comes back once after the result box") + Evolution.clearScreen = realClear + check(sawClear, via .. ": the screen clears once the result box closes") +end + +T.finish() diff --git a/tests/engine/game3_shaderfx_options_test.lua b/tests/engine/game3_shaderfx_options_test.lua index a4163053..66a6b798 100644 --- a/tests/engine/game3_shaderfx_options_test.lua +++ b/tests/engine/game3_shaderfx_options_test.lua @@ -127,23 +127,34 @@ do Menu.show({ game = game, session = { version = "ruby", engineOptions = game.options } }) for _ = 1, 40 do Menu.update() end local top = Menu._pages[1] - for i = 1, 7 do eq(top.rows[i].native, i, "RS OPTION keeps native row " .. i .. " in cart order") end local seen = {} - for i = 8, #top.rows do seen[top.rows[i].port.id] = i end - check(seen["group.graphics"] ~= nil, "RS OPTION lists a visible GRAPHICS row below CANCEL") - check(seen["group.speed"] and seen["group.audio"] and seen["controls"] and seen["mods"], - "RS OPTION carries the Emerald port pages") - check(seen["eventTickets"] == nil and seen["textSpeed"] == nil, "RS port rows skip cart rows and Emerald-only rows") + for i, r in ipairs(top.rows) do seen[r.id] = i end + local order = {} + for _, id in ipairs(Rows.ORDER) do if seen[id] then order[#order + 1] = id end end + for i, id in ipairs(order) do eq(top.rows[i].id, id, "RS OPTION top row " .. i .. " follows Emerald's grouped order") end + check(seen["group.speed"] and seen["group.graphics"] and seen["group.audio"] and seen["group.battle"] + and seen["controls"] and seen["mods"], "RS OPTION carries the Emerald pages") + check(seen["textSpeed"] == nil and seen["sound"] == nil and seen["frameType"] == nil, + "RS cart rows move into their Emerald groups") + eq(top.rows[seen["buttonMode"]].native, 5, "BUTTON MODE stays a native cart row on the top page") + check(seen["eventTickets"] == nil, "RS skips Emerald-only rows") Menu.handleInput(input({ "select" })) check(not ShaderFXMenu.isOpen(), "SELECT is not a hidden SHADER FX shortcut") for _ = 1, seen["group.graphics"] - 1 do Menu.handleInput(input({ "down" })) end - eq(top.scroll, seen["group.graphics"] - 7, "the list scrolls to reach the port rows") Menu.handleInput(input({ "a" })) local graphics = Menu._pages[#Menu._pages] check(#Menu._pages == 2, "A on GRAPHICS opens its page") - local si - for i, r in ipairs(graphics.rows) do if r.id == "shaderfx" then si = i end end + local si, fi + for i, r in ipairs(graphics.rows) do + if r.id == "shaderfx" then si = i end + if r.id == "frameType" and r.native == 6 then fi = i end + end + check(fi ~= nil, "RS GRAPHICS carries the native FRAME row") check(si ~= nil, "RS GRAPHICS lists SHADER FX") + for _ = 1, (fi or 1) - 1 do Menu.handleInput(input({ "down" })) end + Menu.handleInput(input({ "right" })) + eq(Menu._pending.frameType, 1, "RIGHT on the grouped FRAME row steps the pending frame") + graphics.index = 1 for _ = 1, (si or 1) - 1 do Menu.handleInput(input({ "down" })) end Menu.handleInput(input({ "a" })) check(ShaderFXMenu.isOpen(), "A on the visible SHADER FX row opens the picker") diff --git a/tests/engine/gen1_field_fx_palette_h13.lua b/tests/engine/gen1_field_fx_palette_h13.lua index 5086009e..7bb09615 100644 --- a/tests/engine/gen1_field_fx_palette_h13.lua +++ b/tests/engine/gen1_field_fx_palette_h13.lua @@ -256,9 +256,10 @@ for _, transparent in ipairs({false, true}) do render(s, case) if case.key == "cutTree" then s.cutAnim.frames = 5 elseif s.dustAnim.boulder then s.dustAnim.faded = true - else s.dustAnim.frames = 28 end + else s.dustAnim.frames = 31 end draws = {}; case.fn(s, s.camera) - T.eq(draws[1].alpha, .55, version .. " " .. case.name .. " flicker tint preserved") + T.eq(draws[1].alpha, 1, version .. " " .. case.name .. " OBP1 flash frame drawn opaque") + T.check(draws[1].image ~= render(s, case)[1].image, version .. " " .. case.name .. " OBP1 flash frame uses its own bake") end end end diff --git a/tests/engine/grass_feet_strip_2688.lua b/tests/engine/grass_feet_strip_2688.lua new file mode 100644 index 00000000..3a2ebc19 --- /dev/null +++ b/tests/engine/grass_feet_strip_2688.lua @@ -0,0 +1,133 @@ +-- data/sprites/facings.asm:51 + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +T.fixtures.fresh() + +local TileRenderer = require("src.render.TileRenderer") +local PaletteFX = require("src.render.PaletteFX") +local OW = require("src.world.OverworldController") + +local function upvalue(fn, name) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return v end + i = i + 1 + end +end + +check(type(TileRenderer.feetStrip) == "function", "TileRenderer exposes the feet strip") + +local x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, nil) +eq(x0, 32, "strip starts at the sprite's left edge") +eq(y0, 52, "strip starts 4 px into the cell (bottom OAM row, sprite row 8)") +eq(x1, 48, "strip is the sprite's 16 px width") +eq(y1, 60, "strip ends 4 px above the cell bottom (sprite row 15)") + +local vanilla = { frameWidth = 16, frameHeight = 16, anchorX = 8, anchorY = 16 } +x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, vanilla) +eq(y0, 52, "vanilla 16x16 frame gets the same strip") +eq(y1, 60, "vanilla 16x16 frame strip is 8 rows") + +local tall = { frameWidth = 16, frameHeight = 32, anchorX = 8, anchorY = 32 } +x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, tall) +eq(y0, 52, "a 32-tall frame keeps its strip on the frame's bottom 8 rows") +eq(y1, 60, "a 32-tall frame strip is still 8 rows") + +local function spans(a, b, c, d) + local out = {} + TileRenderer.eachStripSpan(a, b, c, d, function(tx, ty, x, y, ox, oy, w, h) + out[#out + 1] = { tx = tx, ty = ty, x = x, y = y, ox = ox, oy = oy, w = w, h = h } + end) + return out +end + +local s = spans(32, 52, 48, 60) +eq(#s, 4, "a standing strip touches 2x2 tiles") +eq(s[1].ty, 6, "first span is the cell's top tile row") +eq(s[1].oy, 4, "top tile row is cut from its row 4") +eq(s[1].h, 4, "top tile row contributes its lower 4 rows") +eq(s[3].ty, 7, "second span is the cell's bottom tile row") +eq(s[3].oy, 0, "bottom tile row starts at its row 0") +eq(s[3].h, 4, "bottom tile row contributes its upper 4 rows") +eq(s[1].w, 8, "standing strip spans whole tile columns") + +s = spans(36, 54, 52, 62) +eq(#s, 6, "a mid-step strip straddles three tile columns") +eq(s[1].ox, 4, "left column clips to the sprite's left edge") +eq(s[1].w, 4, "left column keeps only the covered pixels") +eq(s[3].w, 4, "right column clips to the sprite's right edge") +eq(s[1].oy + s[1].h, 8, "top span runs to its tile's bottom") +eq(s[4].h, 6, "bottom span covers the rest of the strip") + +local image = { getDimensions = function() return 128, 128 end } +local fakeMap = { + tileset = { tilesPerRow = 16 }, + tileAt = function() return 0x52 end, +} +local r = setmetatable({ map = fakeMap, image = image, quads = { [0x52] = {} } }, + TileRenderer) + +local drawn = {} +local realDraw = love.graphics.draw +love.graphics.draw = function(_, q, x, y) + drawn[#drawn + 1] = { q = q, x = x, y = y } +end +r:drawStripRaw(32, 52, 48, 60, 0, 0) +love.graphics.draw = realDraw + +local covered = {} +for _, d in ipairs(drawn) do + for row = d.y, d.y + d.q.h - 1 do covered[row] = true end +end +for row = 48, 51 do + check(not covered[row], "sprite row " .. (row - 44) .. " stays above the grass") +end +for row = 52, 59 do + check(covered[row], "sprite row " .. (row - 44) .. " is behind the grass") +end +for row = 60, 63 do + check(not covered[row], "cell row " .. (row - 48) .. " below the sprite is not redrawn") +end + +local marks = {} +local realMark = PaletteFX.markSpriteRedraw +PaletteFX.markSpriteRedraw = function(img, q, x, y) + marks[#marks + 1] = { q = q, x = x, y = y } +end +r:markStripRedraw(32, 52, 48, 60, 0, 0, {}) +PaletteFX.markSpriteRedraw = realMark +local markRows = {} +for _, m in ipairs(marks) do + for row = m.y, m.y + m.q.h - 1 do markRows[row] = true end +end +check(markRows[52] and markRows[59] and not markRows[60] and not markRows[51], + "OG RED replay redraws the same strip") + +local grassStrip = upvalue(OW.drawWorld, "grassStrip") +check(type(grassStrip) == "function", "drawWorld places grass by the feet strip") +if grassStrip then + local map = { + renderer = r, + isGrassCell = function(_, cx, cy) return cx == 2 and cy == 3 end, + } + local ow = { map = map } + local standing = { cellX = 2, cellY = 3, px = 32, py = 48 } + local a, b, c, d = grassStrip(ow, standing) + eq(b, 52, "entity standing in grass gets the strip from row py+4") + eq(d, 60, "entity standing in grass gets the strip to row py+12") + local entering = { cellX = 2, cellY = 2, targetX = 2, targetY = 3, px = 32, py = 40, + pose = function(self) return vanilla, self.px, self.py end } + a, b, c, d = grassStrip(ow, entering) + eq(b, 44, "stepping into grass follows the sprite mid-step") + check(grassStrip(ow, { cellX = 1, cellY = 1, px = 16, py = 16 }) == nil, + "entity outside grass gets no strip") +end + +T.finish("grass feet strip") diff --git a/tests/engine/indigo_statues_bug2683.lua b/tests/engine/indigo_statues_bug2683.lua new file mode 100644 index 00000000..cc7ce8ed --- /dev/null +++ b/tests/engine/indigo_statues_bug2683.lua @@ -0,0 +1,62 @@ +-- engine/events/hidden_events/indigo_plateau_statues.asm:5 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed +local textBoxStub = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} +local fakeGame = { + data = { text = { + _IndigoPlateauStatuesText1 = "PLAQUE", + _IndigoPlateauStatuesText2 = "ULTIMATE GOAL", + _IndigoPlateauStatuesText3 = "HIGHEST AUTHORITY", + } }, + stack = { push = function(_, box) pushed = box end }, +} +T.check(setUpvalue(OW.tryBookshelf, "TextBox", textBoxStub), + "TextBox upvalue on tryBookshelf") +T.check(setUpvalue(OW.tryBookshelf, "Game", fakeGame), + "Game upvalue on tryBookshelf") + +local map = { + def = { tileset = "PLATEAU" }, + inBounds = function() return true end, + cellTile = function() return 0x30 end, +} + +local function read(cellX) + pushed = nil + local self_ = setmetatable({ player = { facing = "up", cellX = cellX, cellY = 6 }, + map = map }, { __index = OW }) + local ok = self_:tryBookshelf(cellX, 5) + return ok, pushed and pushed.text +end + +for _, x in ipairs({ 9, 11 }) do + local ok, text = read(x) + T.eq(ok, true, "odd X " .. x .. " statue is consumed") + T.eq(text, "PLAQUE\fULTIMATE GOAL", "odd X " .. x .. " prints Text2") +end +for _, x in ipairs({ 8, 10 }) do + local ok, text = read(x) + T.eq(ok, true, "even X " .. x .. " statue is consumed") + T.eq(text, "PLAQUE\fHIGHEST AUTHORITY", "even X " .. x .. " prints Text3") +end + +T.finish("indigo_statues_bug2683") diff --git a/tests/engine/lance_side_trigger_facing_bug2669.lua b/tests/engine/lance_side_trigger_facing_bug2669.lua new file mode 100644 index 00000000..6bed366d --- /dev/null +++ b/tests/engine/lance_side_trigger_facing_bug2669.lua @@ -0,0 +1,62 @@ +-- scripts/LancesRoom.asm:54 +-- home/overworld.asm:1212 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local story4 = require("data.scripts.story4") +local hooks = story4.LANCES_ROOM + +local function facingToward(npc, player) + local dx, dy = player.cellX - npc.cellX, player.cellY - npc.cellY + if math.abs(dx) > math.abs(dy) then + return dx < 0 and "left" or "right" + end + return dy < 0 and "up" or "down" +end + +local function trigger(x, y, playerFacing) + local lance = { + def = { name = "LANCESROOM_LANCE", index = 1, + trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 }, + id = "LANCES_ROOM:1", + cellX = 6, cellY = 1, + facing = "down", + } + function lance:facePlayer(player) self.facing = facingToward(self, player) end + local engaged = false + local facingAtEngage + local game = { data = {}, save = { flags = {}, defeatedTrainers = {} } } + local ow = { + npcs = { lance }, + player = { cellX = x, cellY = y, facing = playerFacing }, + } + function ow:trainerDefeated(npc) return game.save.defeatedTrainers[npc.id] == true end + function ow:engageTrainer(npc) + engaged = npc == lance + facingAtEngage = npc.facing + end + local handled = hooks.onStep(game, ow, x, y) + return handled, engaged, facingAtEngage, lance +end + +do + local handled, engaged, atEngage, lance = trigger(5, 1, "right") + check(handled == true, "(5,1) trigger handled") + check(engaged, "(5,1) trigger engages Lance") + eq(atEngage, "down", "(5,1) Lance still faces down when his text starts") + eq(lance.facing, "down", "(5,1) Lance keeps his object_event DOWN facing") +end + +do + local handled, engaged, atEngage, lance = trigger(6, 2, "up") + check(handled == true, "(6,2) trigger handled") + check(engaged, "(6,2) trigger engages Lance") + eq(atEngage, "down", "(6,2) Lance faces down when his text starts") + eq(lance.facing, "down", "(6,2) Lance keeps facing down") +end + +T.finish("lance_side_trigger_facing_bug2669") diff --git a/tests/engine/lance_victory_music_bug2659.lua b/tests/engine/lance_victory_music_bug2659.lua new file mode 100644 index 00000000..4bbc08d2 --- /dev/null +++ b/tests/engine/lance_victory_music_bug2659.lua @@ -0,0 +1,45 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Music = require("src.core.Music") +local BattleState = require("src.battle.BattleState") + +local played +local realPlayVictory = Music.playVictory +Music.playVictory = function(data, kind) played = kind end + +local function run(kind, id, partyIndex) + local b = setmetatable({ + data = {}, + kind = kind, + oppClass = id, + partyIndex = partyIndex, + trainer = id and { id = id } or nil, + }, { __index = BattleState }) + b.musicKind = b:computeMusicKind() + played = nil + b:playVictoryMusic() + return b.musicKind, played +end + +local cases = { + { "trainer", "OPP_LANCE", 1, "gym", "trainer" }, + { "trainer", "OPP_LORELEI", 1, "trainer", "trainer" }, + { "trainer", "OPP_AGATHA", 1, "trainer", "trainer" }, + { "trainer", "OPP_BROCK", 1, "gym", "gym" }, + { "trainer", "OPP_GIOVANNI", 3, "gym", "gym" }, + { "trainer", "OPP_GIOVANNI", 2, "trainer", "trainer" }, + { "trainer", "OPP_RIVAL3", 1, "final", "gym" }, + { "trainer", "OPP_YOUNGSTER", 1, "trainer", "trainer" }, + { "wild", nil, nil, "wild", "wild" }, +} + +for _, c in ipairs(cases) do + local label = tostring(c[2] or "wild") .. "#" .. tostring(c[3] or "-") + local battleKind, victoryKind = run(c[1], c[2], c[3]) + T.eq(battleKind, c[4], label .. " battle theme kind") + T.eq(victoryKind, c[5], label .. " victory theme kind") +end + +Music.playVictory = realPlayVictory +T.finish("lance victory music bug2659") diff --git a/tests/engine/lance_walk_in_bug2665.lua b/tests/engine/lance_walk_in_bug2665.lua new file mode 100644 index 00000000..2ec21a15 --- /dev/null +++ b/tests/engine/lance_walk_in_bug2665.lua @@ -0,0 +1,88 @@ +-- scripts/LancesRoom.asm:96 +-- home/map_objects.asm:179 +-- home/overworld.asm:1845 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local savedSound = package.loaded["src.core.Sound"] +local played = {} +package.loaded["src.core.Sound"] = { + play = function(_, name) played[#played + 1] = name end, +} + +local story4 = require("data.scripts.story4") +local hooks = story4.LANCES_ROOM + +local function reversed(rle) + local out = {} + for i = #rle, 1, -1 do out[#out + 1] = rle[i] end + return out +end + +local function sameRoute(got, want, label) + eq(#got, #want, label .. ": segment count") + for i, w in ipairs(want) do + local g = got[i] or {} + eq(g[1], w[1], label .. ": segment " .. i .. " direction") + eq(g[2], w[2], label .. ": segment " .. i .. " count") + end +end + +local redRle = { { "up", 12 }, { "left", 12 }, { "down", 7 }, { "left", 6 } } +local yellowRle = { { "up", 13 }, { "left", 12 }, { "down", 7 }, { "left", 6 } } + +sameRoute(hooks.walkInRoute, reversed(redRle), "red walkInRoute") +sameRoute(hooks.walkInRouteFor(false), reversed(redRle), "red walkInRouteFor") +sameRoute(hooks.walkInRouteFor(true), reversed(yellowRle), "yellow walkInRouteFor") +eq(hooks.walkInRoute[1][1], "left", "walk-in leaves the staircase going left first") + +local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +local function runWalkIn(version) + local prev = GameVersion.current + GameVersion.current = version + played = {} + local blocks = {} + local moves = {} + local game = { data = {}, save = { flags = {} } } + local ow = { player = { cellX = 24, cellY = 16 } } + function ow:replaceBlock(bx, by, id) blocks[bx .. "," .. by] = id end + function ow:scriptMove(entity, dir, n, onDone) + moves[#moves + 1] = { dir, n } + local d = D[dir] + entity.cellX, entity.cellY = entity.cellX + d[1] * n, entity.cellY + d[2] * n + if onDone then onDone() end + end + hooks.onEnter(game, ow) + GameVersion.current = prev + return game, ow, moves, blocks +end + +do + local game, ow, moves, blocks = runWalkIn("red") + sameRoute(moves, reversed(redRle), "red onEnter moves") + eq(ow.player.cellX, 6, "red walk-in lands at x=6") + eq(ow.player.cellY, 11, "red walk-in lands at y=11") + eq(game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR, true, "red landing locks the door") + eq(played[#played], "Go_Inside", "red landing plays SFX_GO_INSIDE") + eq(blocks["2,6"], 0x72, "red door block (2,6) closed") + eq(blocks["3,6"], 0x73, "red door block (3,6) closed") +end + +do + local game, ow, moves, blocks = runWalkIn("yellow") + sameRoute(moves, reversed(yellowRle), "yellow onEnter moves") + eq(ow.player.cellX, 6, "yellow walk-in lands at x=6") + eq(ow.player.cellY, 10, "yellow walk-in lands at y=10") + check(not game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR, "yellow landing leaves the door open") + eq(#played, 0, "yellow landing plays no SFX") + eq(blocks["2,6"], 0x31, "yellow door block (2,6) open") +end + +package.loaded["src.core.Sound"] = savedSound +T.finish("lance_walk_in_bug2665") diff --git a/tests/engine/mart_bag_full_bug2162.lua b/tests/engine/mart_bag_full_bug2162.lua index 1abd9e93..4cc2214d 100644 --- a/tests/engine/mart_bag_full_bug2162.lua +++ b/tests/engine/mart_bag_full_bug2162.lua @@ -119,18 +119,38 @@ local function buyOne(game, stack) return menu, list, qty end +require("src.core.GameVersion").set("red") + +local function fill(game, n) + for i = 1, n do + local id = "FILLER_" .. i + game.save.inventory[id] = 1 + game.save.bagOrder[#game.save.bagOrder + 1] = id + end +end + do local game = newGame(3000, 99) + eq(Bag.add(game.save, "POKE_BALL", 1, game.data), true, + "AddItemToInventory puts a 100th POKe BALL in a new slot") + eq(game.save.inventory.POKE_BALL, 100, "the total is 100") + eq(Bag.slots(game.save, game.data), 2, "split over two slots") +end + +do + local game = newGame(3000, 99) + fill(game, 19) eq(Bag.add(game.save, "POKE_BALL", 1, game.data), false, - "AddItemToInventory refuses a 100th POKe BALL") + "a full bag refuses the overflow slot") eq(game.save.inventory.POKE_BALL, 99, "and adds nothing") end for _, case in ipairs({ - { name = "bag full", money = 3000, count = 99, text = BAG_FULL }, + { name = "bag full", money = 3000, count = 99, fill = 19, text = BAG_FULL }, { name = "not enough money", money = 100, count = nil, text = NO_MONEY }, }) do local game, events, stack = newGame(case.money, case.count) + fill(game, case.fill or 0) local menu, list, qty = buyOne(game, stack) local box = stack[#stack] if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then diff --git a/tests/engine/naming_empty_confirm_bug833.lua b/tests/engine/naming_empty_confirm_bug833.lua index a184b62a..b8737e94 100644 --- a/tests/engine/naming_empty_confirm_bug833.lua +++ b/tests/engine/naming_empty_confirm_bug833.lua @@ -7,9 +7,9 @@ -- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname. -- Nothing in the original invents a letter, so NamingScreen:confirm must hand -- the caller "" rather than the literal "A" when nothing was typed -- both via --- START and via the ED cell. The two fallbacks that are load bearing stay: --- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never --- accepts an empty name) and opts.default for the Name Rater cancel. +-- START and via the ED cell. Player/rival naming (oak_speech2.asm +-- ChoosePlayerName never accepts an empty name) re-opens the grid, and +-- opts.default still covers the Name Rater cancel. -- luajit tests/engine/naming_empty_confirm_bug833.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -107,14 +107,34 @@ press(ns, game, "a") press(ns, game, "start") eq(res.name, "A", "a genuinely typed A still comes back as A") --- ---------------------------------------------------------------- presets fallback (player / rival) --- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm) --- compare wStringBuffer to '@' and re-open rather than accept an empty name; --- the port answers the same need with its presets fallback, which #833 must --- not disturb. +-- ---------------------------------------------------------------- player / rival re-prompt +-- engine/movie/oak_speech/oak_speech2.asm:20 ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } }) +ns.row, ns.col, ns.lower = 3, 4, true press(ns, game, "start") -eq(res.name, "RED", "an empty confirm with presets still yields presets[1]") +check(not res.fired, "an empty player/rival name does not confirm") +eq(#game.stack.states, 1, "the naming screen stays up on an empty START") +eq(ns.row, 1, "re-entry puts the cursor back on row 1") +eq(ns.col, 1, "re-entry puts the cursor back on column 1") +eq(ns.lower, true, "wAlphabetCase carries over the re-entry") +check((ns.whiteout or 0) > 0, "the re-entry whites the screen out") +ns.lower = false +press(ns, game, "a") +eq(#ns.glyphs, 0, "input is ignored while the screen is white") +for _ = 1, 60 do ns:update(1 / 60) end +eq(ns.whiteout, 0, "the whiteout ends") +local edR, edC = edCell(ns) +ns.row, ns.col = edR, edC +press(ns, game, "a") +check(not res.fired, "ED with nothing typed re-prompts too") +eq(#game.stack.states, 1, "the naming screen is still up after an empty ED") +for _ = 1, 60 do ns:update(1 / 60) end +ns.row, ns.col = 1, 1 +press(ns, game, "a") +press(ns, game, "start") +check(res.fired, "a typed player name confirms") +eq(res.name, "A", "the typed name comes back, never presets[1]") +eq(#game.stack.states, 0, "a typed confirm pops the naming screen") -- ---------------------------------------------------------------- default fallback (Name Rater) -- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the diff --git a/tests/engine/oak_shrink_timing_2685.lua b/tests/engine/oak_shrink_timing_2685.lua new file mode 100644 index 00000000..9e7a7d16 --- /dev/null +++ b/tests/engine/oak_shrink_timing_2685.lua @@ -0,0 +1,176 @@ +-- engine/movie/oak_speech/oak_speech.asm:112 +-- home/fade.asm:24 +-- data/text/text_2.asm:1742 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local eq, check = T.eq, T.check +local OakSpeech = require("src.ui.OakSpeech") +local TextBox = require("src.render.TextBox") +local PaletteFX = require("src.render.PaletteFX") +local SpriteRenderer = require("src.render.SpriteRenderer") +local Transition = require("src.render.Transition") +local Music = require("src.core.Music") +local Sound = require("src.core.Sound") +local Version = require("src.core.GameVersion") + +local origFadeOut, origPlay, origPress = Music.fadeOut, Sound.play, Sound.playPress +Music.fadeOut = function() end +Sound.play = function() end +Sound.playPress = function() end + +local function stub(fields) + local s = setmetatable(fields or {}, OakSpeech) + return s +end + +do + local player, p1, p2 = { tag = "player" }, { tag = "p1" }, { tag = "p2" } + local speech = stub({ playerPic = player, shrinkPic1 = p1, shrinkPic2 = p2, + pic = player }) + local finishes = 0 + speech.finish = function() finishes = finishes + 1 end + speech.shrink = { frame = 0 } + local pics, walk, fade = {}, {}, {} + for f = 1, 200 do + speech:update(0) + pics[f], walk[f], fade[f] = speech.pic, speech.walkVisible, speech.fadeLevel + end + local function span(want) + local first, last + for f = 1, 200 do + if pics[f] == want then + first = first or f + last = f + end + end + return first, last + end + local _, redLast = span(player) + local p1First, p1Last = span(p1) + local p2First, p2Last = span(p2) + check(redLast and redLast >= 30, + "RedPicFront holds through ShrinkPic1's decompress (got " .. tostring(redLast) .. ")") + eq(p1First, OakSpeech.SHRINK_PIC1_AT, "ShrinkPic1 lands on its frame") + check(p1Last and p1First and p1Last - p1First + 1 >= 30, + "ShrinkPic1 holds ~40 frames, not 4") + eq(p2First, OakSpeech.SHRINK_PIC2_AT, "ShrinkPic2 lands on its frame") + check(p2Last and p2First and p2Last - p2First + 1 >= 25, + "ShrinkPic2 holds ~32 frames") + eq(pics[OakSpeech.SHRINK_WALK_AT], nil, "the pic area clears") + check(walk[OakSpeech.SHRINK_WALK_AT] and not walk[OakSpeech.SHRINK_WALK_AT - 1], + "and the walking sprite appears on the same frame") + eq(OakSpeech.SHRINK_FADE_AT - OakSpeech.SHRINK_WALK_AT, 50, + "the walking sprite stands for ld c, 50") + eq(fade[OakSpeech.SHRINK_FADE_AT - 1], nil, "no fade before GBFadeOutToWhite") + eq(fade[OakSpeech.SHRINK_FADE_AT], 1, "FadePal6 first") + eq(fade[OakSpeech.SHRINK_FADE_AT + 8], 2, "FadePal7 eight frames later") + eq(fade[OakSpeech.SHRINK_FADE_AT + 16], 3, "FadePal8 eight frames after that") + eq(OakSpeech.SHRINK_END - OakSpeech.SHRINK_FADE_AT, 24, "3 palettes x 8 frames") + eq(finishes, 1, "finish runs once") +end + +do + local steps = {} + for _, step in ipairs(OakSpeech.defaultSteps({})) do steps[step.id] = step end + check(steps.legend and steps.legend.auto, + "OakSpeechText3 ends on done, so PrintText returns without a button wait") + + Version.set("red") + local pressed = false + local game = { data = { text = { _OakSpeechText3 = "A\nB{DONE}" } }, + save = { player = { name = "RED" }, options = { textSpeed = 1 } }, + input = { wasPressed = function() return pressed end, + isDown = function() return false end } } + game.stack = { push = function(s, b) s.box = b end, + pop = function(s) s.box = nil end } + local advanced = 0 + local speech = stub({ game = game }) + speech.advance = function() advanced = advanced + 1 end + speech:runStep(steps.legend) + for _ = 1, 24 do speech:update(0) end + local box = game.stack.box + eq(getmetatable(box), TextBox, "the legend page is a real text box") + local sawArrow = false + for _ = 1, 300 do + if not game.stack.box then break end + box:update() + if game.stack.box and box.done and box:arrowVisible() then sawArrow = true end + end + check(not sawArrow, "no blinking arrow on the last page") + eq(game.stack.box, nil, "the box closes on its own") + eq(advanced, 1, "and the shrink starts with no A press") +end + +local origObp = SpriteRenderer.obpImage +local origDraw = love.graphics.draw + +local function drawWalk(mode, fadeLevel) + PaletteFX.mode = mode + PaletteFX.clearSpriteRedraws() + PaletteFX.setShadeMap(nil) + PaletteFX.setFadeObp(nil) + PaletteFX.setPass("ui") + local baked = { tag = "baked" } + local got = {} + SpriteRenderer.obpImage = function(path, colors, group) + got.path, got.colors, got.group = path, colors, group + return baked + end + local drawn = {} + love.graphics.draw = function(img) drawn[#drawn + 1] = img end + local sheet = love.graphics.newImage("missing.png") + local speech = stub({ game = {}, walkVisible = true, walkSheet = sheet, + walkPath = "red.png", walkDef = { image = "red.png" }, + fadeLevel = fadeLevel }) + speech:draw() + love.graphics.draw = origDraw + SpriteRenderer.obpImage = origObp + local shadeMap = PaletteFX.shadeMap() + local redraws = #PaletteFX.uiSpriteRedraws() + PaletteFX.setFadeObp(nil) + PaletteFX.setShadeMap(nil) + return got, drawn, baked, sheet, redraws, shadeMap +end + +do + Version.set("red") + local got, drawn, baked, sheet, redraws = drawWalk("ogred") + eq(got.path, "red.png", "OG RED bakes the RedSprite sheet") + local want = PaletteFX.ogObjNormal() + eq(got.colors, want, "through the boot-ROM OBJ palette at rOBP0 = $D0") + check(drawn[1] == baked and drawn[1] ~= sheet, "and draws the baked sheet") + eq(redraws, 1, "replayed past the red BG zone pass") + + got, drawn, baked, sheet, redraws = drawWalk("gbc") + eq(got.colors, PaletteFX.OBP0_SHADES, + "SGB keeps the sprite in DMG shades through the OBP0 lift") + eq(redraws, 0, "and lets the zone palette color it") + + Version.set("blue") + got = drawWalk("ogred") + eq(got.colors, PaletteFX.ogObjNormal(), "OG BLUE bakes Blue's pink OBJ ramp") + Version.set("red") +end + +do + Version.set("red") + local raw = PaletteFX.ogObjBase() + local got, drawn, _, _, redraws, shadeMap = drawWalk("ogred", 1) + eq(shadeMap, Transition.shadeMapFor(0x90), "FadePal6 rBGP") + check(got.colors and got.colors[2] == raw[1] and got.colors[3] == raw[1] + and got.colors[4] == raw[3], + "FadePal6 rOBP0 $80 lightens the baked sprite") + eq(redraws, 1, "and the faded sprite still replays") + got, drawn, _, _, redraws, shadeMap = drawWalk("ogred", 3) + eq(shadeMap, Transition.shadeMapFor(0x00), "FadePal8 rBGP is solid white") + check(got.colors and got.colors[4] == raw[1], "and so is the sprite") +end + +PaletteFX.mode = "gbc" +Music.fadeOut, Sound.play, Sound.playPress = origFadeOut, origPlay, origPress + +T.finish("oak_shrink_timing_2685") diff --git a/tests/engine/oaks_lab_starter_ball_hide_2686.lua b/tests/engine/oaks_lab_starter_ball_hide_2686.lua new file mode 100644 index 00000000..7f2b701a --- /dev/null +++ b/tests/engine/oaks_lab_starter_ball_hide_2686.lua @@ -0,0 +1,61 @@ +-- scripts/OaksLab.asm:918 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local M = assert(loadfile("data/scripts/oaks_lab.lua"))() + +local cases = { + { "TEXT_OAKSLAB_CHARMANDER_POKE_BALL", "OAKSLAB_CHARMANDER_POKE_BALL", + "OAKSLAB_SQUIRTLE_POKE_BALL" }, + { "TEXT_OAKSLAB_SQUIRTLE_POKE_BALL", "OAKSLAB_SQUIRTLE_POKE_BALL", + "OAKSLAB_BULBASAUR_POKE_BALL" }, + { "TEXT_OAKSLAB_BULBASAUR_POKE_BALL", "OAKSLAB_BULBASAUR_POKE_BALL", + "OAKSLAB_CHARMANDER_POKE_BALL" }, +} + +local function find(rows, pred) + for i, row in ipairs(rows) do + if pred(row) then return i end + end +end + +for _, c in ipairs(cases) do + local key, own, rival = c[1], c[2], c[3] + local rows = M.talk[key] + T.check(type(rows) == "table", key .. " rows loaded") + local jumpNo = find(rows, function(r) + return r[1] == "jump_if_false" and r[2] == "end" end) + local hideOwn = find(rows, function(r) + return r[1] == "hide_object" and r[3] == own end) + local energetic = find(rows, function(r) + return r[1] == "show_text" and r[2] == "_OaksLabMonEnergeticText" end) + local received = find(rows, function(r) + return r[1] == "show_text" and r[2] == "_OaksLabReceivedMonText" end) + local give = find(rows, function(r) return r[1] == "give_pokemon" end) + local illTake = find(rows, function(r) + return r[1] == "show_text" and r[2] == "_OaksLabRivalIllTakeThisOneText" end) + local hideRival = find(rows, function(r) + return r[1] == "hide_object" and r[3] == rival end) + local rivalReceived = find(rows, function(r) + return r[1] == "show_text" and r[2] == "_OaksLabRivalReceivedMonText" end) + + T.check(jumpNo and hideOwn and energetic and received and give, + key .. ": all player-pick rows present") + if jumpNo and hideOwn and energetic and received and give then + T.eq(hideOwn, jumpNo + 1, key .. ": own ball hides right after YES") + T.check(hideOwn < energetic, key .. ": ball gone before the energetic line") + T.check(hideOwn < received, key .. ": ball gone before the received line") + T.check(hideOwn < give, key .. ": ball gone before the nickname prompt") + T.check(energetic < received and received < give, + key .. ": energetic, received, then AddPartyMon") + end + T.check(illTake and hideRival and rivalReceived, + key .. ": all rival-pick rows present") + if illTake and hideRival and rivalReceived then + T.check(illTake < hideRival and hideRival < rivalReceived, + key .. ": rival ball hides between ILL_TAKE_THIS_ONE and RECEIVED") + end +end + +T.finish("oaks_lab_starter_ball_hide_2686") diff --git a/tests/engine/oaks_lab_starter_energetic_bug1334.lua b/tests/engine/oaks_lab_starter_energetic_bug1334.lua index 2512d890..84131e82 100644 --- a/tests/engine/oaks_lab_starter_energetic_bug1334.lua +++ b/tests/engine/oaks_lab_starter_energetic_bug1334.lua @@ -18,13 +18,16 @@ for i, row in ipairs(rows) do end T.check(askRow ~= nil, "found the ask row") T.eq(rows[askRow + 1][1], "jump_if_false", "the ask is followed by its jump") -T.same(rows[askRow + 2], { "show_text", "_OaksLabMonEnergeticText" }, - "the energetic line is the row right after the NO jump") -T.eq(rows[askRow + 3][1], "text_sound", +T.same(rows[askRow + 2], + { "hide_object", "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL" }, + "the ball hides right after the NO jump") +T.same(rows[askRow + 3], { "show_text", "_OaksLabMonEnergeticText" }, + "the energetic line follows the ball hide") +T.eq(rows[askRow + 4][1], "text_sound", "the sound cue stays on the RECEIVED line, not the energetic one") -T.eq(rows[askRow + 4][1], "show_text", +T.eq(rows[askRow + 5][1], "show_text", "and the received-mon text follows the sound cue") -T.eq(rows[askRow + 4][2], "_OaksLabReceivedMonText", +T.eq(rows[askRow + 5][2], "_OaksLabReceivedMonText", "specifically the received-mon text") -- jump_if_true (row 2): the EVENT_GOT_STARTER short-circuit must land on diff --git a/tests/engine/oaks_pc_flow.lua b/tests/engine/oaks_pc_flow.lua index b736b213..bc5f7584 100644 --- a/tests/engine/oaks_pc_flow.lua +++ b/tests/engine/oaks_pc_flow.lua @@ -149,7 +149,7 @@ T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil, T.eq(#plays, 0, "no jingle while the evaluation is printing") ratingBox.opts.auto.sound() T.eq(#plays, 1, "jingle fires once the rating text is printed") -T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare") +T.eq(plays[1], "Get_Item1", "55 owned plays the Get_Item1 tier jingle") T.check(tostring(pushed[6].text):find("Closed link", 1, true) ~= nil, "the closing link prints at the end of the session") @@ -166,6 +166,29 @@ T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil, "NO skips the rating and closes the PC") T.eq(#plays, 0, "declining plays no jingle") +-- audio/pokedex_rating_sfx.asm:26 +local tiers = { + { 0, "Denied" }, { 9, "Denied" }, + { 10, "Pokedex_Rating" }, { 39, "Pokedex_Rating" }, + { 40, "Get_Item1" }, { 59, "Get_Item1" }, + { 60, "Caught_Mon" }, { 89, "Caught_Mon" }, + { 90, "Level_Up" }, { 119, "Level_Up" }, + { 120, "Get_Key_Item" }, { 149, "Get_Key_Item" }, + { 150, "Get_Item2" }, { 151, "Get_Item2" }, +} +for _, row in ipairs(tiers) do + T.eq(OW.dexRatingSfx and OW.dexRatingSfx(row[1]), row[2], + ("%d owned rates with %s"):format(row[1], row[2])) +end + +reset() +local full = {} +for i = 1, 151 do full[i] = true end +fakeGame.save.pokedex = { seen = full, owned = full } +fakeSelf:dexRating(function() end) +lastPush().opts.auto.sound() +T.eq(plays[1], "Get_Item2", "a complete dex plays Get_Item2") + package.loaded["src.ui.Menu"] = realMenu if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end diff --git a/tests/engine/overworld_og_obp0_bug2689.lua b/tests/engine/overworld_og_obp0_bug2689.lua new file mode 100644 index 00000000..0ae96df4 --- /dev/null +++ b/tests/engine/overworld_og_obp0_bug2689.lua @@ -0,0 +1,119 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local Assets = require("src.render.Assets") +local SpriteRenderer = require("src.render.SpriteRenderer") + +local prevVersion, prevMode, prevDark = GameVersion.get(), PaletteFX.mode, PaletteFX.darkWorld() +local realImageData, realImage = Assets.imageData, Assets.image +local realNewImage = love.graphics.newImage + +local SHADES = { 1.0, 0.66, 0.33, 0.0 } + +Assets.imageData = function(path) + local probe = { path = path } + function probe:mapPixel(fn) + self.mapped = {} + for i, v in ipairs(SHADES) do + local r, g, b, a = fn(0, 0, v, v, v, 1) + self.mapped[i] = { r, g, b, a } + end + end + return probe +end +Assets.image = function(path) + return realNewImage(path) +end +love.graphics.newImage = function(src) + local img = realNewImage(src) + if type(src) == "table" and src.mapped then img.mapped = src.mapped end + return img +end + +local serial = 0 +local function freshPath(tag) + serial = serial + 1 + return "bug2689/" .. tag .. "_" .. serial .. ".png" +end + +local function rgb(c) return string.format("%d,%d,%d", c[1], c[2], c[3]) end +local function mappedRgb(m) + return string.format("%d,%d,%d", math.floor(m[1] * 255 + 0.5), + math.floor(m[2] * 255 + 0.5), math.floor(m[3] * 255 + 0.5)) +end + +local function stubSprite(tag) + return setmetatable({ def = { image = freshPath(tag) } }, SpriteRenderer) +end + +local function expectRamp(img, ramp, label) + local m = img and img.mapped + check(m ~= nil, label .. ": bake ran through mapPixel") + if not m then return end + eq(m[1][4], 0, label .. ": OBJ color 0 is transparent") + eq(mappedRgb(m[2]), rgb(ramp[1]), label .. ": OBJ color 1 -> rOBP0 shade") + eq(mappedRgb(m[3]), rgb(ramp[2]), label .. ": OBJ color 2 -> rOBP0 shade") + eq(mappedRgb(m[4]), rgb(ramp[3]), label .. ": OBJ color 3 -> rOBP0 shade") +end + +local function lastRedrawImage() + local list = PaletteFX.spriteRedraws() + local e = list[#list] + return e and e.image +end + +for _, version in ipairs({ "red", "blue" }) do + GameVersion.set(version) + PaletteFX.setMode("ogred") + PaletteFX.setDarkWorld(false) + PaletteFX.setFadeObp(nil) + local base = PaletteFX.ogObjBase() + local lit = { base[1], base[2], base[4] } + + expectRamp(stubSprite(version):resolveImage(), lit, + version .. " lit resolveImage") + + PaletteFX.clearSpriteRedraws() + PaletteFX.setPass("world") + local ok, err = pcall(SpriteRenderer.drawTile, stubSprite(version), + freshPath(version .. "_tile"), 0, 0, false) + PaletteFX.setPass(nil) + check(ok, version .. " drawTile runs headless" .. (ok and "" or (": " .. tostring(err)))) + expectRamp(lastRedrawImage(), lit, version .. " lit drawTile redraw") + PaletteFX.clearSpriteRedraws() + + PaletteFX.setFadeObp({ [0] = 0, [1] = 1, [2] = 2, [3] = 3 }) + expectRamp(stubSprite(version):resolveImage(), { base[2], base[3], base[4] }, + version .. " FadePal3 rOBP0 $E4 step") + PaletteFX.setFadeObp({ [0] = 0, [1] = 0, [2] = 1, [3] = 3 }) + expectRamp(stubSprite(version):resolveImage(), lit, + version .. " FadePal4 rOBP0 $D0 step") + PaletteFX.setFadeObp(nil) + + PaletteFX.setDarkWorld(true) + local _, group = PaletteFX.ogObjWorld() + check(tostring(group):match("dark$") ~= nil, + version .. " dark map keeps the FadePal2 OBJ shift (" .. tostring(group) .. ")") + expectRamp(stubSprite(version):resolveImage(), { base[4], base[4], base[4] }, + version .. " dark map rOBP0 3,3,3,2") + PaletteFX.setDarkWorld(false) + + local _, litGroup = PaletteFX.ogObjWorld() + local _, rawGroup = PaletteFX.ogObj() + check(litGroup ~= rawGroup, + version .. " overworld bake group differs from the $E4 title/battle bake") +end + +love.graphics.newImage = realNewImage +Assets.imageData, Assets.image = realImageData, realImage +PaletteFX.setFadeObp(nil) +PaletteFX.setDarkWorld(prevDark) +GameVersion.set(prevVersion) +PaletteFX.setMode(prevMode) + +T.finish("overworld OG rOBP0 #2689") diff --git a/tests/engine/party_icon_anim_freeze_bug2682.lua b/tests/engine/party_icon_anim_freeze_bug2682.lua new file mode 100644 index 00000000..6163e71c --- /dev/null +++ b/tests/engine/party_icon_anim_freeze_bug2682.lua @@ -0,0 +1,122 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local PartyMenu = require("src.ui.PartyMenu") + +local function newGame(moveId, ow) + local function mk(species) + return { species = species, hp = 50, stats = { hp = 50 }, + level = 30, moves = { { id = moveId, pp = 15 } } } + end + local party = { mk("MACHOP"), mk("MACHOP") } + ow.map = ow.map or { def = { tileset = "OVERWORLD" }, id = "PALLET_TOWN" } + ow.dark = ow.dark or false + ow.partyKnows = function() return party[1] end + local game = { + data = { pokemon = { MACHOP = { name = "MACHOP" } } }, + save = { party = party, inventory = {}, options = {}, flags = {} }, + overworld = ow, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + return game +end + +local function press(pm, btn) + pm.game.input.queue = { [btn] = true } + pm:update(1 / 60) + pm.game.input.queue = {} +end + +local function idle(pm, n) + for _ = 1, n do pm:update(1 / 60) end +end + +local function rowOf(pm, action) + for i, item in ipairs(pm.subItems or {}) do + if item.action == action then return i end + end +end + +do + local game = newGame("STRENGTH", {}) + local pm = PartyMenu.new(game, {}) + game.stack:push(pm) + idle(pm, 7) + check(pm.blink > 0, "the cursor mon animates while the list takes input") + press(pm, "a") + check(pm.submenu, "A opens the submenu") + local frozen = pm.blink + idle(pm, 40) + eq(pm.blink, frozen, "the icon counter holds while the submenu is open") + press(pm, "b") + eq(pm.blink, 0, "closing the submenu restarts the icon on its rest frame") + idle(pm, 7) + press(pm, "down") + eq(pm.index, 2, "down moves the cursor") + eq(pm.blink, 0, "a cursor move restarts the icon on its rest frame") + idle(pm, 3) + press(pm, "up") + eq(pm.blink, 0, "up restarts the icon too") +end + +do + local game = newGame("STRENGTH", {}) + local pm = PartyMenu.new(game, {}) + game.stack:push(pm) + idle(pm, 5) + local before = pm.blink + pm:animateTo(game.save.party[1], 10) + pm:update(1 / 60) + eq(pm.blink, before, "the icon counter holds during the HP bar fill") +end + +local function fieldMove(label, moveId, action, ow, captured) + local game = newGame(moveId, ow) + local pm = PartyMenu.new(game, {}) + game.stack:push(pm) + press(pm, "a") + pm.subIndex = rowOf(pm, action) or 1 + press(pm, "a") + check(captured.onClose ~= nil, label .. " reaches the overworld handler") + check(not pm.submenu, label .. ": the options box is gone under the text") + check(pm.chosenHollow, label .. ": the party cursor stays hollow under the text") +end + +do + local captured = {} + fieldMove("STRENGTH", "STRENGTH", "strength", { + useStrengthFieldMove = function(_, _, onClose) captured.onClose = onClose end, + }, captured) +end + +do + local captured = {} + fieldMove("SURF", "SURF", "surf", { + player = { facingCell = function() return 4, 14 end }, + useSurfFieldMove = function() return "ok" end, + trySurf = function(_, _, _, onClose) captured.onClose = onClose end, + }, captured) +end + +do + local captured = {} + fieldMove("FLASH", "FLASH", "flash", { + dark = true, + useFlashFieldMove = function(_, onClose) captured.onClose = onClose end, + }, captured) +end + +T.finish() diff --git a/tests/engine/rage_building_bug2654.lua b/tests/engine/rage_building_bug2654.lua new file mode 100644 index 00000000..3aa9c8a6 --- /dev/null +++ b/tests/engine/rage_building_bug2654.lua @@ -0,0 +1,179 @@ +-- engine/battle/core.asm:3227 +-- engine/battle/core.asm:4913 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +Data.moves.FIX_LEAFISH = { + id = "FIX_LEAFISH", index = 97, name = "FIX LEAF", + type = "GRASS", power = 40, accuracy = 100, pp = 25, + effect = "NO_ADDITIONAL_EFFECT", +} +Data.moves.FIX_MULTI = { + id = "FIX_MULTI", index = 98, name = "FIX MULTI", + type = "NORMAL", power = 15, accuracy = 100, pp = 10, + effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = 3, +} + +local save = SaveData.newGame() +save.party = { Pokemon.new(Data, "FIXMON_A", 30) } +local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + +local function lowRolls(lo, hi) + if lo and lo > 0 then return hi end + return 0 +end + +local function mkseq(vals) + local i = 0 + return function(_, hi) + i = i + 1 + return vals[i] ~= nil and vals[i] or hi + end +end + +local function rager(rng) + local b = BattleState.newWild(game, "FIXMON_C", 40) + b.rng = rng or lowRolls + b.enemy.rageMove = { id = "RAGE", pp = 20 } + b.enemy.stages.attack = 0 + return b +end + +local function texts(b) + local out = {} + for _, row in ipairs(b.queue) do + if row.text then out[#out + 1] = row.text end + end + return out +end + +local function find(list, needle, from) + for i = from or 1, #list do + if list[i]:find(needle, 1, true) then return i end + end + return nil +end + +local function count(list, needle) + local n = 0 + for _, t in ipairs(list) do + if t:find(needle, 1, true) then n = n + 1 end + end + return n +end + +do + local b = rager() + b:performMove(b.player, b.enemy, { id = "FIX_LEAFISH", pp = 25 }) + local t = texts(b) + local crit = find(t, "Critical hit!") + local super = find(t, "super") + local rage = find(t, "RAGE is building!") + local rose = find(t, "rose!") + T.check(crit and super and rage and rose, "crit, effectiveness, rage and rose rows all queued") + T.check(crit and super and crit < super, "crit text precedes effectiveness") + T.check(super and rage and super < rage, "rage text follows effectiveness") + T.check(rage and rose and rage < rose, "ATTACK rose! follows the rage text") + T.check(rose and t[rose]:find("ATTACK", 1, true) ~= nil, "the rose row names ATTACK") + T.eq(b.enemy.stages.attack, 1, "rage raises the attack stage once") +end + +do + local b = rager() + b.enemy.mon.hp = 1 + b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 }) + local t = texts(b) + T.eq(b.enemy.mon.hp, 0, "the hit knocks the rager out") + T.eq(find(t, "RAGE is building!"), nil, "no rage text on the fainting hit") + T.eq(find(t, "rose!"), nil, "no stat text on the fainting hit") + T.eq(b.enemy.stages.attack, 0, "no stage gained on the fainting hit") +end + +do + local b = rager() + b.enemy.stages.attack = 6 + b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 }) + local t = texts(b) + T.eq(find(t, "RAGE is building!"), nil, "maxed attack stays silent") + T.eq(find(t, "Nothing happened!"), nil, "maxed attack prints no fallback either") + T.eq(b.enemy.stages.attack, 6, "stage stays at +6") +end + +do + local b = rager() + b.enemy.substituteHP = 200 + local hp = b.enemy.mon.hp + b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 }) + local t = texts(b) + T.eq(b.enemy.mon.hp, hp, "the substitute takes the hit") + T.check(find(t, "RAGE is building!") ~= nil, "rage still builds behind a substitute") + T.eq(b.enemy.stages.attack, 1, "substitute hit raises the stage") +end + +do + local b = rager(mkseq({ 0, 255, 255 })) + b.enemy.mon.maxHP, b.enemy.mon.hp = 999, 999 + b:performMove(b.player, b.enemy, { id = "FIX_MULTI", pp = 10 }) + local t = texts(b) + T.eq(count(t, "RAGE is building!"), 3, "rage builds once per strike") + T.eq(count(t, "rose!"), 3, "a rose line per strike") + T.eq(b.enemy.stages.attack, 3, "three strikes, three stages") + local multi = find(t, "times!") + local lastRose + for i, s in ipairs(t) do if s:find("rose!", 1, true) then lastRose = i end end + T.check(multi and lastRose and lastRose < multi, "hit count text follows the last strike's rage") +end + +do + local probe = rager(mkseq({ 0, 255, 255 })) + probe.enemy.mon.maxHP, probe.enemy.mon.hp = 999, 999 + probe:performMove(probe.player, probe.enemy, { id = "FIX_MULTI", pp = 10 }) + local perStrike = math.floor((999 - probe.enemy.mon.hp) / 3) + local b = rager(mkseq({ 0, 255, 255 })) + b.enemy.mon.hp = perStrike + 1 + b:performMove(b.player, b.enemy, { id = "FIX_MULTI", pp = 10 }) + local t = texts(b) + T.eq(b.enemy.mon.hp, 0, "the second strike knocks the rager out") + T.eq(count(t, "RAGE is building!"), 1, "only the first strike built rage") + T.eq(find(t, "times!"), nil, "no hit count text when a strike faints the target") + T.check(find(t, "fainted!") ~= nil, "the target faints") +end + +Data.moves.FIX_BOOM = { + id = "FIX_BOOM", index = 99, name = "FIX BOOM", + type = "NORMAL", power = 170, accuracy = 100, pp = 5, + effect = "EXPLODE_EFFECT", +} + +for _, arm in ipairs({ + { label = "accuracy miss", seq = { 255 } }, + { label = "invulnerable target", seq = { 0, 255, 255 }, invulnerable = true }, +}) do + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local b = rager(mkseq(arm.seq)) + b.enemy.invulnerable = arm.invulnerable + b:performMove(b.player, b.enemy, { id = "FIX_BOOM", pp = 5 }) + local t = texts(b) + T.check(find(t, "attack missed!") ~= nil, arm.label .. ": the Explosion missed") + T.eq(b.player.mon.hp, 0, arm.label .. ": the exploder still faints") + local rage = find(t, "RAGE is building!") + local faint = find(t, "fainted!") + T.check(rage ~= nil, arm.label .. ": a missed Explosion still builds rage") + T.check(rage and faint and rage < faint, arm.label .. ": rage text precedes the exploder's faint") + T.eq(b.enemy.stages.attack, 1, arm.label .. ": stage +1 after the miss") +end + +Data.moves.FIX_LEAFISH = nil +Data.moves.FIX_MULTI = nil +Data.moves.FIX_BOOM = nil +T.finish("rage building order") diff --git a/tests/engine/recoil_hp_drain_bug2653.lua b/tests/engine/recoil_hp_drain_bug2653.lua new file mode 100644 index 00000000..10fb03fe --- /dev/null +++ b/tests/engine/recoil_hp_drain_bug2653.lua @@ -0,0 +1,155 @@ +-- engine/battle/move_effects/recoil.asm:28 +-- engine/battle/move_effects/drain_hp.asm:81 +-- engine/battle/core.asm:4899 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +Data.moves.FIX_RECOIL = { + id = "FIX_RECOIL", index = 98, name = "FIX RECOIL", + type = "NORMAL", power = 90, accuracy = 100, pp = 20, + effect = "RECOIL_EFFECT", +} +Data.moves.FIX_ABSORB = { + id = "FIX_ABSORB", index = 97, name = "FIX ABSORB", + type = "NORMAL", power = 60, accuracy = 100, pp = 20, + effect = "DRAIN_HP_EFFECT", +} + +local function mkseq(vals) + local i = 0 + return function(_, hi) + i = i + 1 + return vals[i] ~= nil and vals[i] or hi + end +end + +local function newBattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 40) + battle.rng = mkseq({ 0, 255, 255 }) + return battle +end + +local function rowsOf(battle) + local out = {} + for i, row in ipairs(battle.queue) do + local kind + if row.drain then + kind = row.battler == battle.player and "drain:player" + or row.battler == battle.enemy and "drain:enemy" or "drain" + elseif row.text then + kind = row.text:find("recoil", 1, true) and "text:recoil" or "text" + end + if kind then out[#out + 1] = { kind = kind, row = row, i = i } end + end + return out +end + +local function find(rows, kind) + for _, r in ipairs(rows) do + if r.kind == kind then return r end + end +end + +local function replay(battle, row) + battle.queue, battle.current = { row }, nil + local frames = 0 + while battle:updateQueue() and frames < 8000 do frames = frames + 1 end + T.check(frames < 8000, "drain row settles") +end + +do + local battle = newBattle() + battle.player.mon.hp = battle.player.mon.stats.hp + battle.player.shownHP = battle.player.mon.hp + local pStart = battle.player.mon.hp + local eStart = battle.enemy.mon.hp + battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 }) + local dealt = eStart - battle.enemy.mon.hp + local recoil = math.max(1, math.floor(dealt / 4)) + T.eq(pStart - battle.player.mon.hp, recoil, "recoil is a quarter of the damage") + + local rows = rowsOf(battle) + local enemyRow, playerRow = find(rows, "drain:enemy"), find(rows, "drain:player") + local recoilText = find(rows, "text:recoil") + T.check(enemyRow and playerRow and recoilText, "target drain, user drain and recoil text all queued") + if enemyRow and playerRow and recoilText then + T.check(enemyRow.i < playerRow.i, "the target's bar drains before the user's") + T.check(playerRow.i < recoilText.i, "the user's bar drains before the recoil text") + T.eq(playerRow.row.stopAt, battle.player.mon.hp, "the user's row stops at the post-recoil HP") + + replay(battle, enemyRow.row) + T.eq(battle.enemy.shownHP, battle.enemy.mon.hp, "the target bar settles on its row") + T.eq(battle.player.shownHP, pStart, "the user's bar holds still during the target's row") + + replay(battle, playerRow.row) + T.eq(battle.player.shownHP, battle.player.mon.hp, "the user's bar settles on the recoil row") + end +end + +do + local battle = newBattle() + battle.player.substituteHP = 10 + local pStart = battle.player.mon.hp + battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 }) + T.check(battle.player.mon.hp < pStart, "recoil comes off the user's HP") + T.eq(battle.player.substituteHP, 10, "the user's substitute is untouched by recoil") + for _, row in ipairs(battle.queue) do + T.check(not (row.text and row.text:find("SUBSTITUTE", 1, true)), + "no substitute text for the user's recoil") + end +end + +do + local battle = newBattle() + battle.enemy.substituteHP = 1 + local pStart = battle.player.mon.hp + battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 }) + T.eq(battle.enemy.substituteHP, nil, "the hit breaks the substitute") + T.eq(battle.player.mon.hp, pStart, "no recoil after breaking a substitute") + T.check(not find(rowsOf(battle), "text:recoil"), "no recoil text after breaking a substitute") +end + +do + local battle = newBattle() + battle.player.mon.hp = 10 + battle.player.shownHP = 10 + battle.player.shownPx = nil + battle:performMove(battle.player, battle.enemy, { id = "FIX_ABSORB", pp = 20 }) + T.check(battle.player.mon.hp > 10, "absorb heals the user") + local rows = rowsOf(battle) + local enemyRow, playerRow = find(rows, "drain:enemy"), find(rows, "drain:player") + T.check(enemyRow and playerRow and enemyRow.i < playerRow.i, + "the user's heal has its own row after the target's") + if enemyRow then + replay(battle, enemyRow.row) + T.eq(battle.player.shownHP, 10, "the user's bar holds still during the target's row") + end + if playerRow then + replay(battle, playerRow.row) + T.eq(battle.player.shownHP, battle.player.mon.hp, "the user's bar settles on its row") + end +end + +do + local battle = newBattle() + battle.player.mon.hp = 10 + battle.enemy.substituteHP = 1 + battle:performMove(battle.player, battle.enemy, { id = "FIX_ABSORB", pp = 20 }) + T.eq(battle.player.mon.hp, 10, "no heal after breaking a substitute") +end + +Data.moves.FIX_RECOIL, Data.moves.FIX_ABSORB = nil, nil +T.finish("recoil hp drain") diff --git a/tests/engine/route23_go_right_ahead_bug2649.lua b/tests/engine/route23_go_right_ahead_bug2649.lua new file mode 100644 index 00000000..45b5236f --- /dev/null +++ b/tests/engine/route23_go_right_ahead_bug2649.lua @@ -0,0 +1,223 @@ +-- scripts/Route23.asm:195 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local played = {} +package.loaded["src.core.Sound"] = { + play = function(_, id) played[#played + 1] = id; return nil end, +} + +local OW = require("src.world.OverworldController") +local RealTextBox = require("src.render.TextBox") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local OH = "You can pass here\nonly if you have\vthe {RAM:wNameBuffer}!\fOh! That is the\n{RAM:wNameBuffer}!{DONE}" +local GO = "\fOK then! Please,\ngo right ahead!{DONE}" +local NO = "You can pass here\nonly if you have\vthe {RAM:wNameBuffer}!\fYou don't have the\n{RAM:wNameBuffer} yet!{DONE}" +local NO22 = "Only truly skilled\ntrainers are\vallowed through.\fYou don't have the\nBOULDERBADGE yet!{DONE}" +local RULES = "\fThe rules are\nrules. I can't\vlet you pass.{DONE}" + +local pushed +local textBoxStub = setmetatable({ + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +}, { __index = RealTextBox }) +local fakeGame = { + data = { + text = { + _Route23OhThatIsTheBadgeText = OH, + _Route23GoRightAheadText = GO, + _Route23YouDontHaveTheBadgeYetText = NO, + _Route22GateGuardNoBoulderbadgeText = NO22, + _Route22GateGuardICantLetYouPassText = RULES, + }, + items = { CASCADEBADGE = { name = "CASCADEBADGE" } }, + field = { badgeGates = { + ROUTE_23 = { + passText = "Route23OhThatIsTheBadgeText", + failText = "Route23YouDontHaveTheBadgeYetText", + guards = { { y = 96, badge = "CASCADEBADGE", event = "EVENT_PASSED_CASCADEBADGE_CHECK" } }, + }, + ROUTE_22_GATE = { + badge = "BOULDERBADGE", + passText = "Route22GateGuardGoRightAheadText", + failText = "Route22GateGuardNoBoulderbadgeText", + coords = { { x = 4, y = 2 } }, + }, + } }, + }, + save = { inventory = { CASCADEBADGE = 1 }, flags = {} }, + stack = { push = function(_, box) pushed = box end }, +} +T.check(setUpvalue(OW.checkBadgeGate, "TextBox", textBoxStub), + "TextBox upvalue on checkBadgeGate") +T.check(setUpvalue(OW.checkBadgeGate, "Game", fakeGame), + "Game upvalue on checkBadgeGate") +T.check(type(OW.route23BadgeCheck) == "function", "route23BadgeCheck shared helper exists") +if OW.route23BadgeCheck then + setUpvalue(OW.route23BadgeCheck, "TextBox", textBoxStub) + setUpvalue(OW.route23BadgeCheck, "Game", fakeGame) +end + +local moves = {} +local function newOw(x, y, mapId) + return setmetatable({ + player = { cellX = x, cellY = y }, + map = { id = mapId or "ROUTE_23" }, + scriptMove = function(_, entity, dir, tiles, onDone, opts) + moves[#moves + 1] = { entity = entity, dir = dir, tiles = tiles, + onDone = onDone, collide = opts and opts.collide } + end, + }, { __index = OW }) +end + +local function checkPassBox(label) + local text = pushed and pushed.text or "" + local ohAt = text:find("Oh! That is the\nCASCADEBADGE!", 1, true) + local pauseAt = text:find(RealTextBox.PAUSE, 1, true) + local goAt = text:find("OK then! Please,\ngo right ahead!", 1, true) + T.check(ohAt ~= nil, label .. ": badge line present") + T.check(goAt ~= nil, label .. ": go right ahead text follows the badge text") + T.check(ohAt and pauseAt and goAt and ohAt < pauseAt and pauseAt < goAt, + label .. ": fanfare mark sits between the badge line and the go-ahead page") + T.check(not text:sub(1, (goAt or 1) - 1):find("{DONE}", 1, true), + label .. ": badge text DONE stripped before the go-ahead page") + local opts = pushed and pushed.opts or {} + T.eq(opts.pauseSounds and opts.pauseSounds[1], "Get_Item1", + label .. ": Get_Item1 rides the mid-string mark") + T.eq(opts.pauseSoundWait, true, label .. ": fanfare waits like TextCommand_SOUND") + local pages = RealTextBox.paginate((text:gsub(RealTextBox.PAUSE, ""))) + T.eq(#pages, 3, label .. ": pass text is three pages") +end + +local function checkFailBox(label) + local text = pushed and pushed.text or "" + T.check(text:find("You don't have the\nCASCADEBADGE yet!", 1, true) ~= nil, + label .. ": fail text shown") + T.eq(#played, 0, label .. ": no SFX_DENIED before the text") + local opts = pushed and pushed.opts or {} + T.check(opts.auto and type(opts.auto.sound) == "function", + label .. ": SFX_DENIED armed as the trailing text_asm sound") + T.eq(opts.auto and opts.auto.wait, true, label .. ": button wait after the sound") + if opts.auto and opts.auto.sound then opts.auto.sound() end + T.eq(played[1], "Denied", label .. ": the trailing sound is SFX_DENIED") + T.check(pushed and type(pushed.onDone) == "function", + label .. ": the box closes into Route23MovePlayerDownScript") +end + +pushed, played, moves = nil, {}, {} +local self_ = newOw(10, 96) +T.eq(self_:checkBadgeGate(), false, "badge holder walks on") +T.check(pushed ~= nil, "pass text box pushed") +checkPassBox("step") +T.eq(fakeGame.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK ~= nil, true, "pass flag set") + +pushed, played, moves = nil, {}, {} +fakeGame.save = { inventory = {}, flags = {} } +self_ = newOw(10, 96) +T.eq(self_:checkBadgeGate(), true, "no badge: the step is blocked") +T.check(pushed ~= nil, "fail text box pushed") +checkFailBox("step") +if pushed and pushed.onDone then pushed.onDone() end +T.eq(#moves, 1, "step fail: one scripted move after the box") +T.eq(moves[1] and moves[1].dir, "down", "step fail: the move is down") +T.eq(moves[1] and moves[1].tiles, 1, "step fail: one tile") +T.eq(moves[1] and moves[1].collide, true, "step fail: simulated d-pad collides") +T.eq(moves[1] and moves[1].entity, self_.player, "step fail: the player walks") +T.eq(fakeGame.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, nil, "no pass flag without the badge") + +local R23 = require("data.scripts.flavor.route_23") +local talk = R23.ROUTE_23.talk +local expect = { + TEXT_ROUTE23_GUARD1 = { "EARTHBADGE", "EVENT_PASSED_EARTHBADGE_CHECK" }, + TEXT_ROUTE23_GUARD2 = { "VOLCANOBADGE", "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + TEXT_ROUTE23_GUARD3 = { "RAINBOWBADGE", "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + TEXT_ROUTE23_GUARD4 = { "THUNDERBADGE", "EVENT_PASSED_THUNDERBADGE_CHECK" }, + TEXT_ROUTE23_GUARD5 = { "CASCADEBADGE", "EVENT_PASSED_CASCADEBADGE_CHECK" }, + TEXT_ROUTE23_SWIMMER1 = { "MARSHBADGE", "EVENT_PASSED_MARSHBADGE_CHECK" }, + TEXT_ROUTE23_SWIMMER2 = { "SOULBADGE", "EVENT_PASSED_SOULBADGE_CHECK" }, +} +for id, want in pairs(expect) do + local badge, flag = want[1], want[2] + T.eq(type(talk[id]), "function", id .. " is a Lua talk handler") + if type(talk[id]) == "function" then + fakeGame.data.items[badge] = { name = badge } + pushed, played, moves = nil, {}, {} + fakeGame.save = { inventory = { [badge] = 1 }, flags = {} } + local doneCalls = 0 + local ow = newOw(7, 136) + talk[id](fakeGame, ow, {}, function() doneCalls = doneCalls + 1 end) + T.check(pushed ~= nil, id .. " pass: one box pushed") + local text = pushed and pushed.text or "" + T.check(text:find("Oh! That is the\n" .. badge .. "!", 1, true) ~= nil, + id .. " pass: badge line names " .. badge) + local pauseAt = text:find(RealTextBox.PAUSE, 1, true) + local goAt = text:find("OK then! Please,\ngo right ahead!", 1, true) + T.check(pauseAt and goAt and pauseAt < goAt, id .. " prints go-ahead after the badge text") + local opts = pushed and pushed.opts or {} + T.eq(opts.pauseSounds and opts.pauseSounds[1], "Get_Item1", id .. " pass: Get_Item1 on the mark") + T.eq(opts.pauseSoundWait, true, id .. " pass: fanfare waits") + T.eq(fakeGame.save.flags[flag] ~= nil, true, id .. " sets " .. flag) + T.eq(#played, 0, id .. " pass: nothing played before the box") + if pushed and pushed.onDone then pushed.onDone() end + T.eq(doneCalls, 1, id .. " pass: done fires when the box closes") + T.eq(#moves, 0, id .. " pass: no scripted move") + + pushed, played, moves = nil, {}, {} + fakeGame.save = { inventory = {}, flags = {} } + doneCalls = 0 + ow = newOw(7, 136) + talk[id](fakeGame, ow, {}, function() doneCalls = doneCalls + 1 end) + T.check(pushed ~= nil, id .. " fail: box pushed") + text = pushed and pushed.text or "" + T.check(text:find("You don't have the\n" .. badge .. " yet!", 1, true) ~= nil, + id .. " fail: text names " .. badge) + T.eq(#played, 0, id .. " fail: no SFX_DENIED before the text") + opts = pushed and pushed.opts or {} + T.check(opts.auto and type(opts.auto.sound) == "function", + id .. " fail: SFX_DENIED armed after the text") + if opts.auto and opts.auto.sound then opts.auto.sound() end + T.eq(played[1], "Denied", id .. " fail: trailing sound is SFX_DENIED") + T.eq(doneCalls, 0, id .. " fail: done waits for the walk") + if pushed and pushed.onDone then pushed.onDone() end + T.eq(#moves, 1, id .. " fail: Route23MovePlayerDownScript queued") + T.eq(moves[1] and moves[1].dir, "down", id .. " fail: walks down") + T.eq(moves[1] and moves[1].collide, true, id .. " fail: simulated d-pad collides") + T.check(moves[1] and type(moves[1].onDone) == "function", + id .. " fail: move carries an onDone") + if moves[1] and moves[1].onDone then moves[1].onDone() end + T.eq(doneCalls, 1, id .. " fail: done fires after the walk") + T.eq(fakeGame.save.flags[flag], nil, id .. " fail: no pass flag") + end +end + +pushed, played, moves = nil, {}, {} +fakeGame.save = { inventory = {}, flags = {} } +self_ = newOw(4, 2, "ROUTE_22_GATE") +T.eq(self_:checkBadgeGate(), true, "Route 22 gate: no badge blocks the step") +T.check(pushed ~= nil, "Route 22 gate: fail box pushed") +local text22 = pushed and pushed.text or "" +local noAt = text22:find("BOULDERBADGE yet!", 1, true) +local pause22 = text22:find(RealTextBox.PAUSE, 1, true) +local rulesAt = text22:find("The rules are", 1, true) +T.check(noAt and pause22 and rulesAt and noAt < pause22 and pause22 < rulesAt, + "Route 22 gate: Denied mark sits between the two texts") +T.eq(#played, 0, "Route 22 gate: no SFX_DENIED before the text") +local opts22 = pushed and pushed.opts or {} +T.eq(opts22.pauseSounds and opts22.pauseSounds[1], "Denied", "Route 22 gate: mark plays SFX_DENIED") +T.eq(opts22.pauseSoundWait, true, "Route 22 gate: sound waits") +T.check(not text22:sub(1, (rulesAt or 1) - 1):find("{DONE}", 1, true), + "Route 22 gate: first text DONE stripped") + +T.finish("route23_go_right_ahead_bug2649") diff --git a/tests/engine/textbox_arrow_cell_bug2674.lua b/tests/engine/textbox_arrow_cell_bug2674.lua new file mode 100644 index 00000000..5a85693c --- /dev/null +++ b/tests/engine/textbox_arrow_cell_bug2674.lua @@ -0,0 +1,116 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local Font = require("src.render.Font") +Font.load({ font = require("tests.fixture_data.font") }) +local Theme = require("src.ui.Theme") +local TextBox = require("src.render.TextBox") +local Version = require("src.core.GameVersion") +local Timing = require("src.core.Timing") +package.loaded["src.core.Sound"] = { play = function() end, playPress = function() end } + +local ARROW = Theme.moreArrow or 0xEE +local SPACE = 0x7F +local LINE = "ENTIRELY COMPLETEZ" +local HEAD = "YOUR DEX IS" + +local function make(text, save) + local pressed = false + local game = { + data = {}, save = save or { options = { textSpeed = 1 } }, + input = { wasPressed = function(_, key) return pressed == key end, + isDown = function() return false end }, + stack = { pop = function() end }, + } + local box = TextBox.new(game, text, function() end) + return box, function(key) + pressed = key or false + box:update(1 / 60) + pressed = false + end +end + +local function prompt(box, tick) + for _ = 1, 400 do + if box.waiting or box.done then return true end + tick() + end + return false +end + +local function drawn(box, blink) + local drawCode, drawBox = Font.drawCode, Font.drawBox + local out = {} + Font.drawCode = function(code, x, y) + out[#out + 1] = { code = code, x = x, y = y } + end + Font.drawBox = function() end + local saved = box.blink + box.blink = blink + box:draw() + box.blink = saved + Font.drawCode, Font.drawBox = drawCode, drawBox + return out +end + +local function at(list, x, y) + local codes = {} + for _, g in ipairs(list) do + if g.x == x and g.y == y then codes[#codes + 1] = g.code end + end + return codes +end + +local bang = Font.encode("Z")[1] + +local function has(codes, code) + for _, c in ipairs(codes) do + if c == code then return true end + end + return false +end + +-- home/text.asm:262 +for _, edition in ipairs({ "red", "blue", "yellow" }) do + Version.set(edition) + local box, tick = make(HEAD .. "\n" .. LINE .. "\vCONGRATULATIONS") + T.check(prompt(box, tick) and box.contAdvance, edition .. " reaches the cont wait") + local ax, ay = box:arrowPos() + T.eq(#box.shown[2], 18, edition .. " bottom line is 18 tiles") + local lit = at(drawn(box, 0), ax, ay) + T.same(lit, { ARROW }, edition .. " arrow replaces the 18th glyph while lit") + local dark = at(drawn(box, 30), ax, ay) + T.same(dark, {}, edition .. " the cell is blank while the arrow blinks off") + for _ = 1, Timing.TEXT_PRE_ADVANCE do tick() end + tick("a") + T.eq(box.shown[1][18], SPACE, edition .. " cont leaves a space in the scrolled line") + T.eq(box.shown[1][17], Font.encode(LINE)[17], edition .. " the 17th glyph survives") + for _ = 1, Timing.TEXT_SCROLL_PAIR do tick() end + T.check(prompt(box, tick) and box.done, edition .. " completes") + local after = drawn(box, 30) + T.check(not has(at(after, ax, box.line1Y), bang), + edition .. " scrolled line keeps no 18th glyph") +end + +Version.set("red") +local plain, plainTick = make("SHORT LINE\nHERE") +T.check(prompt(plain, plainTick) and plain.done, "short page completes") +local ax, ay = plain:arrowPos() +local list = drawn(plain, 0) +T.same(at(list, ax, ay), { ARROW }, "short line still draws the arrow") +T.eq(#plain.shown[2], 4, "short line untouched") + +Version.set("gold") +local gold, goldTick = make(HEAD .. "\n" .. LINE .. "\vNEXT", + { generation = 2, options = { textSpeed = 1 } }) +T.check(prompt(gold, goldTick) and gold.contAdvance, "gold reaches cont") +local gx = gold:arrowPos() +local gglyphs = at(drawn(gold, 0), gx, gold.line2Y) +T.same(gglyphs, { bang }, "gold keeps the glyph above its border-row arrow") +for _ = 1, Timing.TEXT_PRE_ADVANCE do goldTick() end +goldTick("a") +T.eq(gold.shown[#gold.shown - 1][18], bang, "gold cont keeps the glyph") +Version.set("red") + +T.finish("textbox_arrow_cell_bug2674") diff --git a/tests/engine/warp_fade_obp0_identity_bug2689.lua b/tests/engine/warp_fade_obp0_identity_bug2689.lua new file mode 100644 index 00000000..f286b779 --- /dev/null +++ b/tests/engine/warp_fade_obp0_identity_bug2689.lua @@ -0,0 +1,87 @@ +-- home/fade.asm:67 + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +T.fixtures.fresh() + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local Transition = require("src.render.Transition") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local function getUpvalue(fn, name) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return v end + i = i + 1 + end +end + +local prevVersion, prevMode, prevDark = GameVersion.get(), PaletteFX.mode, PaletteFX.darkWorld() +local prevGame = getUpvalue(OW.warpFade, "Game") +local realSetFadeObp = PaletteFX.setFadeObp + +local fade +local game = { stack = { top = function() return fade end, pop = function() end }, + worldBgBattleInStack = function() return false end } +check(setUpvalue(OW.warpFade, "Game", game), "warpFade reads the Game upvalue") + +local function armedAt(t) + local armed, called = nil, false + PaletteFX.setFadeObp = function(map) armed, called = map, true; realSetFadeObp(map) end + fade = Transition.new(game, nil, nil, true) + fade.t = t + local self_ = setmetatable({}, { __index = OW }) + pcall(OW.drawWorld, self_) + PaletteFX.setFadeObp = realSetFadeObp + return armed, called, fade +end + +for _, version in ipairs({ "red", "blue" }) do + GameVersion.set(version) + PaletteFX.setMode("ogred") + PaletteFX.setDarkWorld(false) + local base = PaletteFX.ogObjBase() + + for step = 0, 3 do + local map, called, f = armedAt(step * 8) + local obp = f:obp0() + check(called, ("%s step %d drawWorld arms the fade OBP"):format(version, step)) + check(map ~= nil, ("%s step %d rOBP0 $%02X arms a fade map"):format(version, step, obp)) + end + + local map, _, f = armedAt(8) + eq(f:obp0(), 0xE4, version .. " FadePal3 is the black-out second step") + check(map ~= nil and map[0] == 0 and map[1] == 1 and map[2] == 2 and map[3] == 3, + version .. " FadePal3 rOBP0 $E4 arms the identity map") + local colors = PaletteFX.ogObjWorld() + check(colors[2][1] == base[2][1] and colors[2][2] == base[2][2] + and colors[3][1] == base[3][1] and colors[3][2] == base[3][2], + version .. " FadePal3 step bakes the raw $E4 ramp, not the $D0 lift") + PaletteFX.setFadeObp(nil) +end + +PaletteFX.setFadeObp = realSetFadeObp +PaletteFX.setFadeObp(nil) +setUpvalue(OW.warpFade, "Game", prevGame) +PaletteFX.setDarkWorld(prevDark) +GameVersion.set(prevVersion) +PaletteFX.setMode(prevMode) + +T.finish("warp fade rOBP0 identity #2689") diff --git a/tests/game3_pause_and_main_menu_exit_test.lua b/tests/game3_pause_and_main_menu_exit_test.lua index eabfbfce..c68f1038 100644 --- a/tests/game3_pause_and_main_menu_exit_test.lua +++ b/tests/game3_pause_and_main_menu_exit_test.lua @@ -331,4 +331,33 @@ do Version.set(oldVersion) end +do + local RsMenu = require("src.ui.game3.rs.main_menu") + local Pal = require("src.core.game3.pal_fade") + local T3 = RsMenu.TYPE + eq(table.concat(RsMenu.items(T3.HAS_NO_SAVED_GAME), ","), "NEW_GAME,OPTION,EXIT", "RS main menu lists EXIT under OPTION") + eq(table.concat(RsMenu.items(T3.HAS_SAVED_GAME), ","), "CONTINUE,NEW_GAME,OPTION,EXIT", "RS continue menu lists EXIT") + eq(table.concat(RsMenu.items(T3.HAS_MYSTERY_EVENT), ","), "CONTINUE,NEW_GAME,MYSTERY_EVENTS,OPTION,EXIT", + "RS mystery events menu lists EXIT last") + local items = RsMenu.items(T3.HAS_MYSTERY_EVENT) + local m = setmetatable({ menuType = T3.HAS_MYSTERY_EVENT, items = items, cursor = 1, scroll = 0, blink = 0, + pal = Pal.new(), state = "input" }, RsMenu) + for _ = 1, #items - 1 do + m:frame({ new = { down = true } }) + m.state = "input" + end + eq(m.cursor, #items, "RS cursor reaches EXIT") + check(m.scroll > 0, "RS menu scrolls so EXIT stays on screen") + local w = RsMenu.windowFor(m.menuType, m.cursor) + check((w.top + w.height + 1) * 8 - m.scroll <= 160, "RS EXIT window fits after scroll") + m.state = "pressed_a" + local r = m:frame({ new = {} }) + eq(type(r) == "table" and r.action, "exit", "A on RS EXIT returns to the launcher") +end + +do + local Data = require("src.ui.game3.rs.start_menu_data") + check(Data.exitConfirms == true, "RS start menu EXIT offers return to title like Emerald") +end + T.finish("game3_pause_and_main_menu_exit_test") diff --git a/tests/gen2_crystal_extras_test.lua b/tests/gen2_crystal_extras_test.lua index d30c3b75..9e6efd71 100644 --- a/tests/gen2_crystal_extras_test.lua +++ b/tests/gen2_crystal_extras_test.lua @@ -3,6 +3,7 @@ -- ROM-free: -- luajit tests/gen2_crystal_extras_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path +require("src.core.GameVersion").set("crystal") -- The same love stub tests/gen2_menus_test.lua installs; nothing here draws. love = love or {} @@ -569,4 +570,5 @@ do check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too") end +require("src.core.GameVersion").set("red") S.finish() diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0d4d7f5a..80261564 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -978,7 +978,7 @@ do local fgame = { data = {}, stack = newStack(), input = newInput(), save = { player = {} } } local fspeech = OakSpeech.new(fgame, nil) - fspeech.shrink = { frame = 103 } -- past the shrink timeline's end + fspeech.shrink = { frame = OakSpeech.SHRINK_END } -- past the shrink timeline's end fgame.stack:push(fspeech) fspeech.picReveal = nil -- skip the intro fade so update reaches shrink local finishes = 0 diff --git a/tests/parity_B.lua b/tests/parity_B.lua index 8f530ee3..03ac64e9 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -138,10 +138,10 @@ local champGame = { save = { flags = {} } } champ.onEnter(champGame, champOw) eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script") local walk = champQueued[1] and champQueued[1].script -check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1 +check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 3 and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1 - and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3, - "entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)") + and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 1, + "entrance walk is RivalEntrance_RLEMovement (up 3, right 1, up 1)") check(champQueued[2] and champQueued[2].script == rows, "second queue is TEXT_CHAMPIONSROOM_RIVAL script rows") check(champQueued[2] and champQueued[2].extra diff --git a/tests/parity_battle_intro_chrome.lua b/tests/parity_battle_intro_chrome.lua index 37532314..7c3ceb79 100644 --- a/tests/parity_battle_intro_chrome.lua +++ b/tests/parity_battle_intro_chrome.lua @@ -94,13 +94,14 @@ local wild = BattleState.newWild(game, "RATTATA", 2) wild.onFinish = function() end wild:enter() -eq(wild.introBalls, true, "the wild intro opens the DrawAllPokeballs window") --- introBalls is a plain field, not a queue row, so it cannot displace the --- wild cry PrintBeginningBattleText plays before PrintText (#303) +check(wild.introBalls ~= true, "the DrawAllPokeballs window is not open at enter") +-- engine/battle/common_text.asm:11 check(wild.queue[1] and wild.queue[1].fn ~= nil, "the cry act is still the first queue row (#303)") -check(wild.queue[2] and wild.queue[2].text == wild.introText, - "the intro text is still the second queue row") +check(wild.queue[2] and wild.queue[2].fn ~= nil, + "DrawAllPokeballs is the second queue row") +check(wild.queue[3] and wild.queue[3].text == wild.introText, + "the intro text is the third queue row") -- let the silhouette slide land so the intro box is genuinely up (the slide -- now runs 80 frames at 2px/frame, matching the original ~2px/frame) @@ -174,8 +175,9 @@ local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) }) local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1) tr.onFinish = function() end tr:enter() +check(tr.introBalls ~= true, "the trainer window is not open at enter") +for _ = 1, 140 do tr:update(1 / 60) end eq(tr.introBalls, true, "the trainer intro opens the same window") -for _ = 1, 85 do tr:update(1 / 60) end local ok3, err3, rows3 = snapshotHUD(tr) check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3)) diff --git a/tests/parity_e4_after.lua b/tests/parity_e4_after.lua index d8521d62..c4f70d56 100644 --- a/tests/parity_e4_after.lua +++ b/tests/parity_e4_after.lua @@ -20,18 +20,21 @@ local CASES = { after = "_LoreleisRoomLoreleiAfterBattleText", headerMap = "LoreleisRoom", event = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", npcName = "LORELEISROOM_LORELEI", class = "OPP_LORELEI", + closed = 0x24, open = 0x05, }, { map = "BRUNOS_ROOM", text = "TEXT_BRUNOSROOM_BRUNO", after = "_BrunoAfterBattleText", headerMap = "BrunosRoom", event = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", npcName = "BRUNOSROOM_BRUNO", class = "OPP_BRUNO", + closed = 0x24, open = 0x05, }, { map = "AGATHAS_ROOM", text = "TEXT_AGATHASROOM_AGATHA", after = "_AgathaAfterBattleText", headerMap = "AgathasRoom", event = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", npcName = "AGATHASROOM_AGATHA", class = "OPP_AGATHA", + closed = 0x3b, open = 0x0e, }, } @@ -64,23 +67,45 @@ for _, c in ipairs(CASES) do facePlayer = function() end, } local engaged = false + local blocks = {} + local blocksAtPush + game.stack.push = function(_, state) + pushed[#pushed + 1] = state + blocksAtPush = blocksAtPush or #blocks + end local ow = { + player = { cellY = 5 }, trainerDefeated = function(_, n) return game.save.defeatedTrainers[n.id] == true end, - engageTrainer = function(_, n, onDone) + replaceBlock = function(_, bx, by, id) + blocks[#blocks + 1] = { bx, by, id } + end, + engageTrainer = function(self, n, onDone) engaged = n == npc game.save.defeatedTrainers[n.id] = true game.save.flags[c.event] = true + local hooks = mapScripts.get(c.map) + if hooks and hooks.onVictory then hooks.onVictory(game, self) end if onDone then onDone() end end, } - talk(game, ow, npc, function() end) + local finished = false + talk(game, ow, npc, function() finished = true end) check(engaged, c.map .. " engages on first talk") check(#pushed == 1 and pushed[1].pages ~= nil, c.map .. " win pushes after-battle TextBox") check(#pushed[1].pages > 0, c.map .. " after-battle TextBox has pages") + eq(blocksAtPush, 0, c.map .. " exit stays shut while after-battle text is up") + check(not finished, c.map .. " talk not done before after-battle text closes") + pushed[1].onDone() + eq(#blocks, 1, c.map .. " exit block swapped once after the text") + local b = blocks[1] or {} + check(b[1] == 2 and b[2] == 0 and b[3] == c.open, + c.map .. " exit opens at block (2,0) after the text") + check(finished, c.map .. " talk done after after-battle text closes") + game.stack.push = function(_, state) pushed[#pushed + 1] = state end -- loss: no after text pushed, engaged = {}, false @@ -104,6 +129,17 @@ for _, c in ipairs(CASES) do talk(game, ow, npc, function() end) check(#pushed == 1 and pushed[1].pages ~= nil, c.map .. " defeated re-talk shows after text") + + local hooks = mapScripts.get(c.map) + check(hooks.onVictory == nil, c.map .. " has no onVictory door swap") + blocks = {} + game.save.flags = { [c.event] = true } + hooks.onEnter(game, ow) + check(blocks[1] and blocks[1][3] == c.open, c.map .. " reload after win opens exit") + blocks = {} + game.save.flags = {} + hooks.onEnter(game, ow) + check(blocks[1] and blocks[1][3] == c.closed, c.map .. " reload before win keeps exit shut") end S.finish() diff --git a/tests/parity_party_hp_bar_palette.lua b/tests/parity_party_hp_bar_palette.lua index 2efd7cf2..6e8cc5dc 100644 --- a/tests/parity_party_hp_bar_palette.lua +++ b/tests/parity_party_hp_bar_palette.lua @@ -100,9 +100,7 @@ for _, mode in ipairs({ "gbc", "redpp" }) do eq(z.colors, want, ("row %d (%d/48 HP) carries the %s block palette%s") :format(i, l.hp, l.pal, tag)) - -- 05,YY - 11,YY shifted one tile right, because this port draws the bar at - -- tile 5 where party_menu.asm:71-76 draws it at 4; still cap + six fill - eq(z.x, 48, ("row %d bar block starts at tile 6%s"):format(i, tag)) + eq(z.x, 40, ("row %d bar block starts at tile 5%s"):format(i, tag)) eq(z.w, 56, ("row %d bar block is seven tiles wide%s"):format(i, tag)) eq(z.y, (i * 2 - 1) * 8, ("row %d bar block sits on its HP row%s"):format(i, tag)) eq(z.h, 8, ("row %d bar block is one tile tall%s"):format(i, tag)) @@ -161,7 +159,7 @@ do eq(bar.gray, true, ("row %d draws a gray fill when a zone pass will run") :format(i)) -- the placement the sgbPalettes rects above are keyed to - eq(bar.tx, 5, ("row %d bar starts at tile 5"):format(i)) + eq(bar.tx, 4, ("row %d bar starts at tile 4"):format(i)) eq(bar.ty, i * 2 - 1, ("row %d bar sits on its HP row"):format(i)) -- wHPBarType 2: the party menu closes with the $6C nub, not the -- player-battle double bar (home/pokemon.asm DrawHPBar "Right") diff --git a/tests/parity_party_row_columns.lua b/tests/parity_party_row_columns.lua new file mode 100644 index 00000000..08c885fc --- /dev/null +++ b/tests/parity_party_row_columns.lua @@ -0,0 +1,126 @@ +-- engine/menus/party_menu.asm:14 +-- engine/menus/party_menu.asm:71 +-- engine/menus/party_menu.asm:93 +-- engine/menus/party_menu.asm:160 +-- engine/pokemon/status_screen.asm:46 +-- data/sgb/sgb_packets.asm:152 +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity party row columns") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local PaletteFX = require("src.render.PaletteFX") +local HudTiles = require("src.render.HudTiles") +local Strings = require("src.core.Strings") +local PartyMenu = require("src.ui.PartyMenu") +local Pokemon = require("src.pokemon.Pokemon") + +local NAME_COL = 3 +local BAR_COL = NAME_COL + 1 +local FRACTION_COL = BAR_COL + 9 +local LEARN_COL = NAME_COL + 9 + +local function makeGame(species) + local party = {} + for i, sp in ipairs(species) do + local mon = Pokemon.new(Data, sp, 20) + mon.stats.hp = 266 + mon.hp = 266 + party[i] = mon + end + return { + data = Data, + save = { party = party, options = {} }, + stack = { push = function() end, pop = function() end, + top = function() end }, + }, party +end + +local function capture(menu) + local bars, texts = {}, {} + local realBar, realDraw, realGfx = HudTiles.drawHPBar, Font.draw, love.graphics.draw + HudTiles.drawHPBar = function(data, tx, ty, mon, barType, grayFill, segments) + bars[#bars + 1] = { tx = tx, ty = ty, segments = segments } + end + Font.draw = function(text, x, y, ...) + texts[#texts + 1] = { text = text, x = x, y = y } + end + love.graphics.draw = function() end + local ok, err = pcall(function() menu:draw() end) + HudTiles.drawHPBar, Font.draw, love.graphics.draw = realBar, realDraw, realGfx + check(ok, "PartyMenu:draw runs headless" .. (ok and "" or (": " .. tostring(err)))) + return bars, texts +end + +local prevMode = PaletteFX.mode + +do + local game, party = makeGame({ "NIDOKING", "BULBASAUR", "PIDGEY" }) + local bars, texts = capture(PartyMenu.new(game, {})) + eq(#bars, #party, "one HP bar per party row") + for i, bar in ipairs(bars) do + eq(bar.tx, BAR_COL, ("row %d HP: tile sits one column right of the name"):format(i)) + eq(bar.ty, i * 2 - 1, ("row %d bar sits on its HP row"):format(i)) + local capCol = bar.tx + 2 + (bar.segments or 6) + check(capCol < FRACTION_COL, + ("row %d bar cap (col %d) ends before the fraction (col %d)") + :format(i, capCol, FRACTION_COL)) + end + local fractions = 0 + for _, t in ipairs(texts) do + if type(t.text) == "string" and t.text:match("^%s*%d+/%s*%d+$") then + fractions = fractions + 1 + eq(t.x, FRACTION_COL * 8, "HP fraction prints nine columns right of the bar") + elseif t.text == party[1].nickname or t.text == Data.pokemon.NIDOKING.name then + eq(t.x, NAME_COL * 8, "nickname prints at column 3") + end + end + eq(fractions, #party, "one HP fraction per party row") + + for _, mode in ipairs({ "gbc", "redpp" }) do + PaletteFX.setMode(mode) + local zones = PartyMenu.new(game, {}):sgbPalettes(game) or {} + local tag = " [" .. PaletteFX.modeLabel(mode) .. "]" + for i = 1, #party do + local z = zones[2 + i] or {} + eq(z.x, 5 * 8, ("row %d bar block starts at the packet's tile 5%s"):format(i, tag)) + eq(z.w, (11 - 5 + 1) * 8, ("row %d bar block ends at the packet's tile 11%s"):format(i, tag)) + eq(z.x, (BAR_COL + 1) * 8, ("row %d bar block starts on the bar's ':' tile%s"):format(i, tag)) + end + end +end + +local function learnRows(opts, species) + local game = makeGame(species) + local _, texts = capture(PartyMenu.new(game, opts)) + local able, notAble = Strings("ABLE"), Strings("NOT ABLE") + local seen = { [able] = 0, [notAble] = 0 } + for _, t in ipairs(texts) do + if t.text == able or t.text == notAble then + seen[t.text] = seen[t.text] + 1 + eq(t.x, LEARN_COL * 8, ("%q prints at column 12"):format(t.text)) + end + end + return seen[able], seen[notAble] +end + +do + local a, n = learnRows({ tmhm = { move = "SWORDS_DANCE", kind = "TM" } }, + { "BULBASAUR", "MAGIKARP" }) + eq(a, 1, "TM list shows one ABLE row") + eq(n, 1, "TM list shows one NOT ABLE row") +end + +do + local a, n = learnRows({ evoStone = "THUNDER_STONE" }, { "PIKACHU", "RATTATA" }) + eq(a, 1, "stone list shows one ABLE row") + eq(n, 1, "stone list shows one NOT ABLE row") +end + +PaletteFX.setMode(prevMode) +S.finish() diff --git a/tests/parity_rival_end_text_bug2660.lua b/tests/parity_rival_end_text_bug2660.lua new file mode 100644 index 00000000..ab61ed6f --- /dev/null +++ b/tests/parity_rival_end_text_bug2660.lua @@ -0,0 +1,58 @@ +-- scripts/ChampionsRoom.asm:65 +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity rival end text") +local check = S.check + +local init = require("data.scripts.init") +local champ = init.get("CHAMPIONS_ROOM") +local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL or {} + +local found, armed = false, false +for i, r in ipairs(rows) do + if r[1] == "rival_battle" and r[2] == "OPP_RIVAL3" then + found = true + local p = rows[i - 1] + armed = p ~= nil and p[1] == "save_end_battle_text" and p[2] == "_RivalDefeatedText" + end +end +check(found, "champion script has rival_battle OPP_RIVAL3") +check(armed, "champion arms _RivalDefeatedText right before rival_battle OPP_RIVAL3") + +local text = Data.text and Data.text._RivalDefeatedText +if text then + check(text:find("NO!", 1, true) ~= nil, "_RivalDefeatedText is the cart's champion loss line") +end + +local SKIP = { play_music = true, set_option = true } +local seen, offenders, total = {}, {}, 0 +local function walk(t, path) + if type(t) ~= "table" or seen[t] then return end + seen[t] = true + for i, r in ipairs(t) do + if type(r) == "table" and r[1] == "rival_battle" then + total = total + 1 + local j = i - 1 + while t[j] and type(t[j]) == "table" and SKIP[t[j][1]] do j = j - 1 end + local p = t[j] + if not (type(p) == "table" and p[1] == "save_end_battle_text") then + offenders[#offenders + 1] = path .. "[" .. i .. "] " .. tostring(r[2]) + end + end + end + for k, v in pairs(t) do walk(v, path .. "." .. tostring(k)) end +end + +for _, mod in ipairs({ "story", "story2", "story3", "story4", "story5", + "story6", "story7", "flavor_all", "gyms", + "oaks_lab", "oaks_lab_yellow" }) do + local ok, m = pcall(require, "data.scripts." .. mod) + if ok then walk(m, mod) end +end +check(total > 0, "sweep found scripted rival battles") +check(#offenders == 0, "every scripted rival_battle arms its loss line: " + .. table.concat(offenders, ", ")) + +S.finish() diff --git a/tests/quick.list b/tests/quick.list index 5db9ca07..74e529dd 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -14,6 +14,7 @@ tests/engine/audio_device_reset.lua tests/engine/bag_bicycle_no_options_bug1705.lua tests/engine/bag_cancel_row_bug1685.lua tests/engine/bag_item_box_bug1521.lua +tests/engine/bag_split_stack_bug2671.lua tests/engine/bag_use_toss_hollow_cursor_2063.lua tests/engine/ball_toss_sound_sync_bug2611.lua tests/engine/battle_ai_item_messages_2589.lua @@ -28,6 +29,7 @@ tests/engine/battle_fainted_message_romtext.lua tests/engine/battle_field_residual_validation.lua tests/engine/battle_fit_option.lua tests/engine/battle_fixed_menu_scale.lua +tests/engine/battle_intro_balls_after_cry_bug2662.lua tests/engine/battle_learn_move_page_bug2540.lua tests/engine/battle_levelup_order_bug1996.lua tests/engine/battle_menu_auxiliary.lua @@ -41,6 +43,7 @@ tests/engine/bike_shop_displays_bug1530.lua tests/engine/bills_pc_changebox_bug2124.lua tests/engine/blues_house_daisy_walking_bug1338.lua tests/engine/blues_house_town_map_order_2523.lua +tests/engine/boulder_dust_obp1_bug2650.lua tests/engine/box_menu_message_pages_test.lua tests/engine/box_release_confirmation_romtext.lua tests/engine/buena_prize_box_bug1935.lua @@ -54,11 +57,13 @@ tests/engine/cart_saves.lua tests/engine/cart_store.lua tests/engine/caught_ball_obp_restore_bug2127.lua tests/engine/celadon_girl_drink_bug1955.lua +tests/engine/champion_entrance_walk_bug2666.lua tests/engine/chip_analog_path.lua tests/engine/chip_audio_preroll_test.lua tests/engine/chip_mono_fastpath_test.lua tests/engine/chip_sample_rate_test.lua tests/engine/confusion_selfhit_anim_bug1578.lua +tests/engine/continue_info_layout_2684.lua tests/engine/copyright_splash_backdrop.lua tests/engine/crystal_buena_cache_2639.lua tests/engine/crystal_credits_bug1967.lua @@ -82,6 +87,7 @@ tests/engine/ether_move_menu_2158.lua tests/engine/evo_stone_cancel_bug883_test.lua tests/engine/evo_stone_sfx_bug2072.lua tests/engine/evolution_dialogue_bug1596_test.lua +tests/engine/evolution_double_after_battle_bug2664.lua tests/engine/evolution_hold_b_bug968_test.lua tests/engine/evolution_timing_bug2033.lua tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -349,6 +355,7 @@ tests/engine/ghost_unveil_bug492.lua tests/engine/gift_gotmon_rows_2243.lua tests/engine/gift_jingle_sites_bug2555.lua tests/engine/give_item_jingle.lua +tests/engine/grass_feet_strip_2688.lua tests/engine/grass_palette_modes_2500.lua tests/engine/gym_badge_jingle_page_bug1982.lua tests/engine/gym_leader_end_battle_text_bug1606.lua @@ -365,6 +372,7 @@ tests/engine/host_shell_request_headers.lua tests/engine/hostshell_spawn_self.lua tests/engine/importer_worker_failure_cleanup.lua tests/engine/importers_asset_packs.lua +tests/engine/indigo_statues_bug2683.lua tests/engine/input_display_chord_test.lua tests/engine/input_dual_path_test.lua tests/engine/input_focus_reset_test.lua @@ -390,6 +398,9 @@ tests/engine/jessie_james_pic.lua tests/engine/jigglypuff_dance_gate_bug2335.lua tests/engine/kit_spinner_trail_bug2048.lua tests/engine/kit_transition.lua +tests/engine/lance_side_trigger_facing_bug2669.lua +tests/engine/lance_victory_music_bug2659.lua +tests/engine/lance_walk_in_bug2665.lua tests/engine/launch_options_flags_bug1657.lua tests/engine/launch_prelaunch_bug1657.lua tests/engine/launch_update_mods_bug2037.lua @@ -471,12 +482,14 @@ tests/engine/nurse_bow_bug995.lua tests/engine/nx_display_test.lua tests/engine/nx_yellow_boot_test.lua tests/engine/oak_intro_cry_bug2481.lua +tests/engine/oak_shrink_timing_2685.lua tests/engine/oaks_aide_requirement_bug1006.lua tests/engine/oaks_lab_dex_rating_bug600.lua tests/engine/oaks_lab_last_ball_bug601.lua tests/engine/oaks_lab_posters_email_bug1331_1333.lua tests/engine/oaks_lab_rival_faces_down_bug1279.lua tests/engine/oaks_lab_rival_paths_bug1989_bug1987.lua +tests/engine/oaks_lab_starter_ball_hide_2686.lua tests/engine/oaks_lab_starter_energetic_bug1334.lua tests/engine/oaks_lab_yellow_rival_loss_line_2121.lua tests/engine/oaks_lab_yellow_starter_bug1013.lua @@ -492,12 +505,14 @@ tests/engine/options_write_readback_bug828.lua tests/engine/orientation_option.lua tests/engine/overworld_field_faint_heal_romtext.lua tests/engine/overworld_hidden_item_romtext.lua +tests/engine/overworld_og_obp0_bug2689.lua tests/engine/overworld_slot_seat_over_hidden_coin.lua tests/engine/overworld_world_palette_seam_bug2152.lua tests/engine/pad_hints_bug2452_test.lua tests/engine/palette_test.lua tests/engine/party_battle_prompt_bug1901.lua tests/engine/party_fieldmove_order_bug792.lua +tests/engine/party_icon_anim_freeze_bug2682.lua tests/engine/party_icon_og_obj_bug2268.lua tests/engine/party_submenu_cancel_bug1833.lua tests/engine/pc_accessed_text_bug1529.lua @@ -545,9 +560,11 @@ tests/engine/push_battle_transition.lua tests/engine/quicksave_gate_bug2185.lua tests/engine/quicksave_gate_bug2561.lua tests/engine/quit_thread_shutdown.lua +tests/engine/rage_building_bug2654.lua tests/engine/rare_candy_bag_open_bug796.lua tests/engine/rby_translation_runtime_test.lua tests/engine/rebind_capture_bug510.lua +tests/engine/recoil_hp_drain_bug2653.lua tests/engine/rebind_joystick_bug632.lua tests/engine/rebind_swap_clear_bug589.lua tests/engine/refresh_rate_counters_test.lua @@ -564,6 +581,7 @@ tests/engine/rom_importer_nx_flags_test.lua tests/engine/rom_importer_source_tree_test.lua tests/engine/rom_pick_error_bug442.lua tests/engine/rom_pick_tv_cancel.lua +tests/engine/route23_go_right_ahead_bug2649.lua tests/engine/route24_rocket_bug1550.lua tests/engine/safari_gate_bug540.lua tests/engine/safe_area_units_test.lua @@ -632,6 +650,7 @@ tests/engine/teleport_escape_ends_turn.lua tests/engine/text_cont_single_row_bug2634.lua tests/engine/text_double_line_overwrite_bug2524.lua tests/engine/textbox_arrow_bug2084.lua +tests/engine/textbox_arrow_cell_bug2674.lua tests/engine/textbox_pause_bug1818.lua tests/engine/textbox_sfx_speed_bug2087_test.lua tests/engine/thrash_setup_anim_bug1532.lua @@ -679,6 +698,7 @@ tests/engine/vending_machine_bug1876.lua tests/engine/viridian_fisher_pre_bug775.lua tests/engine/vitamin_rose_text_sfx_bug2332.lua tests/engine/vsync_option.lua +tests/engine/warp_fade_obp0_identity_bug2689.lua tests/engine/warp_fade_palette_2180.lua tests/engine/warp_sprite_hidden_bug916.lua tests/engine/wave_channel_mix_bug429.lua diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index a92286e5..da5cc5b2 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -1634,7 +1634,8 @@ end do local interp = arg and arg[-1] or "luajit" - for _, suite in ipairs({ "tests/save_editor_gen3_tests.lua", "tests/save_editor_gen3_persistence_tests.lua" }) do + for _, suite in ipairs({ "tests/save_editor_gen3_tests.lua", "tests/save_editor_gen3_persistence_tests.lua", + "tests/save_editor_split_stack_bug2671_test.lua" }) do local r = os.execute(interp .. " " .. suite) check(r == true or r == 0, suite .. " passes") end diff --git a/tests/save_editor_split_stack_bug2671_test.lua b/tests/save_editor_split_stack_bug2671_test.lua new file mode 100644 index 00000000..7aa5a925 --- /dev/null +++ b/tests/save_editor_split_stack_bug2671_test.lua @@ -0,0 +1,249 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +love = require("tests.love_stub") + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor / .sav split stack tests (#2671) ==") + +require("src.core.GameVersion").set("red") +local Ops = require("Ops") +local State = require("State") +local Catalog = require("Catalog") +local Bag = require("src.inventory.Bag") +local SaveData = require("src.core.SaveData") +local GenSave = require("src.save_convert.GenSave") + +local Data = require("src.core.Data") +Data:load() +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local function red() + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + S.save.inventory, S.save.bagOrder, S.save.pcItems, S.save.pcOrder = {}, nil, {}, nil + return S +end + +local function split(S) + Bag.add(S.save, "X_ACCURACY", 99, S.data) + Bag.add(S.save, "POTION", 3, S.data) + Bag.add(S.save, "X_ACCURACY", 2, S.data) +end + +local function counts(rows) + local out = {} + for _, r in ipairs(rows) do out[#out + 1] = tostring(r.id) .. "x" .. tostring(r.count) end + return table.concat(out, ",") +end + +do + local S = red() + split(S) + eq(S.save.inventory.X_ACCURACY, 101, "fixture: 99 + 2 X ACCURACY totals 101") + eq(table.concat(S.save.bagOrder, ","), "X_ACCURACY,POTION,X_ACCURACY", "fixture: two cart slots") + eq(counts(Ops.stackRows(S, false)), "X_ACCURACYx99,POTIONx3,X_ACCURACYx2", "editor rows carry each slot's own count") + local legal = true + for _, r in ipairs(Ops.stackRows(S, false)) do + if not require("Legality").integer(r.count, 1, Ops.itemMax(S, r.id, false)) then legal = false end + end + check(legal, "a split stack is legal per row") +end + +do + local S = red() + split(S) + check(Ops.bagCanMax(S, "X_ACCURACY"), "the part-filled overflow slot can be maxed") + check(Ops.bagMax(S, "X_ACCURACY") == true, "Max fills the overflow slot") + eq(S.save.inventory.X_ACCURACY, 198, "Max fills both slots to 99") + eq(counts(Ops.stackRows(S, false)), "X_ACCURACYx99,POTIONx3,X_ACCURACYx99", "both rows read x99") +end + +do + local S = red() + split(S) + S.dirty = false + check(Ops.bagDrop(S, "X_ACCURACY", 2) == true, "Drop on the overflow row succeeds") + eq(S.save.inventory.X_ACCURACY, 99, "Drop on the overflow row removes only that slot") + eq(table.concat(S.save.bagOrder, ","), "X_ACCURACY,POTION", "the overflow slot leaves bagOrder") +end + +do + local S = red() + Bag.add(S.save, "X_ACCURACY", 99, S.data) + check(Ops.bagAdjust(S, "X_ACCURACY", 1) == true, "+1 on a full slot opens a new slot") + eq(S.save.inventory.X_ACCURACY, 100, "the bag now holds 100") + eq(counts(Ops.stackRows(S, false)), "X_ACCURACYx99,X_ACCURACYx1", "the new slot reads x1") + check(Ops.bagAdjust(S, "X_ACCURACY", -1, 2) == true, "-1 on the new slot") + eq(table.concat(S.save.bagOrder, ","), "X_ACCURACY", "emptying the new slot drops it") +end + +do + local S = red() + Bag.add(S.save, "X_ACCURACY", 99, S.data) + local n = 1 + for _, id in ipairs(S.cat.items) do + if n >= Bag.capacity(S.data) then break end + if not Ops.isBadgeId(id) and id ~= "X_ACCURACY" and Bag.add(S.save, id, 1, S.data) then n = n + 1 end + end + eq(Bag.slots(S.save, S.data), 20, "fixture: a 20-slot bag") + S.dirty = false + check(Ops.bagAdjust(S, "X_ACCURACY", 1) == false, "a full bag refuses the overflow slot") + eq(S.save.inventory.X_ACCURACY, 99, "the refusal changes nothing") + check(S.dirty == false, "the refusal does not dirty") +end + +do + local S = red() + S.save.pcItems.POTION = 150 + eq(table.concat(Ops.pcOrder(S), ","), "POTION,POTION", "a 150 PC stack lists two box slots") + eq(counts(Ops.stackRows(S, true)), "POTIONx99,POTIONx51", "PC rows carry each slot's own count") + check(Ops.pcDrop(S, "POTION", 2) == true, "Drop on the PC overflow row") + eq(S.save.pcItems.POTION, 99, "only the overflow slot leaves the PC") + check(Ops.pcAdjust(S, "POTION", 1) == true, "+1 on a full PC slot opens a new slot") + eq(S.save.pcItems.POTION, 100, "the PC now holds 100") +end + +do + local S = red() + split(S) + check(Ops.bagAdjust(S, "X_ACCURACY", -5, 1) == true, "-5 on the first bag slot") + eq(counts(Ops.stackRows(S, false)), "X_ACCURACYx94,POTIONx3,X_ACCURACYx2", + "editor rows keep 94 and 2 after a first-slot decrease") + eq(table.concat(S.save.bagStacks.X_ACCURACY, ","), "94,2", "the per-slot counts ride save.bagStacks") + S.save.pcItems.POTION = 150 + check(Ops.pcAdjust(S, "POTION", -1, 1) == true, "-1 on the first PC slot") + eq(counts(Ops.stackRows(S, true)), "POTIONx98,POTIONx51", "PC rows keep 98 and 51") +end + +do + local Kit = require("Kit") + local App = require("App") + local oldDim = love.graphics.getDimensions + love.graphics.getDimensions = function() return 1280, 720 end + local path = os.tmpname() .. "-bug2671.lua" + local seed = SaveData.newGame() + seed.inventory = { X_ACCURACY = 101, POTION = 3 } + seed.bagOrder = { "X_ACCURACY", "POTION", "X_ACCURACY" } + local f = assert(io.open(path, "wb")); f:write(SaveData.encode(seed)); f:close() + App.load(path, { version = "red" }) + local S = App.getState() + S.tab = "items" + local labels, invalid = {}, {} + local real = Kit.button + Kit.button = function(x, y, w, h, label, opts) + if type(label) == "string" and label:find("×", 1, true) then + labels[#labels + 1] = label + if opts and opts.invalid then invalid[#invalid + 1] = label end + end + return real(x, y, w, h, label, opts) + end + App.draw() + Kit.button = real + eq(table.concat(labels, "|"), "X ACCURACY ×99|POTION ×3|X ACCURACY ×2", "panel: one row per cart slot with its own count") + eq(#invalid, 0, "panel: no row is flagged as an illegal stack") + App.unload() + os.remove(path) + love.graphics.getDimensions = oldDim +end + +do + local O = GenSave.OFFSETS + local idx = GenSave.crosswalks(Data).itemsIndex + local save = SaveData.newGame() + save.inventory = { X_ACCURACY = 101, POTION = 3 } + save.bagOrder = { "X_ACCURACY", "POTION", "X_ACCURACY" } + save.cartBag = nil + save.pcItems = { POTION = 150, ANTIDOTE = 1 } + save.pcOrder = { "POTION", "ANTIDOTE", "POTION" } + save.cartPc = nil + local ok, bytes = pcall(GenSave.encode, save, Data) + check(ok and type(bytes) == "string", ".sav encode runs: " .. tostring(bytes)) + if ok and type(bytes) == "string" then + eq(bytes:byte(O.numBagItems + 1), 3, ".sav: wNumBagItems counts both X ACCURACY slots") + local function slots(off, n) + local out = {} + for i = 0, n - 1 do + out[#out + 1] = bytes:byte(off + i * 2 + 1) .. ":" .. bytes:byte(off + i * 2 + 2) + end + out[#out + 1] = tostring(bytes:byte(off + n * 2 + 1)) + return table.concat(out, ",") + end + eq(slots(O.bagItems, 3), ("%d:99,%d:3,%d:2,255"):format(idx.X_ACCURACY, idx.POTION, idx.X_ACCURACY), + ".sav: wBagItems keeps each slot where bagOrder put it") + eq(bytes:byte(O.numPcItems + 1), 3, ".sav: wNumBoxItems counts both POTION slots") + eq(slots(O.pcItems, 3), ("%d:99,%d:1,%d:51,255"):format(idx.POTION, idx.ANTIDOTE, idx.POTION), + ".sav: wBoxItems keeps each slot where pcOrder put it") + local okD, dec = pcall(GenSave.decode, bytes, Data) + check(okD and type(dec) == "table", ".sav decode runs: " .. tostring(dec)) + if okD and type(dec) == "table" then + local back = dec.save or dec + eq(back.inventory.X_ACCURACY, 101, ".sav round trip: total 101") + eq(table.concat(back.bagOrder or {}, ","), "X_ACCURACY,POTION,X_ACCURACY", ".sav round trip: two bagOrder occurrences") + eq(back.cartBag, nil, ".sav round trip: the modelled rows need no raw carrier") + eq(back.pcItems.POTION, 150, ".sav round trip: PC total 150") + eq(table.concat(back.pcOrder or {}, ","), "POTION,ANTIDOTE,POTION", ".sav round trip: two pcOrder occurrences") + end + end +end + +do + local O = GenSave.OFFSETS + local idx = GenSave.crosswalks(Data).itemsIndex + local save = SaveData.newGame() + save.inventory = { X_ACCURACY = 96, POTION = 3 } + save.bagOrder = { "X_ACCURACY", "POTION", "X_ACCURACY" } + save.bagStacks = { X_ACCURACY = { 94, 2 } } + save.cartBag = nil + save.pcItems = { POTION = 149, ANTIDOTE = 1 } + save.pcOrder = { "POTION", "ANTIDOTE", "POTION" } + save.pcStacks = { POTION = { 98, 51 } } + save.cartPc = nil + local ok, bytes = pcall(GenSave.encode, save, Data) + check(ok and type(bytes) == "string", ".sav encode of per-slot counts runs: " .. tostring(bytes)) + if ok and type(bytes) == "string" then + local function slots(off, n) + local out = {} + for i = 0, n - 1 do + out[#out + 1] = bytes:byte(off + i * 2 + 1) .. ":" .. bytes:byte(off + i * 2 + 2) + end + out[#out + 1] = tostring(bytes:byte(off + n * 2 + 1)) + return table.concat(out, ",") + end + eq(slots(O.bagItems, 3), ("%d:94,%d:3,%d:2,255"):format(idx.X_ACCURACY, idx.POTION, idx.X_ACCURACY), + ".sav: wBagItems writes each slot's own count") + eq(slots(O.pcItems, 3), ("%d:98,%d:1,%d:51,255"):format(idx.POTION, idx.ANTIDOTE, idx.POTION), + ".sav: wBoxItems writes each slot's own count") + local okD, dec = pcall(GenSave.decode, bytes, Data) + check(okD and type(dec) == "table", ".sav decode of per-slot counts runs: " .. tostring(dec)) + if okD and type(dec) == "table" then + local back = dec.save or dec + eq(back.inventory.X_ACCURACY, 96, ".sav round trip: total 96") + eq(table.concat(back.bagStacks and back.bagStacks.X_ACCURACY or {}, ","), "94,2", + ".sav round trip: 94/2 comes back as per-slot counts") + eq(back.cartBag, nil, ".sav round trip: per-slot counts need no raw carrier") + eq(table.concat(back.pcStacks and back.pcStacks.POTION or {}, ","), "98,51", + ".sav round trip: PC 98/51 comes back as per-slot counts") + eq(back.cartPc, nil, ".sav round trip: PC per-slot counts need no raw carrier") + end + end +end + +print(string.format("save editor split stack tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua index 048f08a9..f4efc5e9 100644 --- a/tests/save_editor_task6_tests.lua +++ b/tests/save_editor_task6_tests.lua @@ -239,8 +239,9 @@ do Ops.addToPc(S, id) Ops.pcItems(S)[id] = Ops.STACK_MAX S.dirty = false - check(Ops.pcAdjust(S, id, 1) == false, "pcAdjust refuses past the 99 stack cap") - eq(Ops.pcItems(S)[id], Ops.STACK_MAX, "a refused pcAdjust changes nothing") + check(Ops.pcAdjust(S, id, 1) == true, "pcAdjust past 99 opens a second box slot") + eq(Ops.pcItems(S)[id], Ops.STACK_MAX + 1, "the PC total passes 99") + eq(#Ops.pcOrder(S), seeded + 2, "the PC order lists the id once per slot") Ops.pcDrop(S, id) eq(Ops.pcItems(S)[id], nil, "pcDrop removes the entry") diff --git a/tools/run_driver.sh b/tools/run_driver.sh index ca36159a..88d90f69 100755 --- a/tools/run_driver.sh +++ b/tools/run_driver.sh @@ -3,7 +3,7 @@ set -u ROOT="$(cd "$(dirname "$0")/.." && pwd)" SLOW_DRIVERS="game3_seam_walk_stress game3_daycare_egg game3_daycare_menu game3_daycare_deposit game3_import2_fresh_cache game3_import2_town_map game3_trade_scene game3_u8c_yesno_frame game3_u9c_catch_ball_open" -REALTIME_DRIVERS="battle_fanfare_speed_bug1952_test" +REALTIME_DRIVERS="battle_fanfare_speed_bug1952_test battle_intro_bug317_test trainer_sight_order_bug1948_test party_hp_palette_bug274_test" help_text() { cat < 1 +end + +local function stackTarget(S, id, have) + if splits(S, id) then + return math.max(1, Bag.slotsFor(id, have, S.data)) * Ops.STACK_MAX + end + return Ops.stackMax(S) +end + +local function rowCount(S, pc, id, slot) + for _, row in ipairs(Ops.stackRows(S, pc)) do + if row.id == id and row.slot == slot then return row.count, row end + end +end + local function changeG3(S, pc, id, quantity) if not id then return Ops.say(S, "Pick an item first") end if not G3.change(S.data, S.save, pc, { { id = id, qty = quantity } }) then @@ -1280,18 +1298,22 @@ function Ops.addToBag(S, id) :format(Bag.slots(S.save, S.data, pocket), capacity, pocket)) end -function Ops.bagAdjust(S, id, delta) +function Ops.bagAdjust(S, id, delta, slot) if not id then return Ops.say(S, "No bag row selected") end S.save.inventory = S.save.inventory or {} local have = itemQty(S.save.inventory, id) if Gen.ofState(S) == 3 then return changeG3(S, false, id, math.max(0, G3.quantity(S.data, S.save, false, id) + delta)) end if delta > 0 then - if have >= Ops.stackMax(S) then + if have >= Ops.stackMax(S) and not splits(S, id) then return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end - Bag.add(S.save, id, delta, S.data) + if not Bag.add(S.save, id, delta, S.data) then + local pocket = Bag.pocketOf(id, S.data) + return Ops.say(S, ("No room for %d more %s (%d/%d %s slots)"):format(delta, tostring(id), + Bag.slots(S.save, S.data, pocket), Bag.capacity(S.data, pocket), pocket)) + end else - Bag.remove(S.save, id, -delta) + Bag.remove(S.save, id, -delta, S.data, slot) if not S.save.inventory[id] then return Ops.mark(S, ("Removed the last %s from the bag"):format(tostring(id))) end @@ -1299,12 +1321,17 @@ function Ops.bagAdjust(S, id, delta) return Ops.mark(S, ("%s x%d"):format(tostring(id), itemQty(S.save.inventory, id))) end -function Ops.bagDrop(S, id) +function Ops.bagDrop(S, id, slot) if not id then return Ops.say(S, "No bag row selected") end S.save.inventory = S.save.inventory or {} local qty = itemQty(S.save.inventory, id) if Gen.ofState(S) == 3 then return changeG3(S, false, id, 0) end - Bag.remove(S.save, id, qty) + local row = slot and rowCount(S, false, id, slot) + if row and row < qty then + Bag.remove(S.save, id, row, S.data, slot) + return Ops.mark(S, ("Dropped %d %s (%d left in the bag)"):format(row, tostring(id), qty - row)) + end + Bag.remove(S.save, id, qty, S.data) return Ops.mark(S, ("Dropped all %d %s"):format(qty, tostring(id))) end @@ -1339,17 +1366,18 @@ function Ops.bagMax(S, id) if not Ops.itemStacks(S, id) then return Ops.say(S, ("%s has no quantity to max"):format(tostring(id))) end - if have >= Ops.stackMax(S) then + local target = stackTarget(S, id, have) + if have >= target then return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end - Bag.add(S.save, id, Ops.stackMax(S) - have, S.data) - return Ops.mark(S, ("%s x%d"):format(tostring(id), Ops.stackMax(S))) + Bag.add(S.save, id, target - have, S.data) + return Ops.mark(S, ("%s x%d"):format(tostring(id), target)) end function Ops.bagCanMax(S, id) if id ~= nil then local have = Gen.ofState(S) == 3 and G3.quantity(S.data, S.save, false, id) or itemQty(S.save.inventory, id) - return have > 0 and have < Ops.stackMax(S) and Ops.itemStacks(S, id) + return have > 0 and have < stackTarget(S, id, have) and Ops.itemStacks(S, id) end for _, rowId in ipairs(Bag.order(S.save, S.data)) do if Ops.bagCanMax(S, rowId) then return true end @@ -1365,8 +1393,9 @@ function Ops.bagMaxAll(S) local n = 0 for _, id in ipairs(ids) do local have = itemQty(S.save.inventory, id) - if have > 0 and have < Ops.stackMax(S) and Ops.itemStacks(S, id) then - Bag.add(S.save, id, Ops.stackMax(S) - have, S.data) + local target = stackTarget(S, id, have) + if have > 0 and have < target and Ops.itemStacks(S, id) then + Bag.add(S.save, id, target - have, S.data) n = n + 1 end end @@ -1430,10 +1459,26 @@ end function Ops.pcOrder(S) local ids = {} - for id in pairs(Ops.pcItems(S)) do ids[#ids + 1] = id end + local gen1 = Gen.ofState(S) == 1 + for id, n in pairs(Ops.pcItems(S)) do + for _ = 1, gen1 and math.max(1, Bag.slotsFor(id, n, S.data)) or 1 do ids[#ids + 1] = id end + end return itemRows(S, ids, S.pcSort) end +function Ops.stackRows(S, pc) + local order = pc and Ops.pcOrder(S) or Bag.order(S.save, S.data) + local store = pc and Ops.pcItems(S) or S.save.inventory + if Gen.ofState(S) == 1 then + return Bag.stackRows(store, order, S.data, pc and S.save.pcStacks or S.save.bagStacks) + end + local rows = {} + for i, id in ipairs(order) do + rows[i] = { id = id, slot = 1, index = i, count = store[id] } + end + return rows +end + function Ops.pcSort(S, mode) if not ITEM_SORT_KEYS[mode] then return false end local repeated = S.pcSort == mode @@ -1490,16 +1535,27 @@ function Ops.addToPc(S, id) return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end -function Ops.pcAdjust(S, id, delta) +function Ops.pcAdjust(S, id, delta, slot) if Gen.ofState(S) == 3 then return changeG3(S, true, id, math.max(0, G3.quantity(S.data, S.save, true, id) + delta)) end if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) local cur = itemQty(pc, id) if not pc[id] and cur <= 0 then return Ops.say(S, ("%s is not in PC storage"):format(tostring(id))) end - if delta > 0 and cur >= slotMax(S, id) then + if delta < 0 and splits(S, id) then + Bag.pcRemove(S.save, id, -delta, S.data, slot) + if not pc[id] then + return Ops.mark(S, ("Removed %s from PC storage"):format(tostring(id))) + end + return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) + end + local split = delta > 0 and splits(S, id) + if delta > 0 and cur >= slotMax(S, id) and not split then return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id))) end - local nextQty = clamp(cur + delta, 0, math.max(cur, slotMax(S, id))) + if split and not pcStacksFree(S, id, delta) then + return Ops.say(S, ("PC item storage is full (%d stacks)"):format(pcCapacity(S))) + end + local nextQty = split and cur + delta or clamp(cur + delta, 0, math.max(cur, slotMax(S, id))) if nextQty <= 0 then pc[id] = nil return Ops.mark(S, ("Removed %s from PC storage"):format(tostring(id))) @@ -1508,12 +1564,17 @@ function Ops.pcAdjust(S, id, delta) return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end -function Ops.pcDrop(S, id) +function Ops.pcDrop(S, id, slot) if Gen.ofState(S) == 3 then return changeG3(S, true, id, 0) end if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) local qty = itemQty(pc, id) - pc[id] = nil + local row = slot and rowCount(S, true, id, slot) + if row and row < qty then + Bag.pcRemove(S.save, id, row, S.data, slot) + return Ops.mark(S, ("Dropped %d %s (%d left in PC storage)"):format(row, tostring(id), qty - row)) + end + Bag.pcRemove(S.save, id, qty, S.data) return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, tostring(id))) end @@ -1529,22 +1590,23 @@ function Ops.pcMax(S, id) if not Ops.itemStacks(S, id) then return Ops.say(S, ("%s has no quantity to max"):format(tostring(id))) end - if cur >= Ops.stackMax(S) then + local target = stackTarget(S, id, cur) + if cur >= target then return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end - pc[id] = Ops.stackMax(S) - return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), Ops.stackMax(S))) + pc[id] = target + return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), target)) end function Ops.pcCanMax(S, id) local pc = Ops.pcItems(S) if id ~= nil then local have = Gen.ofState(S) == 3 and G3.quantity(S.data, S.save, true, id) or itemQty(pc, id) - return have > 0 and have < Ops.stackMax(S) and Ops.itemStacks(S, id) + return have > 0 and have < stackTarget(S, id, have) and Ops.itemStacks(S, id) end for rowId, val in pairs(pc) do local qty = itemQty(pc, rowId) - if qty > 0 and qty < Ops.stackMax(S) and Ops.itemStacks(S, rowId) then + if qty > 0 and qty < stackTarget(S, rowId, qty) and Ops.itemStacks(S, rowId) then return true end end @@ -1557,8 +1619,9 @@ function Ops.pcMaxAll(S) local n = 0 for id, val in pairs(pc) do local qty = itemQty(pc, id) - if qty > 0 and qty < Ops.stackMax(S) and Ops.itemStacks(S, id) then - pc[id] = Ops.stackMax(S) + local target = stackTarget(S, id, qty) + if qty > 0 and qty < target and Ops.itemStacks(S, id) then + pc[id] = target n = n + 1 end end diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 02bcf43a..d1a457fe 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -1,6 +1,5 @@ local Ops = require("Ops") local Gen = require("Gen") -local Bag = require("src.inventory.Bag") local M = {} local Touch = require("TouchEditor") local Motion = require("Motion") @@ -135,8 +134,8 @@ local function drawView(S, Kit, x, y, w, h) end local pc = S.itemView == "pc" local prefix = pc and "pc" or "bag" - local function call(verb, id, value) - return Ops[prefix .. verb](S, id, value) + local function call(verb, id, value, slot) + return Ops[prefix .. verb](S, id, value, slot) end if not compact then local half = (inner - gap) / 2 @@ -186,8 +185,7 @@ local function drawView(S, Kit, x, y, w, h) end return end - local order = pc and Ops.pcOrder(S) or Bag.order(S.save, S.data) - local quantities = pc and S.save.pcItems or S.save.inventory + local order = Ops.stackRows(S, pc) local pagerY = y + h - pad - row local bodyH = math.max(0, pagerY - gap - cy) -- Reserve the complete confirmation label before arming Drop, so its @@ -211,13 +209,15 @@ local function drawView(S, Kit, x, y, w, h) local drawn, shift = Kit.list(S, offsetKey, cx, cy, inner, bodyH, #order, itemH) Kit.pushClip(cx, cy, inner, bodyH) for i = 1, drawn do - local id = order[S[offsetKey] + i] - if id == nil then + local entry = order[S[offsetKey] + i] + if entry == nil then break end + local id, slot = entry.id, entry.slot + local key = tostring(id) .. (slot > 1 and ("#" .. slot) or "") local by = cy + (i - 1) * itemH - shift local def = S.data.items[id] - local count = quantities[id] or 0 + local count = entry.count or 0 local max = Ops.itemMax(S, id, pc) local issue = not require("Legality").integer(count, 1, max) and ("Saved stack must be a whole number from 1 to " .. max) or nil @@ -236,7 +236,7 @@ local function drawView(S, Kit, x, y, w, h) then Touch.open(S, Kit, { mode = "number", - id = "item-" .. tostring(id), + id = "item-" .. key, title = def and def.name or tostring(id), value = count, savedValue = count, @@ -247,7 +247,7 @@ local function drawView(S, Kit, x, y, w, h) help = "Set the stack size. Max fills it; Drop removes it.", }, apply = function(v) - return call("Adjust", id, v - count) + return call("Adjust", id, v - count, slot) end, }) end @@ -258,7 +258,7 @@ local function drawView(S, Kit, x, y, w, h) "Increase", "Max", pc and "Bag" or "PC", - Ops.armLabel(S, prefix .. "-drop-" .. tostring(id), "Drop"), + Ops.armLabel(S, prefix .. "-drop-" .. key, "Drop"), } for j, label in ipairs(labels) do local actionRow = math.floor((j - 1) / actionCols) @@ -277,7 +277,7 @@ local function drawView(S, Kit, x, y, w, h) }) then if j == 1 then - call("Adjust", id, -1) + call("Adjust", id, -1, slot) elseif j == 2 then call("Adjust", id, 1) elseif j == 3 then @@ -287,11 +287,11 @@ local function drawView(S, Kit, x, y, w, h) elseif Ops.arm( S, - prefix .. "-drop-" .. tostring(id), + prefix .. "-drop-" .. key, "Drop this entire stack? Tap again to confirm" ) then - call("Drop", id) + call("Drop", id, slot) end end actionX = actionX + bw + gap