From c23f82efdd934d9c05753888925b906f1a6a10c6 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 21:21:59 -0400 Subject: [PATCH] Fix transition state pop, Gen 2 Bide/Sleep Talk/exp bugs, pp repair, TM pocket capacity and Bill's PC arrows CLOSES #1663, CLOSES #1664, CLOSES #1665, CLOSES #1666, CLOSES #1667, CLOSES #1668, CLOSES #1670, CLOSES #1672 --- src/battle/gen2/Battle.lua | 130 ++++++++-- src/core/SaveData.lua | 22 +- src/import/RomImporter.lua | 3 +- src/inventory/Bag.lua | 5 +- src/mods/Gen2Compat.lua | 29 ++- src/pokemon/Stats.lua | 4 +- src/render/Transition.lua | 14 +- src/ui/gen2/BoxMenu.lua | 40 ++- src/ui/gen2/ItemPcMenu.lua | 4 +- src/ui/gen2/PackMenu.lua | 12 +- src/world/gen2/World.lua | 4 +- tests/drivers/gold_bide_lock_bug1664_1665.lua | 171 +++++++++++++ tests/drivers/gold_billspc_arrows_bug1672.lua | 113 ++++++++ tests/drivers/gold_menu_shots.lua | 5 + tests/drivers/summary_stats_bug1517_test.lua | 2 +- .../warp_midpoint_box_bug1663_test.lua | 80 ++++++ .../engine/summary_move_pp_repair_bug1668.lua | 172 +++++++++++++ .../transition_identity_pop_bug1663.lua | 133 ++++++++++ tests/gen2_battle_test.lua | 242 ++++++++++++++++++ tests/gen2_menus_test.lua | 27 ++ 20 files changed, 1164 insertions(+), 48 deletions(-) create mode 100644 tests/drivers/gold_bide_lock_bug1664_1665.lua create mode 100644 tests/drivers/gold_billspc_arrows_bug1672.lua create mode 100644 tests/drivers/warp_midpoint_box_bug1663_test.lua create mode 100644 tests/engine/summary_move_pp_repair_bug1668.lua create mode 100644 tests/engine/transition_identity_pop_bug1663.lua diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 3c089a24..667cfe62 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -903,8 +903,12 @@ function Battle:movePriority(moveId) return (def and Battle.PRIORITY[def.effect]) or 0 end +-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390) +Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true } + -- Can this mon act? Returns true, or false plus the message the cart prints. -function Battle:canAct(mon) +-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193). +function Battle:canAct(mon, moveId) local name = self:monName(mon) -- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads -- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon @@ -925,7 +929,11 @@ function Battle:canAct(mon) local beforeMove = record and record.beforeMove if beforeMove and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then - return beforeMove(self, mon, name) and true or false + -- engine/battle/effect_commands.asm:188-200 + local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId] + local acted = beforeMove(self, mon, name) and true or false + if acted or not bypass then return acted end + beforeMove = nil end -- SUBSTATUS_FLINCHED, read and cleared right after the freeze check -- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the @@ -1389,7 +1397,14 @@ function Battle:useMove(attacker, defender, moveId) -- free of PP and obedience in exactly the same way. local rolling = state.rolloutLock == moveId - if not (charging or rampaging or rolling) then + -- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800, + -- engine/battle/move_effects/bide.asm:62-68 + local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil + + -- engine/battle/effect_commands.asm:6222-6234, :949-951 + local called = (self.copyDepth or 0) > 0 + + if not (charging or rampaging or rolling or biding or called) then if move and (move.pp or 0) <= 0 then self:emit({ kind = "message", text = "No PP left for this move!" }) return @@ -1471,9 +1486,38 @@ function Battle:useMove(attacker, defender, moveId) return end + -- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61 + if def.effect == "EFFECT_SLEEP_TALK" then + local picked + if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then + -- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141 + local pool = {} + for _, own in ipairs(attacker.moves or {}) do + local ownDef = self:moveDef(own.id) + local effect = ownDef and ownDef.effect + if own.id ~= moveId and not self:moveDisabled(attacker, own.id) + and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then + pool[#pool + 1] = own.id + end + end + if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end + end + if not picked then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + return + end + state.lastMove = nil + self.copyDepth = (self.copyDepth or 0) + 1 + self:useMove(attacker, defender, picked) + self.copyDepth = self.copyDepth - 1 + return + end + -- Everything past here counts as "the user's last move" for Mirror Move, - -- Encore and Disable. - state.lastMove = moveId + -- Encore and Disable. A called move skips the write + -- (engine/battle/used_move_text.asm:30-36). + if (self.copyDepth or 0) == 0 then state.lastMove = moveId end state.turnsTaken = (state.turnsTaken or 0) + 1 state.usedMoves = state.usedMoves or {} local seen = false @@ -1516,6 +1560,13 @@ function Battle:useMove(attacker, defender, moveId) return end + -- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9) + if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + return + end + -- Counter and Mirror Coat answer what the user took this turn, at double, -- and fail outright when nothing of the right kind landed. local counterKind = Effects.COUNTER[def.effect] @@ -2133,6 +2184,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId if not state.bideTurns then state.bideTurns = Effects.bideTurns(self.random) state.bideStored = 0 + -- engine/battle/core.asm:574-576 + state.bideMove = moveId self:emit({ kind = "message", text = self:monName(attacker) .. " is storing energy!" }) return @@ -2144,7 +2197,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId return end local damage = Effects.bideDamage(state.bideStored) - state.bideTurns, state.bideStored = nil, nil + state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil self:emit({ kind = "message", text = self:monName(attacker) .. " unleashed energy!" }) if damage <= 0 then return fail(self) end @@ -3092,6 +3145,12 @@ end -- Faint bookkeeping and experience. Returns true when the battle ended. function Battle:resolveFaints() + -- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037 + if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then + self.participantsCleared = self.player + if self.playerIndex then self.participants[self.playerIndex] = nil end + end + if (self.enemy.hp or 0) <= 0 then self:emit({ kind = "faint", side = "enemy", text = (self.wild and "Wild " or "") .. self:monName(self.enemy) @@ -3547,6 +3606,7 @@ function Battle:switch(index) -- A mon that comes back (a REVIVE, or a second battle) has to be able to -- announce its own faint again; see resolveFaints. self.faintAnnounced = nil + self.participantsCleared = nil -- ForcePlayerMonChoice has been answered, so the next faint may ask again. self.pendingSwitch = nil self.player = mon @@ -3784,19 +3844,37 @@ function Battle:lockedInMove(mon) return nil end +-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin +-- at :5650 +function Battle:fightLockedMove(mon) + local state = self:volatile(mon) + if state.bideTurns then return state.bideMove end + return nil +end + +-- engine/battle/core.asm:627-629 +function Battle:cancelBide(mon) + local state = self:volatile(mon) + state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil +end + -- Encore forces the move; Disable forbids one. Both are read by the screen -- (to grey out the move list) and by the enemy's own choice below. function Battle:forcedMove(mon) local locked = self:lockedInMove(mon) if locked then return locked end local state = self:volatile(mon) - if not state.encore then return nil end - for _, move in ipairs(mon.moves or {}) do - if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end + -- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm + -- (engine/battle/core.asm:561-566). + if state.encore then + for _, move in ipairs(mon.moves or {}) do + if move.id == state.encore and (move.pp or 0) > 0 then + return state.encore + end + end + state.encore, state.encoreTurns = nil, nil end - -- Encore ends early when the move runs out of PP. - state.encore, state.encoreTurns = nil, nil - return nil + return self:fightLockedMove(mon) end function Battle:moveDisabled(mon, moveId) @@ -3810,8 +3888,9 @@ function Battle:usableMoves(mon) -- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage -- that spent its last PP on the opening turn keeps running: no later turn -- of the lock spends any. Encore is not in this exemption -- forcedMove - -- ends it the moment the encored move runs dry. - local locked = self:lockedInMove(mon) + -- ends it the moment the encored move runs dry. Bide is exempt too: + -- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058). + local locked = self:lockedInMove(mon) or self:fightLockedMove(mon) local out = {} for _, move in ipairs(mon.moves or {}) do local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id) @@ -4105,9 +4184,14 @@ function Battle:vanillaEnemyMove() -- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged -- by anything, at any level. Fifteen straight attempts at the Elite Four -- died there, and no amount of grinding could ever have got past it. - local charged = self:volatile(self.enemy).chargeMove + local enemyState = self:volatile(self.enemy) + local charged = enemyState.chargeMove if charged then return charged end + -- engine/battle/core.asm:5650, :5532-5533. Straight off the volatile, the + -- way `charged` above is, so the charge-lock test's bare stub still drives it. + if enemyState.bideTurns then return enemyState.bideMove end + -- Encore and Disable narrow the pool before the AI ever scores it. local moves = self:usableMoves(self.enemy) if #moves == 0 then @@ -4201,6 +4285,7 @@ local function runTurn(self, action) -- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the -- turn unspent -- so a refused RUN never bought the enemy a free attack. if self.runRefused then return self:takeEvents() end + self:cancelBide(self.player) action = { kind = "skip" } end @@ -4216,6 +4301,9 @@ local function runTurn(self, action) if action.kind == "item" and Battle.X_ITEMS[action.item] then Happiness.change(self.player, "USEDXITEM") end + -- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571 + -- instead and keeps the store. + if action.kind == "item" then self:cancelBide(self.player) end -- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides -- to rotate or drink a potion spends its whole turn on it. @@ -4264,7 +4352,6 @@ local function runTurn(self, action) local function playerAttack() if action.kind ~= "move" then return end - if not self:canAct(self.player) then return end local move = action.move -- An encored mon has no choice, whatever the menu said. local forced = self:forcedMove(self.player) @@ -4284,13 +4371,20 @@ local function runTurn(self, action) -- player's own mon spends the rest of the battle underground. local stored = self:volatile(self.player).chargeMove if stored then move = stored end + -- engine/battle/core.asm:558-598 settles wCurPlayerMove before + -- engine/battle/effect_commands.asm:193 reads it. + if not self:canAct(self.player, move) then return end -- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before -- checkobedience, so a locked Rollout or Thrash is exempt from the -- Struggle substitution and the obedience roll the same way the second -- half of a charge move is. local charging = self:volatile(self.player).chargeMove == move or self:lockedInMove(self.player) == move - if not charging and not self:hasUsableMoves(self.player) then + -- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps + -- `checkobedience`. + local bideLocked = self:fightLockedMove(self.player) == move + if not charging and not bideLocked + and not self:hasUsableMoves(self.player) then self:emit({ kind = "message", text = self:monName(self.player) .. " has no moves left!" }) move = Battle.STRUGGLE @@ -4328,7 +4422,7 @@ local function runTurn(self, action) -- against a trainer, could not be escaped either. enemyMoveId = Battle.STRUGGLE end - if not self:canAct(self.enemy) then return end + if not self:canAct(self.enemy, enemyMoveId) then return end -- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574): -- the AI chose before the player's Disable landed, so the turn is spent here. if self:moveDisabled(self.enemy, enemyMoveId) then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index f8f5149e..53e44f3c 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback) return n end +-- PP and the PP Up count are unsigned bit fields of one byte +-- (constants/pokemon_data_constants.asm:101-102) +local function ppInt(v, fallback) + local n = tonumber(v) + if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end + return math.max(0, math.floor(n)) +end + local function ensureOrphaned(save) if not save.orphaned then save.orphaned = { mons = {}, items = {} } @@ -1763,7 +1771,7 @@ local function scrubKnownMon(mon, data) -- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means -- every later reader (menus, battle, items, SGB bar zones, the link -- fingerprint) sees a party-shaped mon. Runs after the level clamp above - -- so the derived stats use a sane level. A save that already has stats is + -- so the derived stats use a sane level. A complete stat block is -- untouched. Stats.ensure(data.pokemon and data.pokemon[mon.species], mon) local moves = mon.moves @@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data) moves[1] = { id = fallback, pp = def.pp } end end + -- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418); + -- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266) + for j = 1, #moves do + local slot = moves[j] + if type(slot) == "table" then + local mdef = data.moves and data.moves[slot.id] + local base = ppInt(mdef and mdef.pp, 0) + local ppUps = math.min(3, ppInt(slot.ppUps, 0)) + if slot.ppUps ~= nil then slot.ppUps = ppUps end + slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5)) + end + end end local function scrubMonList(list, where, save, data, report) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8b1b741d..2c303fac 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -146,8 +146,7 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { -- complete and SlotMachine crashed on its labelled-cell fallback. "assets/generated/slots/gold_slots_1.png", "assets/generated/card_flip/card_flip_1.png", - -- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173): Bill's PC has no - -- held-item or mail icon in a cache built before the symbol was listed. + -- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173) "assets/generated/pc/mail_item.png", }, } diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index ea0e7d83..37381670 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -10,13 +10,14 @@ local Bag = {} -- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of --- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override +-- every TM plus the seven HMs (NUM_TMS + NUM_HMS -- ram/wram.asm:2421 is +-- `wTMsHMs:: ds NUM_TMS + NUM_HMS`, 57 bytes). A `mods` bagSize override -- replaces the ITEM pocket only, the way the Gen 1 single-bag config did. local POCKET_CAPACITY = { ITEM = 20, BALL = 12, KEY_ITEM = 25, - TM_HM = 64, + TM_HM = 57, } local DEFAULT_CAPACITY = 20 diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index e2e9e771..2594e8b6 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -811,19 +811,21 @@ local function buildOverworld() local FieldMoves = rawRequire("src.world.gen2.FieldMoves") -- home/map.asm:1869 - local function stepAllowed(world, entity, dir) + local function stepAllowed(world, entity, dir, cx, cy) local d = Map2.DELTA[dir] - if not (world.map and d and entity and entity.cellX) then return true end + if not (world.map and d and entity) then return true end + cx, cy = cx or entity.cellX, cy or entity.cellY + if not (cx and cy) then return true end if not Permissions.stepPermitted( - function(cx, cy) return world:cellCollisionAcross(world.map, cx, cy) end, - entity.cellX, entity.cellY, dir) then + function(px, py) return world:cellCollisionAcross(world.map, px, py) end, + cx, cy, dir) then return false end local map = world.map if entity == world.player and FieldMoves.isSurfing(world.playerState) then map = world:surfMap(world.map) end - local tx, ty = entity.cellX + d[1], entity.cellY + d[2] + local tx, ty = cx + d[1], cy + d[2] if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end for _, e in ipairs(world.entities or {}) do if e ~= entity and not e.passable then @@ -1048,9 +1050,17 @@ local function buildOverworld() .. "mapped object (def.index) can be moved" end local n = math.max(0, tiles or 1) - if opts and opts.collide and n > 0 and not stepAllowed(world, entity, dir) then - entity.facing = dir - n = 0 + if opts and opts.collide and n > 0 then + local d = Map2.DELTA[dir] + local cx, cy = entity.cellX, entity.cellY + local allowed = 0 + for _ = 1, n do + if not stepAllowed(world, entity, dir, cx, cy) then break end + allowed = allowed + 1 + if d and cx and cy then cx, cy = cx + d[1], cy + d[2] end + end + if allowed < n then entity.facing = dir end + n = allowed end local bytes = {} for _ = 1, n do bytes[#bytes + 1] = step end @@ -1431,7 +1441,8 @@ COVERAGE[OW] = { .. "objectId 1 is wLastTalked, not object zero", scriptMove = "the player maps to objectId 0 and a mapped object to " .. "def.index + 1; an entity with neither (a mod's own guest) is " - .. "REFUSED with a reason rather than moving the last-talked NPC", + .. "REFUSED with a reason rather than moving the last-talked NPC; " + .. "opts.collide truncates the walk at the first blocked step", connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, " .. "conn); `conn` is Gold's connection record, keyed map/mapId + offset", timeOfDay = "recomputed from the clock every call and never cached, so a " diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index f08638c3..9fc70e3a 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -52,7 +52,9 @@ end -- CalcStats) and when one is moved back into the party -- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is -- kept (box_struct does hold it) but clamped to the recalculated maximum. --- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48). +-- A complete stat block is returned untouched; an incomplete one is rebuilt, +-- because CalcStats writes all NUM_STATS stats or none +-- (home/move_mon.asm:33-48). #233, #304, #1517 local function statsComplete(stats) for _, key in ipairs(ORDER) do if type(stats[key]) ~= "number" then return false end diff --git a/src/render/Transition.lua b/src/render/Transition.lua index f970b5f6..4080afd7 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp) end function Transition:finish() - self.game.stack:pop() + if self.done then return end + self.done = true + if not self.offStack then self.game.stack:pop() end if self.onDone then self.onDone() end end @@ -96,10 +98,16 @@ function Transition:update(dt) self.t = 0 if self.phase == "out" then self.phase = "in" - if self.onMidpoint then self.onMidpoint() end -- LoadGBPal restores the palettes in one write, so with no fade in the -- map is simply there on the next frame - if (self.framesIn or 0) <= 0 then self:finish() end + if (self.framesIn or 0) <= 0 then + self.offStack = true + self.game.stack:pop() + if self.onMidpoint then self.onMidpoint() end + self:finish() + elseif self.onMidpoint then + self.onMidpoint() + end else self:finish() end diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 1da75594..1d7d28e4 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -66,7 +66,7 @@ local PIC_X, PIC_Y = 1, 4 -- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386). local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } --- gfx/pc/orange.pal, for a cache that predates menu_gfx.billsPc. +-- gfx/pc/orange.pal local BILLS_PC_ORANGE = { { 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 }, } @@ -75,6 +75,12 @@ local BILLS_PC_ORANGE = { -- (engine/pokemon/bills_pc.asm:1093). local ICON_X, ICON_Y = 7, 12 +-- $5f at hlcoord 8, 1 and $5e at hlcoord 19, 1, off a PCMailGFX sheet that +-- starts at $5c (engine/pokemon/bills_pc.asm:957-963). +local ARROW_ROW = 1 +local ARROW_LEFT = { 3, 8 } +local ARROW_RIGHT = { 2, 19 } + -- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the -- MOVE screen ever loads box 0; the withdraw and deposit lists are one list -- each (BillsPC_BoxName reads the same byte for all three). @@ -550,7 +556,7 @@ function BoxMenu:playSfx(name) if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end end --- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:101) +-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114) function BoxMenu:playMonCry(mon) local data = self.game and self.game.data if not (data and mon and mon.species) or mon.isEgg then return end @@ -765,6 +771,35 @@ function BoxMenu:drawHeldIcon(mon) G.setColor(1, 1, 1, 1) end +-- _MovePKMNWithoutMail only (engine/pokemon/bills_pc.asm:545, :698) +function BoxMenu:drawBoxArrows() + if self.mode ~= "move" then return end + local gfx = (self.menuGfx or {}).billsPc + local image = self:image(gfx and gfx.icons) + if not image then return end + local G = love.graphics + local quads = {} + for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do + local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8, + image:getDimensions()) + if not ok then return end + quads[#quads + 1] = { quad, arrow[2] } + end + G.setColor(1, 1, 1, 1) + local function body() + for _, entry in ipairs(quads) do + G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8) + end + end + local colors = gfx and gfx.palette + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + -- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor -- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top -- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are @@ -811,6 +846,7 @@ function BoxMenu:drawPanel() -- Textbox at (8,0) with a 10x1 interior and the name at (10,1). Chrome.box(8, 0, 12, 3) Chrome.print(self:title(), 10, 1) + self:drawBoxArrows() Chrome.box(8, 2, 12, 12) -- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘' -- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 1d9b4f5f..10c5ab1b 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -521,9 +521,7 @@ function ItemPcMenu:drawList() local entry = self.rows[i] if i == self.listIndex then Chrome.cursor(5, ty) end Chrome.print(entry.name, 6, ty) - -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the - -- entry's second line, right-aligned in a blank-padded 2-digit field, - -- and an item with no quantity draws none at all (menu_2.asm:18). + -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24) if not self:cantToss(entry.id) then Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) end diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 721601af..5217f3ca 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -16,6 +16,9 @@ local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +-- constants/sfx_constants.asm:3, :28 +local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25 + local PackMenu = {} PackMenu.__index = PackMenu PackMenu.isOpaque = true @@ -247,8 +250,7 @@ function PackMenu:rebuild() } end end - -- engine/items/tmhm.asm:341 -- wTMsHMs is walked 1..57, so the TM/HM pocket - -- has no acquisition order to preserve. + -- engine/items/tmhm.asm:341 if pocket == "TM_HM" then table.sort(rows, function(a, b) local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id) @@ -377,7 +379,7 @@ function PackMenu:useSelected() -- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit. -- World has already set the decoration's flag and taken the box. if world.playSfxNamed then - world:playSfxNamed("Sfx_DexFanfare5079") + world:playSfxNamed("Sfx_DexFanfare5079", SFX_DEX_FANFARE_50_79) end self.message = { "There was a trophy", "inside!", "{PLAYER} sent the", "trophy home." } @@ -663,7 +665,9 @@ function PackMenu:openTeachParty(row) if not allowed then -- engine/items/tmhm.asm:131 local world = game.world - if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end + if world and world.playSfxNamed then + world:playSfxNamed("Sfx_Wrong", SFX_WRONG) + end if game.say then game:say(("%s can't learn %s!"):format( require("src.battle.gen2.Mon").displayName(mon), moveName)) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index d41587d0..ef84997a 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -2184,8 +2184,8 @@ function World:playSfxNamed(want, fallbackId) self:playSfx(self:sfxIdNamed(want, fallbackId)) end --- .BumpSound (engine/overworld/player_movement.asm:771): `call CheckSFX / --- ret c` is the whole rate limit (home/audio.asm:477), not a frame counter. +-- .BumpSound (engine/overworld/player_movement.asm:771), CheckSFX at +-- home/audio.asm:477 function World:bumpSound() if Sound.sfxBusy() then return end self:playSfxNamed("Sfx_Bump", SFX.BUMP) diff --git a/tests/drivers/gold_bide_lock_bug1664_1665.lua b/tests/drivers/gold_bide_lock_bug1664_1665.lua new file mode 100644 index 00000000..ce17558a --- /dev/null +++ b/tests/drivers/gold_bide_lock_bug1664_1665.lua @@ -0,0 +1,171 @@ +-- BIDE on the real Gold battle screen: the PP counter in the move list, and +-- the lock that keeps a storing mon on the move it started (#1664, #1665). +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bide_lock_bug1664_1665.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-bide love . +-- +-- data/moves/effects.asm:795-800 puts `storeenergy` ahead of `doturn`, and +-- BattleCommand_DoTurn masks SUBSTATUS_BIDE out of the PP spend +-- (engine/battle/effect_commands.asm:977-979), so the whole three-turn Bide +-- costs the one PP the opening turn paid. The shots are the point: the +-- number beside BIDE in the move list must read the same on 01, 02 and 03. +-- +-- The lock is ParsePlayerAction's own arm (engine/battle/core.asm:569-576), +-- which sits INSIDE the FIGHT branch and skips MoveSelectionScreen only -- so +-- the 2x2 menu still opens, a switch is still legal, and using an item runs +-- .reset_bide (:627-629) and CANCELS the store. Block 2 submits TACKLE in +-- the middle of a Bide on purpose: the engine has to answer with the Bide. +-- +-- NOT covered here, and deliberately: src/ui/gen2/BattleState.lua still draws +-- the move list on a storing turn. The cart jumps past it. That half is a +-- screen change and is called out in the fix report rather than faked. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +local function giveMoves(mon, game, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +local function newWild(game, species, level, moves) + local mon = Mon.new(game.data, species, level) + if moves then giveMoves(mon, game, moves) end + return mon +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bide" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + local function pp(mon, slot) return mon.moves[slot].pp end + + -- ------------------------------------------------------------------ 1 + -- One Bide, start to finish, with the move list open between turns. The + -- foe is a SPLASH-only RATTATA so nothing interrupts the store. + local player = Mon.new(game.data, "SNORLAX", 40) + giveMoves(player, game, { "BIDE", "TACKLE" }) + game.save.party = { player } + game.save.inventory = { POTION = 5 } + local foe = newWild(game, "RATTATA", 20, { "TACKLE" }) + assert(world:startBattle({ wild = foe }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 200) + local opening = pp(player, 1) + print(("[driver] BIDE opens at %d PP"):format(opening)) + + screen:submit({ kind = "move", move = "BIDE" }) + drain(game, screen, 400) + U.shot(game, out .. "/01-selected.png") + local afterSelect = pp(player, 1) + print(("[driver] after the SELECTION turn: %d PP"):format(afterSelect)) + check(afterSelect == opening - 1, + "the opening turn pays its one PP through doturn") + check(screen.battle:forcedMove(player) == "BIDE", + "and ParsePlayerAction's bide arm holds the FIGHT choice") + + -- The middle of the store, with TACKLE deliberately submitted: the cart + -- never re-reads the move list, so wCurPlayerMove is still the BIDE. + local tackleBefore = pp(player, 2) + screen:submit({ kind = "move", move = "TACKLE" }) + drain(game, screen, 400) + U.shot(game, out .. "/02-storing.png") + print(("[driver] after a STORING turn: BIDE %d PP, TACKLE %d PP") + :format(pp(player, 1), pp(player, 2))) + check(pp(player, 1) == afterSelect, "a storing turn spends no PP at all") + check(pp(player, 2) == tackleBefore, + "and the move the menu offered was never run") + + -- The release. Whatever the roll, the store is two or three turns + -- (UnleashEnergy's `BattleRandom / and 1 / inc a / inc a`, + -- move_effects/bide.asm:88-92), so keep pressing FIGHT until it lets go. + local foeBefore = foe.hp + for _ = 1, 3 do + if not screen.battle:forcedMove(player) then break end + screen:submit({ kind = "move", move = "BIDE" }) + drain(game, screen, 400) + end + U.shot(game, out .. "/03-unleashed.png") + print(("[driver] after the RELEASE: %d PP, foe %d -> %d HP") + :format(pp(player, 1), foeBefore, foe.hp)) + check(pp(player, 1) == afterSelect, + "the whole Bide cost exactly one PP, the way the cart charges it") + check(screen.battle:forcedMove(player) == nil, "and the lock is released") + for _ = 1, 400 do + if not game.stack:top() or not game.stack:top().battle then break end + U.tap(game, "a") + U.wait(3) + end + U.wait(60) + + -- ------------------------------------------------------------------ 2 + -- .reset_bide: a nonzero wBattlePlayerAction that is not + -- BATTLEPLAYERACTION_SWITCH clears SUBSTATUS_BIDE (core.asm:572-573, + -- :627-629), so opening the PACK mid-store throws the stored damage away. + local bider = Mon.new(game.data, "SNORLAX", 40) + giveMoves(bider, game, { "BIDE", "TACKLE" }) + bider.hp = math.max(1, bider.hp - 30) + game.save.party = { bider } + game.save.inventory = { POTION = 5 } + local foe2 = newWild(game, "RATTATA", 20, { "TACKLE" }) + assert(world:startBattle({ wild = foe2 }), "startBattle failed") + screen = battleScreen(game) + drain(game, screen, 200) + screen:submit({ kind = "move", move = "BIDE" }) + drain(game, screen, 400) + check(screen.battle:forcedMove(bider) == "BIDE", "the second Bide is up") + screen:useItem("POTION") + drain(game, screen, 400) + U.shot(game, out .. "/04-item-cancelled.png") + print("[driver] after the PACK: forcedMove=" + .. tostring(screen.battle:forcedMove(bider))) + check(screen.battle:forcedMove(bider) == nil, + "using an item cancels the Bide, as .reset_bide does") + check(screen.battle:volatile(bider).bideStored == nil, + "and the damage it had banked goes with it") + for _ = 1, 400 do + if not game.stack:top() or not game.stack:top().battle then break end + U.tap(game, "a") + U.wait(3) + end + + print("[driver] NOTE: the move list is still drawn on a storing turn; the " + .. "cart's bide arm skips MoveSelectionScreen and that half lives in " + .. "src/ui/gen2/BattleState.lua") + if #failures > 0 then + for _, what in ipairs(failures) do print("[FAIL] " .. what) end + error(#failures .. " bide checks failed") + end + print("[driver] all bide checks passed") + print("[driver] shots in " .. out) +end diff --git a/tests/drivers/gold_billspc_arrows_bug1672.lua b/tests/drivers/gold_billspc_arrows_bug1672.lua new file mode 100644 index 00000000..dc6d57ae --- /dev/null +++ b/tests/drivers/gold_billspc_arrows_bug1672.lua @@ -0,0 +1,113 @@ +-- #1672: Bill's PC MOVE screen -- the box-name arrows. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_billspc_arrows_bug1672.lua \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1672-arrows (default) +-- +-- BillsPC_MoveMonWOMail_BoxNameAndArrows writes $5f at hlcoord 8, 1 and $5e at +-- hlcoord 19, 1 (engine/pokemon/bills_pc.asm:957-963), replacing the box-name +-- Textbox's own side borders. Only _MovePKMNWithoutMail calls it: .Init (:545) +-- and .PrepInsertCursor (:698). The withdraw (:299) and deposit (:56) inits +-- call bare BillsPC_BoxName (:965) and are the negative controls here. +-- +-- What to look for in each shot: a solid LEFT-pointing triangle in the left +-- border of the name box and a solid RIGHT-pointing one in the right border, +-- both level with the name, on every move-mode shot and on neither of the last +-- two. Getting them the wrong way round is the failure this exists to catch. +-- +-- The run ends with the MOVE screen open so a human takes the controls there. +local U = require("tests.drivers.util") + +local Boxes = require("src.core.gen2.Boxes") +local Mon = require("src.battle.gen2.Mon") +local Screens = require("src.ui.Screens") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1672-arrows" + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local save, data = game.save, game.data + + local function mon(species, level) return Mon.new(data, species, level) end + + -- IsAnyMonHoldingMail refuses the whole screen (src/ui/gen2/PcMenu.lua:303), + -- so the seeded party carries no mail. + save.party = {} + for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "SENTRET" }) do + save.party[#save.party + 1] = mon(species, 12) + end + local stored = Boxes.box(save, 1) + for i = #stored, 1, -1 do stored[i] = nil end + for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA" }) do + stored[i] = mon(species, 10 + i) + end + Boxes.rename(save, 1, "GRASS") + save.currentBox = 1 + + local function openBox(mode) + Screens.push(game, "Gen2BoxMenu", { + save = save, mode = mode, + onClose = function() game.stack:pop() end, + }) + U.wait(8) + return game.stack:top() + end + + local function close() + while game.stack:top() and game.stack:top().submenuRows do + game.stack:pop() + end + U.wait(4) + end + + -- ---- 1. the move list on a renamed box ----------------------------------- + local menu = openBox("move") + U.log("01 move, box GRASS:", "want both arrows flanking GRASS on row 1") + U.shot(game, out .. "/01-move-box.png") + + -- ---- 2. LEFT onto the PARTY ---------------------------------------------- + -- BillsPC_BoxName's `.party` arm; only the move screen ever loads box 0. + tap("left") + U.log("02 move, PARTY:", "boxIndex " .. tostring(menu.boxIndex) .. + ", want 0 and both arrows still up") + U.shot(game, out .. "/02-move-party.png") + tap("right") + + -- ---- 3. the MOVE / STATS / CANCEL submenu -------------------------------- + tap("a") + U.log("03 submenu:", "phase " .. tostring(menu.phase) .. + ", want submenu and both arrows still up") + U.shot(game, out .. "/03-move-submenu.png") + + -- ---- 4. the insert cursor (.PrepInsertCursor rewrites them) -------------- + tap("a", 10) + U.log("04 insert:", "phase " .. tostring(menu.phase) .. + ", want insert and both arrows still up") + U.shot(game, out .. "/04-move-insert.png") + close() + + -- ---- 5/6. the negative controls ------------------------------------------ + openBox("withdraw") + U.log("05 withdraw:", "want plain border tiles, NO arrows") + U.shot(game, out .. "/05-withdraw-none.png") + close() + + openBox("deposit") + U.log("06 deposit:", "want plain border tiles, NO arrows") + U.shot(game, out .. "/06-deposit-none.png") + close() + + openBox("move") + U.log("done -- the MOVE screen is open; the controls are yours") +end diff --git a/tests/drivers/gold_menu_shots.lua b/tests/drivers/gold_menu_shots.lua index de673519..58b9cc76 100644 --- a/tests/drivers/gold_menu_shots.lua +++ b/tests/drivers/gold_menu_shots.lua @@ -252,6 +252,11 @@ return function(game) dep.index = 2 -- the mon holding FLOWER MAIL show("23b-pc-deposit-mail", dep) + -- Only the move list gets the box-name arrows, $5f left and $5e right + -- (engine/pokemon/bills_pc.asm:957-963); 22 and 23 above are the controls. + local mv = BoxMenu.new(game, { save = save, mode = "move" }) + show("23c-pc-move-arrows", mv) + -- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and -- SetDayOfWeek), each at its picker rather than at its opening page. local clock = InitClock.new(game, { save = save }) diff --git a/tests/drivers/summary_stats_bug1517_test.lua b/tests/drivers/summary_stats_bug1517_test.lua index 97b30644..2c25a2c4 100644 --- a/tests/drivers/summary_stats_bug1517_test.lua +++ b/tests/drivers/summary_stats_bug1517_test.lua @@ -2,7 +2,7 @@ -- -- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and -- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special --- (engine/pokemon/status_screen.asm:255-296). Gen 2 splits that word into +-- (engine/pokemon/status_screen.asm:238-287). Gen 2 splits that word into -- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2 -- block over a Yellow party leaves the port with no `special` to print. -- diff --git a/tests/drivers/warp_midpoint_box_bug1663_test.lua b/tests/drivers/warp_midpoint_box_bug1663_test.lua new file mode 100644 index 00000000..79ea6412 --- /dev/null +++ b/tests/drivers/warp_midpoint_box_bug1663_test.lua @@ -0,0 +1,80 @@ +-- data/maps/force_bike_surf.asm:5 / engine/overworld/player_state.asm:34-72, +-- home/overworld.asm:690-703 (the map-change fade has no fade back in). +-- POKEPORT_DRIVER=tests/drivers/warp_midpoint_box_bug1663_test.lua \ +-- POKEPORT_IDENTITY=bug1663 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Ride into the Route 16 gate, drop the BICYCLE inside, then walk back out +-- the west door. The warp lands on a forced-bike tile, so the refusal box +-- opens from inside the fade's midpoint: it must be on screen when the fade +-- ends (#1663 ate it), and dismissing it must leave the player on Route 16's +-- (17,10), not shoved into the gate wall at (18,10) (#1548). +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + 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 where() + local ow = game.overworld + return ow.map.id, ow.player.cellX, ow.player.cellY + end + local function bikeless() + game.save.inventory.BICYCLE = nil + game.save.onBike, game.save.forcedBike = false, nil + end + + game.save.player.name = "PROBE" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory.BICYCLE = 1 + game.save.onBike, game.save.forcedBike = false, nil + + -- (16,10) is the open cell west of the gate door; (17,10) is the forced + -- tile the door sits on, so the step onto it mounts and then warps + U.teleport(game, "ROUTE_16", 16, 10, "right") + U.wait(20) + U.hold(game, "right", 40) + U.wait(40) + local map, x, y = where() + U.log(("rode east into the gate -> %s (%d,%d)"):format(map, x, y)) + check("the bike carried the player into the gate", + map == "ROUTE_16_GATE_1F") + + bikeless() + U.hold(game, "left", 40) + -- the map-change fade is 32 frames and hands back with no fade in + U.wait(50) + + map, x, y = where() + local top = game.stack:top() + U.log(("walked back out -> %s (%d,%d), top=%s"):format(map, x, y, + tostring(getmetatable(top) == TextBox and "TextBox" or top))) + check("the west door lands back on Route 16", map == "ROUTE_16") + check("on the forced tile the gate exit warps to", x == 17 and y == 10) + check("the refusal box the warp opened is on screen", + getmetatable(top) == TextBox) + U.shot(game, SHOT_DIR .. "/bug1663_refusal.png") + + -- close it: the shove back east is refused by collision, (18,10) is wall + for _ = 1, 12 do + if game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(25) + end + U.wait(30) + map, x, y = where() + local walkable = game.overworld.map:isWalkableCell(x, y) + U.log(("after the refusal -> %s (%d,%d) walkable=%s") + :format(map, x, y, tostring(walkable))) + check("dismissing it leaves the player on a walkable cell", walkable) + check("and not inside the gate wall at (18,10)", not (x == 18 and y == 10)) + U.shot(game, SHOT_DIR .. "/bug1663_after.png") + + U.log(ok and "all clear" or "a check failed") + while true do coroutine.yield() end +end diff --git a/tests/engine/summary_move_pp_repair_bug1668.lua b/tests/engine/summary_move_pp_repair_bug1668.lua new file mode 100644 index 00000000..8456728d --- /dev/null +++ b/tests/engine/summary_move_pp_repair_bug1668.lua @@ -0,0 +1,172 @@ +-- A Gen 1 move slot stores current PP in six bits of one byte and the PP Up +-- count in the other two (constants/pokemon_data_constants.asm:101-102), and +-- the status screen reads it back with `and PP_MASK` before PrintNumber +-- (engine/pokemon/status_screen.asm:357-365), so "not a number" is not a state +-- the hardware record can hold and the load-time repair must normalize it. +-- Max PP follows GetMaxPP/AddBonusPP (engine/items/item_effects.asm:2467, +-- 2418). #1668 + +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 SaveData = require("src.core.SaveData") + +local data = { + pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } }, + moves = { + FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 }, + FIX_GROWL = { id = "FIX_GROWL", name = "GROWL", pp = 40 }, + }, + items = {}, maps = { FIXMAP = { id = "FIXMAP" } }, + constants = { fallbackMove = "FIX_TACKLE" }, +} + +local function saveWith(moves) + return { + party = { { species = "FIXMON_A", level = 21, hp = 62, exp = 9000, + dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }, + statExp = {}, moves = moves } }, + boxes = {}, inventory = {}, pcItems = {}, + player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 }, + } +end + +local function scrub(moves) + local save = saveWith(moves) + SaveData.validate(save, data) + return save.party[1].moves +end + +-- SummaryMenu page 2 and every battle reader index the save's own slot table +local function readers(mv) + local mdef = data.moves[mv.id] + local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5) + local okFmt = pcall(string.format, "%2d/%2d", mv.pp, maxPP) + local okCmp = pcall(function() return mv.pp > 0 end) + return okFmt, okCmp, maxPP +end + +do -- every non-numeric pp shape the tree can be handed + local moves = scrub({ + { id = "FIX_TACKLE" }, + { id = "FIX_TACKLE", pp = {} }, + { id = "FIX_TACKLE", pp = "35" }, + { id = "FIX_TACKLE", pp = 0 / 0 }, + }) + eq(#moves, 4, "all four slots survive") + for i = 1, 4 do + local mv = moves[i] + check(type(mv.pp) == "number", "slot " .. i .. " carries a numeric pp") + local okFmt, okCmp, maxPP = readers(mv) + check(okFmt, "slot " .. i .. ": SummaryMenu's ('%2d/%2d'):format no longer raises") + check(okCmp, "slot " .. i .. ": playerHasPP's `mv.pp > 0` no longer raises") + check(type(mv.pp) == "number" and mv.pp >= 0 and mv.pp <= maxPP, + "slot " .. i .. " sits inside 0..maxPP") + end + eq(moves[1].pp, 35, "a missing pp heals to full, not to Struggle") + eq(moves[2].pp, 35, "a table pp heals to full") + eq(moves[3].pp, 35, "a numeric string becomes the number it spells") + eq(moves[4].pp, 35, "a nan pp heals to full") +end + +do -- numeric, but not a value the unsigned byte field can express + local moves = scrub({ + { id = "FIX_TACKLE", pp = -4 }, + { id = "FIX_TACKLE", pp = 1.7 }, + { id = "FIX_TACKLE", pp = math.huge }, + { id = "FIX_TACKLE", pp = -math.huge }, + }) + eq(moves[1].pp, 0, "a negative pp clamps up to zero") + eq(moves[2].pp, 1, "a fractional pp floors to an integer") + eq(moves[3].pp, 35, "an infinite pp heals to full") + eq(moves[4].pp, 35, "so does a negative infinity") +end + +-- MimicEffect writes the copied move id into the slot and never touches the +-- PP byte (engine/battle/effects.asm:1261-1266), and this port's battler reads +-- mon.moves by identity, so a mid-battle save legitimately carries a PP count +-- above the slot's current move's max. Clamping it down corrupts a battle +-- checkpoint, which is why the repair only replaces values that are not +-- numbers at all. +do + local moves = scrub({ + { id = "FIX_TACKLE", pp = 40, mimic = true }, + { id = "FIX_TACKLE", pp = 999 }, + { id = "FIX_GROWL", pp = 40, ppUps = 0 }, + }) + eq(moves[1].pp, 40, "a Mimic'd slot keeps the 40 PP the GROWL it replaced had") + check(moves[1].mimic == true, "and the battler's own restore marker survives") + eq(moves[2].pp, 999, "an over-max pp is left alone, not clamped") + eq(moves[3].pp, 40, "and a full slot is unchanged") +end + +do -- the PP Up count is two bits, so 0..3 + local moves = scrub({ + { id = "FIX_TACKLE", pp = 5, ppUps = "x" }, + { id = "FIX_TACKLE", pp = 5, ppUps = 9 }, + { id = "FIX_TACKLE", pp = 49, ppUps = 2 }, + { id = "FIX_TACKLE", pp = 5 }, + }) + eq(moves[1].ppUps, 0, "a non-numeric ppUps becomes zero") + eq(moves[2].ppUps, 3, "an over-max ppUps clamps to three") + eq(moves[3].ppUps, 2, "a legal ppUps is kept") + eq(moves[3].pp, 49, "and its PP-Upped count is untouched: 35 + 2 * 7") + eq(moves[4].ppUps, nil, "a slot without a ppUps does not grow one") + for i = 1, 4 do + local okFmt = pcall(string.format, "%2d/%2d", moves[i].pp, 35) + check(okFmt, "slot " .. i .. " formats after a ppUps repair") + local mdef = data.moves[moves[i].id] + local ok = pcall(function() + return mdef.pp + (moves[i].ppUps or 0) * math.floor(mdef.pp / 5) + end) + check(ok, "slot " .. i .. ": SummaryMenu's maxPP arithmetic no longer raises") + end +end + +-- A scalar move slot is a shape the id filter has always tolerated, and +-- tests/modkit/cases/checkpoints.lua treats one as valid content that +-- SaveData.validate must not rewrite. It stays out of the repair; the +-- SummaryMenu.lua:206 crash it causes is a separate defect. +do + local moves = scrub({ "FIX_TACKLE", { id = "FIX_GROWL" } }) + eq(#moves, 2, "the scalar slot survives the id filter, as before") + eq(moves[1], "FIX_TACKLE", "and is left exactly as the save stored it") + eq(moves[2].pp, 40, "while its table-shaped neighbour is still repaired") +end + +do -- the moveless-mon fallback slot goes through the same normalization + local moves = scrub({ { id = "NOT_A_MOVE", pp = "junk" } }) + eq(#moves, 1, "the unknown move is replaced by the fallback") + eq(moves[1].id, "FIX_TACKLE", "which is data.constants.fallbackMove") + check(type(moves[1].pp) == "number", "and carries a numeric pp") + eq(moves[1].pp, 35, "at full") +end + +do -- a vanilla slot passes through byte-identical + local moves = scrub({ + { id = "FIX_TACKLE", pp = 20 }, + { id = "FIX_GROWL", pp = 0 }, + { id = "FIX_GROWL", pp = 64, ppUps = 3 }, + }) + eq(moves[1].pp, 20, "a mid-fight pp is left alone") + eq(moves[2].pp, 0, "an exhausted move is left on zero, not healed") + eq(moves[3].pp, 64, "and a PP-Upped slot agrees with SummaryMenu's own maxPP") + eq(moves[3].ppUps, 3, "with its PP Up count intact") +end + +do -- box mons are reached by the same pass + local save = saveWith({ { id = "FIX_TACKLE", pp = 20 } }) + save.boxes = { { { species = "FIXMON_A", level = 21, hp = 62, + dvs = {}, statExp = {}, + moves = { { id = "FIX_TACKLE", pp = "nonsense" } } } } } + SaveData.validate(save, data) + local mv = save.boxes[1][1].moves[1] + check(type(mv.pp) == "number", "a box mon's move slot is repaired too") + eq(mv.pp, 35, "to full PP") +end + +T.finish() diff --git a/tests/engine/transition_identity_pop_bug1663.lua b/tests/engine/transition_identity_pop_bug1663.lua new file mode 100644 index 00000000..6132c7bb --- /dev/null +++ b/tests/engine/transition_identity_pop_bug1663.lua @@ -0,0 +1,133 @@ +-- home/overworld.asm:690-703 (PlayMapChangeSound tail-calls GBFadeOutToBlack) +-- and home/fade.asm:43-46: the map-change fade has no matching fade in, so the +-- warp shape ends in the same frame its midpoint runs. The midpoint is where +-- setMap opens things (the Cycling Road refusal box, a map script's onEnter), +-- and a fade that popped the top of the stack after that ate them and then +-- finished a second time (#1663). +-- luajit tests/engine/transition_identity_pop_bug1663.lua + +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 StateStack = require("src.core.StateStack") +local Transition = require("src.render.Transition") +local Timing = require("src.core.Timing") + +local function overworld() return { name = "overworld", isOpaque = true } end + +-- ------------------------------------------------ the warp shape (#1663) + +do + StateStack:init() + local ow = overworld() + StateStack:push(ow) + + local box = { name = "refusal" } + local entered, depthAtMidpoint, topAtMidpoint = 0, nil, nil + function box:enter() entered = entered + 1 end + local dones = 0 + local fade + fade = Transition.new({ stack = StateStack }, function() + depthAtMidpoint = #StateStack.states + topAtMidpoint = StateStack:top() + StateStack:push(box) + end, function() dones = dones + 1 end, true) + StateStack:push(fade) + + local finishedOn + for frame = 1, 120 do + StateStack:update(1 / 60) + if dones > 0 and not finishedOn then finishedOn = frame end + end + + eq(finishedOn, Timing.WARP_FADE_OUT, "the warp hands back at the end of the fade") + eq(dones, 1, "and hands back exactly once") + eq(depthAtMidpoint, 1, "the fade is off the stack before the midpoint runs") + check(topAtMidpoint == ow, "so the map switch sees the overworld on top") + eq(entered, 1, "the state the midpoint pushed entered once") + eq(#StateStack.states, 2, "the fade is gone and the pushed state is not") + check(StateStack.states[1] == ow, "the overworld is still the base") + check(StateStack:top() == box, "the box the midpoint opened owns the screen") +end + +-- a midpoint that pushes nothing still leaves exactly the overworld behind +do + StateStack:init() + local ow = overworld() + StateStack:push(ow) + local mids, dones = 0, 0 + local fade = Transition.new({ stack = StateStack }, + function() mids = mids + 1 end, + function() dones = dones + 1 end, true) + StateStack:push(fade) + for _ = 1, 120 do StateStack:update(1 / 60) end + eq(mids, 1, "the map switched once") + eq(dones, 1, "the plain warp still hands back exactly once") + eq(#StateStack.states, 1, "and pops nothing but itself") + check(StateStack:top() == ow, "leaving the overworld on top") +end + +-- ------------------------------------------ the script fade is unchanged + +-- ViridianGym.asm .afterBeat / RocketHideoutB4F BeatGiovanniScript bracket +-- their HideObject with GBFadeOutToBlack -> GBFadeInFromBlack, so those keep +-- a real fade in and wait under whatever the midpoint opened. +do + StateStack:init() + local ow = overworld() + StateStack:push(ow) + + local box = { name = "script box" } + local dones = 0 + local fade = Transition.new({ stack = StateStack }, + function() StateStack:push(box) end, + function() dones = dones + 1 end, false) + StateStack:push(fade) + + for _ = 1, Timing.WARP_FADE_OUT + 8 do StateStack:update(1 / 60) end + eq(dones, 0, "a fade with a fade-in waits under what its midpoint opened") + check(StateStack:top() == box, "the pushed state is on top") + check(StateStack.states[2] == fade, "with the fade still underneath it") + + StateStack:pop() + for _ = 1, Timing.FADE_IN_FROM_BLACK + 8 do StateStack:update(1 / 60) end + eq(dones, 1, "and finishes once the box is gone") + eq(#StateStack.states, 1, "leaving the overworld alone on the stack") + check(StateStack:top() == ow, "and nothing else came off with it") +end + +-- ------------------------------------------------- finish is idempotent + +do + StateStack:init() + local ow = overworld() + StateStack:push(ow) + local dones = 0 + local fade = Transition.new({ stack = StateStack }, nil, + function() dones = dones + 1 end, true) + StateStack:push(fade) + fade:finish() + fade:finish() + eq(dones, 1, "a second finish does not hand back a second time") + eq(#StateStack.states, 1, "and takes nothing else off the stack") + check(StateStack:top() == ow, "the overworld survives the second call") +end + +-- a mod record may still ask a warp for a fade in; framesIn 0 is truthy in +-- Lua, so the built-in warp keeps its 0 and the retimed one keeps its own +do + local retimed = { transitions = { warp_fade = { kind = "fade", frames = 32, + framesIn = 16 } } } + local fade = Transition.new({ data = retimed, stack = StateStack }, nil, + nil, true) + eq(fade.framesIn, 16, "a retimed warp record keeps its fade in") + local vanilla = Transition.new({ stack = StateStack }, nil, nil, true) + eq(vanilla.framesIn, Timing.WARP_FADE_IN, "the built-in warp has none") +end + +StateStack:clear() + +T.finish("transition_identity_pop_bug1663") diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 573f4f5a..76a29eda 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -89,6 +89,15 @@ local MOVES = { accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" }, RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL", accuracy = 100, pp = 20, effect = "EFFECT_RAGE" }, + -- data/moves/moves.asm:133, :230, :189 and :92 rows, unedited. + BIDE = { id = "BIDE", name = "BIDE", power = 0, type = "NORMAL", + accuracy = 100, pp = 10, effect = "EFFECT_BIDE" }, + SLEEP_TALK = { id = "SLEEP_TALK", name = "SLEEP TALK", power = 0, + type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_SLEEP_TALK" }, + SNORE = { id = "SNORE", name = "SNORE", power = 40, type = "NORMAL", + accuracy = 100, pp = 15, effect = "EFFECT_SNORE", effectChance = 30 }, + SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, + type = "GRASS", accuracy = 100, pp = 10, effect = "EFFECT_SOLARBEAM" }, } local GROWTH = { @@ -2790,6 +2799,239 @@ end)() check("and its user reappears", b:volatile(player).vanished, nil) end)() +-- ------------------------------------------------------------------- Bide +-- +-- data/moves/effects.asm:795-800 runs `storeenergy` ahead of `doturn`, and +-- BattleCommand_DoTurn's mask drops SUBSTATUS_BIDE outright +-- (engine/battle/effect_commands.asm:977-979), so the whole Bide costs the +-- one PP its opening turn spent. The lock is ParsePlayerAction's own arm +-- (engine/battle/core.asm:569-576) for the player and CheckEnemyLockedIn +-- (:5650) for the foe. +;(function() + local function said(events, text) + for _, e in ipairs(events) do + if e.kind == "message" and e.text == text then return true end + end + return false + end + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = "BIDE", pp = 10, maxPp = 10 }, + { id = "TACKLE", pp = 35, maxPp = 35 } } + player.hp, player.maxHp = 999, 999 + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp, wild.maxHp = 9999, 9999 + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + b:takeEvents() + + check("nothing forces the first BIDE", b:forcedMove(player), nil) + b:useMove(player, wild, "BIDE") + b:takeEvents() + check("the opening turn spends one PP", player.moves[1].pp, 9) + check("and the FIGHT menu is locked into it", b:forcedMove(player), "BIDE") + check("with the move list narrowed to the one", + #b:usableMoves(player), 1) + + -- BattleCommand_StoreEnergy banks wPlayerDamageTaken while the bit is up. + b:dealDamage(wild, player, 5, {}) + b:takeEvents() + b:useMove(player, wild, "BIDE") + check("a storing turn spends no PP", player.moves[1].pp, 9) + check("and stays locked", b:forcedMove(player), "BIDE") + check("`.still_storing` prints rather than attacking", + said(b:takeEvents(), "CYNDAQUIL is storing energy!"), true) + + -- A dry BIDE keeps running: the bide arm jumps past MoveSelectionScreen, + -- where .CheckPlayerHasUsableMoves lives (engine/battle/core.asm:5058). + player.moves[1].pp = 0 + check("a spent BIDE is still offered", #b:usableMoves(player), 1) + check("...and it is the BIDE", b:usableMoves(player)[1].id, "BIDE") + player.moves[1].pp = 9 + + local hpBefore = wild.hp + b:useMove(player, wild, "BIDE") + b:takeEvents() + check("the release spends no PP either", player.moves[1].pp, 9) + check("UnleashEnergy pays back double", wild.hp, hpBefore - 10) + check("and the lock is gone", b:forcedMove(player), nil) + + -- CheckEnemyLockedIn holds SUBSTATUS_BIDE, so the AI is never asked. + local es = b:volatile(b.enemy) + es.bideTurns, es.bideMove, es.bideStored = 2, "BIDE", 0 + check("a biding foe re-uses its Bide", b:enemyMove(), "BIDE") + es.bideTurns, es.bideMove, es.bideStored = nil, nil, nil + + -- .reset_bide (engine/battle/core.asm:572-573, :627-629): the PACK cancels + -- a Bide, a switch does not. + b:useMove(player, wild, "BIDE") + b:takeEvents() + check("locked again", b:forcedMove(player), "BIDE") + b:takeTurn({ kind = "item", item = "POTION" }) + b:takeEvents() + check("using an item cancels the Bide", b:forcedMove(player), nil) + check("and drops the bank", b:volatile(player).bideStored, nil) +end)() + +-- --------------------------------------------------- Snore and Sleep Talk +-- +-- `.fast_asleep` prints FastAsleepText and then falls into `.not_asleep` for +-- those two moves instead of `call CantMove / jp EndTurn` +-- (engine/battle/effect_commands.asm:188-200). BattleCommand_SleepTalk opens +-- on ClearLastMove and ends in ResetTurn (move_effects/sleep_talk.asm:2, :61). +;(function() + local function said(events, text) + for _, e in ipairs(events) do + if e.kind == "message" and e.text == text then return true end + end + return false + end + local function sleeper(moves) + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = moves + player.hp, player.maxHp = 999, 999 + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp, wild.maxHp = 9999, 9999 + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + b:takeEvents() + return b, player, wild + end + + local b, player, wild = sleeper({ + { id = "SLEEP_TALK", pp = 10, maxPp = 10 }, + { id = "TACKLE", pp = 35, maxPp = 35 } }) + + player.status, player.statusTurns = "sleep", 3 + check("an ordinary move still loses the turn to sleep", + b:canAct(player, "TACKLE"), false) + check("and the counter was spent", player.statusTurns, 2) + check("FastAsleepText still goes up", + said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true) + + player.statusTurns = 3 + check("SLEEP TALK is let through", b:canAct(player, "SLEEP_TALK"), true) + check("...and still spends its sleep turn", player.statusTurns, 2) + check("...and still prints the line", + said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true) + player.statusTurns = 3 + check("SNORE is let through too", b:canAct(player, "SNORE"), true) + b:takeEvents() + + -- The wake-up arm is not a bypass: it answers for the whole turn. + player.statusTurns = 1 + check("the last sleep turn wakes up", b:canAct(player, "SLEEP_TALK"), true) + check("and clears the status", player.status, nil) + + player.status, player.statusTurns = "sleep", 5 + local hpBefore = wild.hp + b:useMove(player, wild, "SLEEP_TALK") + b:takeEvents() + check("SLEEP TALK pays its own PP through doturn", player.moves[1].pp, 9) + check("but the move it calls pays none (ResetTurn)", player.moves[2].pp, 35) + check("and that move really landed", wild.hp < hpBefore, true) + check("ClearLastMove leaves no last move (used_move_text.asm:30-36)", + b:volatile(player).lastMove, nil) + + -- .check_two_turn_move (sleep_talk.asm:117-141) drops the five charge + -- effects and EFFECT_BIDE, so a mon with nothing else fails. + local b2, player2, wild2 = sleeper({ + { id = "SLEEP_TALK", pp = 10, maxPp = 10 }, + { id = "SOLARBEAM", pp = 10, maxPp = 10 } }) + player2.status, player2.statusTurns = "sleep", 5 + b2:useMove(player2, wild2, "SLEEP_TALK") + check("a two-turn move is never sampled", + said(b2:takeEvents(), "But it failed!"), true) + check("and nothing was called", b2:volatile(player2).chargeMove, nil) + + -- BattleCommand_SleepTalk's own `and SLP_MASK / jr z, .fail` (:16-19). + local b3, player3, wild3 = sleeper({ + { id = "SLEEP_TALK", pp = 10, maxPp = 10 }, + { id = "TACKLE", pp = 35, maxPp = 35 } }) + b3:useMove(player3, wild3, "SLEEP_TALK") + check("an awake SLEEP TALK fails", + said(b3:takeEvents(), "But it failed!"), true) + + -- BattleCommand_Snore (move_effects/snore.asm:1-9) is the same refusal. + local b4, player4, wild4 = sleeper({ { id = "SNORE", pp = 15, maxPp = 15 } }) + local snoreBefore = wild4.hp + b4:useMove(player4, wild4, "SNORE") + check("an awake SNORE fails", said(b4:takeEvents(), "But it failed!"), true) + check("and deals nothing", wild4.hp, snoreBefore) + player4.status, player4.statusTurns = "sleep", 5 + b4:useMove(player4, wild4, "SNORE") + b4:takeEvents() + check("a sleeping SNORE hits", wild4.hp < snoreBefore, true) +end)() + +-- ------------------------------------------- fainted mons stop participating +-- +-- UpdateFaintedPlayerMon RESET_FLAGs wBattleParticipantsNotFainted +-- (engine/battle/core.asm:2551-2556) and .EvenlyDivideExpAmongParticipants +-- divides by the count of set bits (:7118-7130), so the survivor of a lost +-- lead collects a whole share, not half of one. +;(function() + local function twoMonBattle() + local one = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + one.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local two = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + two.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local b = Battle.new({ data = DATA, party = { one, two }, wild = wild, + random = zeroRandom }) + b:takeEvents() + return b, one, two, wild + end + + local b, one, two, wild = twoMonBattle() + check("the lead starts as a participant", b.participants[1], true) + one.hp = 0 + b:resolveFaints() + b:takeEvents() + check("a fainted participant drops out", b.participants[1], nil) + + -- The clear is one-shot: GiveExperiencePoints' `.done` falls through + -- ResetBattleParticipants into AddBattleParticipant (:7116, :3033-3037) and + -- puts the dead slot's bit back, and nothing takes it off again. + local oneShot = twoMonBattle() + oneShot.player.hp = 0 + oneShot:resolveFaints() + oneShot:takeEvents() + oneShot:resetParticipants() + oneShot:resolveFaints() + oneShot:takeEvents() + check("and the cart's own re-add survives a second pass", + oneShot.participants[1], true) + + b:switch(2) + b:takeEvents() + check("the replacement is a participant", b.participants[2], true) + check("...and the fainted lead is not", b.participants[1], nil) + local before = two.experience + wild.hp = 0 + b:resolveFaints() + b:takeEvents() + local shared = two.experience - before + + -- The control: the same KO with the same mon as the only party member. + local solo = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + solo.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local soloWild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + soloWild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local soloBattle = Battle.new({ data = DATA, party = { solo }, + wild = soloWild, random = zeroRandom }) + soloBattle:takeEvents() + local soloBefore = solo.experience + soloWild.hp = 0 + soloBattle:resolveFaints() + soloBattle:takeEvents() + check("the survivor gets a whole share, not half", + shared, solo.experience - soloBefore) + check("and the share is a real number", shared > 0, true) +end)() + print(("gen2 battle: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 6664e704..6b175588 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -549,6 +549,33 @@ do check("but the ITEM pocket still arms", tmPack.switching, 1) end +-- item_data_constants.asm:47 MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the +-- TM/HM pocket is wTMsHMs (ram/wram.asm:2421), NUM_TMS + NUM_HMS = 57 bytes: +-- 50 add_tm rows and 7 add_hm rows in constants/item_constants.asm:220-293. +do + local Bag = require("src.inventory.Bag") + check("ITEM pocket is MAX_ITEMS", Bag.capacity(packGame.data, "ITEM"), 20) + check("BALL pocket is MAX_BALLS", Bag.capacity(packGame.data, "BALL"), 12) + check("KEY_ITEM pocket is MAX_KEY_ITEMS", + Bag.capacity(packGame.data, "KEY_ITEM"), 25) + check("TM/HM pocket is NUM_TMS + NUM_HMS", + Bag.capacity(packGame.data, "TM_HM"), 57) + + -- Bag.add tests the cap before inserting, so all 57 cart TM/HMs fit. + local tmData = { items = {}, constants = { bagSize = 2 } } + for i = 1, 58 do + tmData.items["TM_FIX_" .. i] = { id = "TM_FIX_" .. i, name = "TM" .. i, + pocket = "TM_HM", index = 200 + i } + end + check("a mod's bagSize resizes the ITEM pocket only", + Bag.capacity(tmData, "TM_HM"), 57) + local tmSave = { inventory = {}, bagOrder = {} } + for i = 1, 57 do Bag.add(tmSave, "TM_FIX_" .. i, 1, tmData) end + check("all 57 of them fit", Bag.slots(tmSave, tmData, "TM_HM"), 57) + check("and a 58th TM/HM id has no byte to live in", + Bag.add(tmSave, "TM_FIX_58", 1, tmData), false) +end + -- CANCEL sits one past the last row. check("cancel is past the end", pack:total(), #pack.rows + 1) pack.index = pack:total()