diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35533cd1..61e838dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -468,3 +468,24 @@ jobs: if [ "$found" = "0" ]; then echo "no committed mods to lint" fi + + luacheck: + name: engine lint (luacheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: install luacheck + run: | + set -e + sudo apt-get update + sudo apt-get install -y lua5.4 liblua5.4-dev luarocks + sudo luarocks install luacheck || sudo apt-get install -y lua-check + luacheck --version + + - name: luacheck gate (undefined globals, unreachable code) + run: ./scripts/lint.sh --gate + + - name: luacheck full report (advisory) + continue-on-error: true + run: ./scripts/lint.sh diff --git a/.luacheckrc b/.luacheckrc index 3d7f91a1..2b1ec13d 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -23,6 +23,10 @@ read_globals = { -- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks -- it; without this, every `table.unpack` reads as an undefined field. table = { fields = { "unpack" } }, + "POKEPORT_DISPLAY_COMPANION", + "POKEPORT_EDITOR_MODE", + "rawlen", + package = { fields = { "searchers" } }, } -- Vendored/native trees and the test suites have their own conventions. @@ -30,6 +34,7 @@ exclude_files = { "mobile/", "tests/", "tools/save-editor/", + "tools/save_convert/vendor/", } ignore = { diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 94221532..98016848 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -240,7 +240,7 @@ local function stepGate(opts) push(game, text(game)[opts.text] or opts.fallback, function() ow.player.facing = opts.push if not ow:checkLedgeHop(opts.push) then - ow:scriptMove(ow.player, opts.push, 1) + ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true }) end end) return true @@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = { -- on the west-side cells and walks you back local function bikeGateGuard(coords, stopText, explainText) return function(game, ow, x, y) - if game.save.inventory.BICYCLE then return false end + local bike = game.save.inventory.BICYCLE + if bike and bike ~= 0 then return false end if not inCoords(coords, x, y) then return false end -- walk the player up to the tile beside the counter, no further: -- (matchedY - closestY) tiles, 0 when already next to it @@ -842,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText) -- (PlayerMovingRightScript). Without it the player was left -- parked beside the guard's counter with no way past. #518 local function shoveRight() - ow:scriptMove(ow.player, "right", 1) + ow:scriptMove(ow.player, "right", 1, nil, { collide = true }) end if dist > 0 then - ow:scriptMove(ow.player, "up", dist, shoveRight) + ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true }) else shoveRight() end diff --git a/main.lua b/main.lua index c73b10ec..8cbf4c7d 100644 --- a/main.lua +++ b/main.lua @@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport") -- Lua errors: persist a redacted trace in the save dir and surface a hint. do - local defaultErrorHandler = love.errorhandler + local defaultErrorHandler = love.errorhandler or love.errhand function love.errorhandler(msg) - local hint = SwitchDiagnostics.logLuaError(msg) - if hint and type(msg) == "string" then + local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg) + if ok and hint and type(msg) == "string" then msg = msg .. "\n\n" .. hint end if defaultErrorHandler then diff --git a/scripts/lint.sh b/scripts/lint.sh index bf2ece34..48c72ae0 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -7,7 +7,8 @@ # the nil global), unused values, unreachable code. The .luacheckrc mutes the # cosmetic categories the codebase lives with, so what prints is worth a look. # -# scripts/lint.sh lint src/ +# scripts/lint.sh full advisory report over every shipped tree +# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511) # scripts/lint.sh src tools lint specific paths # # Install once with: luarocks install luacheck @@ -15,9 +16,27 @@ set -uo pipefail cd "$(dirname "$0")/.." +DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools) + +GATE=0 +if [ "${1:-}" = "--gate" ]; then + GATE=1 + shift +fi + if ! command -v luacheck >/dev/null 2>&1; then echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2 exit 2 fi -luacheck "${@:-src}" +if [ "$#" -gt 0 ]; then + PATHS=("$@") +else + PATHS=("${DEFAULT_PATHS[@]}") +fi + +if [ "$GATE" = "1" ]; then + exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511 +fi + +luacheck "${PATHS[@]}" diff --git a/scripts/test.sh b/scripts/test.sh index fffa984c..57be8d0a 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -66,6 +66,15 @@ run_tier() { # ------- ROM-free tiers: these are what CI runs +if command -v luacheck >/dev/null 2>&1; then + run_tier "T0 luacheck gate (undefined globals, unreachable code)" \ + ./scripts/lint.sh --gate +else + echo "" + echo "-- T0 luacheck gate: skipped (no luacheck on PATH --" + echo " luarocks install luacheck; CI installs and gates on it regardless)" +fi + run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3a005361..bae8aaa7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -538,6 +538,7 @@ end local function makeBattler(data, mon, isPlayer, save) local def = data.pokemon[mon.species] + require("src.pokemon.Stats").ensure(def, mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil if isPlayer and save then diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 1912967e..2dfdc191 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -645,7 +645,9 @@ MoveEffects.full = { user.bideTurns = ctx.rng(2, 3) user.bideDamage = 0 ctx.battle:cancelMoveAnim() - ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM") + ctx.battle:animBeforeMove( + user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM", + user.isPlayer) ctx.say(Strings("%s\nis storing energy!", displayName(user))) end, }, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 9346f9dd..3c089a24 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -2417,6 +2417,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker) self.enemyIndex = target self.enemy = party[target] self.enemy.volatile = carried + -- engine/battle/move_effects/baton_pass.asm:59 + self:resetParticipants() end local sent = side == "player" and self.player or self.enemy self:emit({ kind = "send", side = side, mon = sent, @@ -2679,6 +2681,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender, self.enemyIndex = pick self.enemy = incoming self.stages.enemy = Battle.newStages() + -- ForceEnemySwitch (engine/battle/core.asm:2937) + self:resetParticipants() end self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming, hp = incoming.hp or 0, status = incoming.status or false, @@ -3341,7 +3345,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved) local moveDef = self:moveDef(moveId) local moveName = (moveDef and moveDef.name) or moveId if ok then + -- data/text/common_3.asm:119 self:emit({ kind = "message", + sfx = "Sfx_DexFanfare5079", waitSfx = true, text = self:monName(mon) .. " learned " .. moveName .. "!" }) elseif reason == "full" then -- LearnMove's full-moveset arm calls ForgetMove, which asks with @@ -3450,8 +3456,7 @@ function Battle:awardExperience(loser) -- GiveExperiencePoints .done falls through ResetBattleParticipants into -- AddBattleParticipant (engine/battle/core.asm:7116 and :3033). - self.participants = {} - if self.playerIndex then self.participants[self.playerIndex] = true end + self:resetParticipants() end -- The answer to a `choose-forget`: drop the move in `slot` and put the @@ -3472,7 +3477,9 @@ function Battle:resolveForget(index, slot, entry, moveName) end self:emit({ kind = "message", text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" }) + -- engine/pokemon/learn.asm:115, data/text/common_3.asm:119 self:emit({ kind = "message", + sfx = "Sfx_DexFanfare5079", waitSfx = true, text = self:monName(mon) .. " learned " .. (moveName or (entry and entry.id) or "?") .. "!" }) -- The forget path writes the slot itself rather than going through @@ -3511,6 +3518,13 @@ function Battle:switchLocked() return self:volatile(self.enemy).trapsTarget == true end +-- ResetBattleParticipants falls through into AddBattleParticipant +-- (engine/battle/core.asm:3033 and :3037). +function Battle:resetParticipants() + self.participants = {} + if self.playerIndex then self.participants[self.playerIndex] = true end +end + -- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch -- (engine/battle/core.asm:2959-2961). function Battle:shiftSwitch(index) @@ -3999,6 +4013,8 @@ function Battle:enemyTrySwitchOrItem() .. self:monName(outgoing) .. "!" }) self.enemyIndex = target self.enemy = self.enemyParty[target] + -- AI_Switch (engine/battle/ai/items.asm:697) + self:resetParticipants() -- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove -- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so -- the mon coming IN starts from an empty area -- the same pair of clears diff --git a/src/core/Game2.lua b/src/core/Game2.lua index c1236a37..8b87ed00 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -483,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone) if onDone then onDone(learned) end end if ok then + -- data/text/common_3.asm:119 return self:say(("%s learned\n%s!"):format(name, moveName), - function() finish(true) end) + function() finish(true) end, + TextBox.soundOpts(self, "Sfx_DexFanfare5079")) end if reason ~= "full" then return finish(false) end local askForget, pickMove, askStop @@ -539,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- The slot is written here rather than through Mon.learnMove, so -- pokemon.move_learned is raised here too. ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) + -- engine/pokemon/learn.asm:115, data/text/common_3.asm:119 self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") :format(name, oldName, name, moveName), - function() finish(true) end) + function() finish(true) end, + TextBox.soundOpts(self, "Sfx_DexFanfare5079")) end, }) end @@ -706,7 +710,9 @@ function Game2:usePartyItem(itemId) }) elseif action == "candy" then self:consumeItem(itemId) - self:say(result.text, function() self:afterRareCandy(mon, result) end) + -- data/text/common_1.asm:86 + self:say(result.text, function() self:afterRareCandy(mon, result) end, + result.sfx and TextBox.soundOpts(self, result.sfx) or nil) else self:consumeItem(itemId) self:say(result.text) @@ -765,8 +771,10 @@ function Game2:useSelectItem() local name = (items[itemId] and items[itemId].name) or itemId self:say(Strings("{PLAYER} used the\n%s.", name)) elseif outcome == "trophy_sent" then + -- data/text/common_3.asm:372 self:say(Strings( - "There was a trophy\ninside!\fThe trophy was\nsent home.")) + "There was a trophy\ninside!\fThe trophy was\nsent home."), + nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079")) end -- Anything else (a fishing bite, the ITEMFINDER's queued script) already -- drives its own presentation off World:step -- nothing left to print here. @@ -778,9 +786,9 @@ end -- onDone that popped again ate the state UNDER the box: dismissing a message -- over the PACK closed the PACK with it, and over an empty overworld stack it -- was a silent extra pop. -function Game2:say(text, onDone) +function Game2:say(text, onDone, opts) local TextBox = require("src.render.TextBox") - self.stack:push(TextBox.new(self, text, onDone)) + self.stack:push(TextBox.new(self, text, onDone, opts)) end -- The landmark the player is standing in, for the Pokegear map's marker. diff --git a/src/core/gen2/ItemEffects.lua b/src/core/gen2/ItemEffects.lua index 241eaaed..4aec70ed 100644 --- a/src/core/gen2/ItemEffects.lua +++ b/src/core/gen2/ItemEffects.lua @@ -240,6 +240,8 @@ local function rareCandy(mon, data) used = true, level = newLevel, learned = learned, + -- data/text/common_1.asm:86 + sfx = "Sfx_DexFanfare5079", text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), } end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index d2ff2435..683ccb24 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5151,6 +5151,7 @@ function RomExtractorGen2:extractMenuGfx() } out.pack = pack out.pokedex = self:pokedexGfx() + out.billsPc = self:billsPcGfx() out.pokegear = self:pokegearGfx() out.trainerCard = self:trainerCardGfx() out.unownPuzzle = self:unownPuzzleGfx() @@ -5697,6 +5698,25 @@ function RomExtractorGen2:pokedexGfx() return dex end +-- BillsPC_InitGFX's four tiles at vTiles2 $5c +-- (engine/pokemon/bills_pc.asm:2170-2173) +function RomExtractorGen2:billsPcGfx() + if not self.symbols["PCMailGFX"] then return nil end + local pc = {} + local gfx = self:symbol("PCMailGFX") + self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16), + 32, 8, "pc/mail_item.png") + pc.icons = "assets/generated/pc/mail_item.png" + pc.firstTile = 0x5c + pc.palette = self:predefPal(PREDEFPAL_POKEDEX) + -- gfx/pc/orange.pal + if self.symbols["BillsPCOrangePalette"] then + local orange = self:symbol("BillsPCOrangePalette") + pc.orangePalette = self:colors(orange.bank, orange.address, 4) + end + return pc +end + -- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's -- own comment says "repeat count, tile ID" and has it backwards: it loads b -- from the first byte, c from the second, and writes `b` c times. $ff ends diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index f1dff9c9..8b1b741d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -146,6 +146,9 @@ 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. + "assets/generated/pc/mail_item.png", }, } -- Same Gen 2 extract, so a Silver cache is complete when the same files exist. diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 72b66d30..e2e9e771 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -808,6 +808,31 @@ local function buildOverworld() local Movement = rawRequire("src.script.gen2.Movement") local HiddenItems = rawRequire("src.world.gen2.HiddenItems") local Bike = rawRequire("src.world.gen2.Bike") + local FieldMoves = rawRequire("src.world.gen2.FieldMoves") + + -- home/map.asm:1869 + local function stepAllowed(world, entity, dir) + local d = Map2.DELTA[dir] + if not (world.map and d and entity and entity.cellX) 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 + 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] + 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 + if e.cellX == tx and e.cellY == ty then return false end + if e.moving and e.targetX == tx and e.targetY == ty then return false end + end + end + return true + end local api = nil -- one WorldAPI instance, so queueScript reuses the five-verb allow list @@ -1011,20 +1036,25 @@ local function buildOverworld() -- Gold has ONE movement slot; a second concurrent call is refused with a -- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe). - function ow.scriptMove(entity, dir, tiles, onDone) + function ow.scriptMove(entity, dir, tiles, onDone, opts) local world = w("scriptMove") if not world then return nil, "no overworld" end if world.moveState then return nil, "a movement is already running" end local step = Movement.stepByte(dir) if not step then return nil, "unknown direction: " .. tostring(dir) end - local bytes = {} - for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end - bytes[#bytes + 1] = Movement.STEP_END local objectId = objectIdOf(world, entity) if not objectId then return nil, "no Gen 2 objectId for that entity: only the player and a " .. "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 + end + local bytes = {} + for _ = 1, n do bytes[#bytes + 1] = step end + bytes[#bytes + 1] = Movement.STEP_END world:beginMovement(objectId, bytes, onDone) return true end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ddfc3f12..84b087cd 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = { ["src.ui.OptionsMenu"] = true, } +local function crossGenerationDenial(name, generation) + if type(name) ~= "string" or generation ~= 1 then return nil end + if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then + return nil + end + return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs " + .. "it reads and writes are not this game's, so anything it stores lands " + .. "on the save in the wrong shape. Take the game from mod.game and the " + .. "world from mod.world, which resolve per generation"):format(name) +end + -- the src.* modules the mod surface points authors at: another mod's -- exports carry a version string that wants range-checking before use, and -- ChipAsm is the authoring path for chip music and sfx @@ -214,6 +225,7 @@ function Loader:_installDevShim() if owner or callerIsMod(3) then local id = type(owner) == "string" and owner or nil local denial = Sandbox.moduleDenial(name, devShim.permissions[id]) + or (id and crossGenerationDenial(name, devShim.generation)) if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end end if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 791778e4..f08638c3 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -51,11 +51,18 @@ end -- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" -> -- 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 so --- a tampered save cannot overfill the bar. A mon that already has stats is --- returned untouched, so a vanilla save round-trips. #233, #304 +-- 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). +local function statsComplete(stats) + for _, key in ipairs(ORDER) do + if type(stats[key]) ~= "number" then return false end + end + return true +end + function Stats.ensure(speciesDef, mon) - if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end + if type(mon) ~= "table" then return mon end + if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then return mon end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 679de38e..f5d9adae 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -2707,7 +2707,9 @@ function BattleState:pushCaught(enemy, itemId) -- so the contest branch is BELOW them (item_effects.asm:528-546), and -- CheckReceivedDex gates the pair (:532-533). if not knew and self:hasPokedex() then + -- data/text/common_3.asm:285 self:push({ kind = "message", + sfx = "Sfx_SlotMachineStart", waitSfx = true, text = self:name(enemy) .. "'s data was newly added to the #DEX." }) self:push({ kind = "dex-entry", species = enemy.species }) end diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index c4abcbaa..1da75594 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -47,6 +47,7 @@ local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local Mail = require("src.core.gen2.Mail") local Palettes = require("src.world.gen2.Palettes") +local PartyMenu = require("src.ui.gen2.PartyMenu") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Unown = require("src.core.gen2.Unown") @@ -65,6 +66,15 @@ 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. +local BILLS_PC_ORANGE = { + { 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 }, +} + +-- PCMonInfo prints the held-item icon at hlcoord 7, 12 +-- (engine/pokemon/bills_pc.asm:1093). +local ICON_X, ICON_Y = 7, 12 + -- 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). @@ -272,6 +282,8 @@ function BoxMenu:beginMove() -- to the submenu; here the message holds until a button clears it and the -- list comes back, which is the same place the player ends up. self.phase = nil + -- engine/pokemon/bills_pc.asm:1607 + self:playSfx("Sfx_Wrong") self.message = reason return end @@ -299,9 +311,13 @@ end function BoxMenu:doWithdraw() local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) if not ok then + -- engine/pokemon/bills_pc.asm:1845 + self:playSfx("Sfx_Wrong") self.message = result return end + -- engine/pokemon/bills_pc.asm:1817 + self:playMonCry(result) self.message = nil self.phase = nil self:clampIndex() @@ -311,9 +327,13 @@ end function BoxMenu:doDeposit() local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) if not ok then + -- engine/pokemon/bills_pc.asm:1790 + self:playSfx("Sfx_Wrong") self.message = result return end + -- engine/pokemon/bills_pc.asm:1762 + self:playMonCry(result) self.message = nil self.phase = nil self.index, self.scroll = 1, 0 @@ -486,6 +506,8 @@ function BoxMenu:update(_dt) if not ok then -- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor, -- so the refusal leaves the cursor exactly where it was. + -- engine/pokemon/bills_pc.asm:1567 + self:playSfx("Sfx_Wrong") self.message = reason else self:insertMon() @@ -528,6 +550,14 @@ 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) +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 + local cries = data.audio and data.audio.cries + if cries and cries[mon.species] then Sound.playCry(data, mon.species) end +end + -- BillsPC's RELEASE, which the model has always supported and nothing on -- screen reached. The cart asks first and starts the prompt on NO, the way -- every irreversible choice in the game does. @@ -541,6 +571,8 @@ function BoxMenu:askRelease() local allowed, refusal = self:checkMailPreventBlackout() if not allowed then self.phase = nil + -- engine/pokemon/bills_pc.asm:1607 + self:playSfx("Sfx_Wrong") self.message = refusal return end @@ -568,6 +600,8 @@ function BoxMenu:askRelease() self.message = err return end + -- engine/pokemon/bills_pc.asm:1866 + self:playMonCry(mon) self.message = name .. " was released." self.phase = nil self:clampIndex() @@ -627,14 +661,31 @@ function BoxMenu:picFor(mon) return self:image(path) end +-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369 +function BoxMenu:panelColors(speciesId, shiny) + if self.phase == "submenu" or self.phase == "insert" then + return self.palettes + and Palettes.monColors(self.palettes, speciesId, shiny) + end + local gfx = (self.menuGfx or {}).billsPc + return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE +end + +-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021) +function BoxMenu:fillPicBlock(colors) + local G = love.graphics + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) + G.setColor(1, 1, 1, 1) +end + -- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4 -- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0. function BoxMenu:drawPicBlock(image, colors) if not image then return end local G = love.graphics - local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } - G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) - G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) + self:fillPicBlock(colors) local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7] G.setColor(1, 1, 1, 1) @@ -650,12 +701,12 @@ function BoxMenu:drawPicBlock(image, colors) end function BoxMenu:drawPic(mon) - local image = self:picFor(mon) - if not image then return end -- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box -- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293). - local colors = self.palettes - and Palettes.monColors(self.palettes, mon.species, mon.shiny) + local colors = self:panelColors(mon.species, mon.shiny) + local image = self:picFor(mon) + -- engine/pokemon/bills_pc.asm:1009-1011 + if not image then return self:fillPicBlock(colors) end self:drawPicBlock(image, colors) end @@ -664,17 +715,14 @@ end -- with the party list's ICON_EGG standing in for a cache built before that. function BoxMenu:drawEggPic(mon) local G = love.graphics - local colors = self.palettes - and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny) + local colors = self:panelColors("EGG", mon and mon.shiny) local gfx = (self.menuGfx or {}).eggHatch local image = self:image(gfx and gfx.egg) if image then return self:drawPicBlock(image, colors) end + self:fillPicBlock(colors) local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG image = self:image(entry and entry.image) if not image then return end - local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } - G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) - G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) -- The ICON_EGG sheet stacks its frames; the first is the egg at rest. local w = entry.width or 16 local h = math.min(entry.height or 16, image:getHeight()) @@ -694,6 +742,29 @@ function BoxMenu:drawEggPic(mon) G.setColor(1, 1, 1, 1) end +-- ItemIsMail picks $5c over $5d at hlcoord 7, 12 +-- (engine/pokemon/bills_pc.asm:1079-1094) +function BoxMenu:drawHeldIcon(mon) + local row = PartyMenu.heldMarkerRow(mon) + if not row then return end + local gfx = (self.menuGfx or {}).billsPc + local image = self:image(gfx and gfx.icons) + if not image then return end + local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8, + image:getDimensions()) + if not ok then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) 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 @@ -793,7 +864,10 @@ function BoxMenu:drawPanel() Chrome.print("\xe2\x99\x80", 5, 12) end Chrome.print(mon.name or mon.species or "?", PIC_X, 14) + self:drawHeldIcon(mon) end + else + self:fillPicBlock(self:panelColors()) end -- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 9ef4babe..1d9b4f5f 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -320,12 +320,15 @@ function ItemPcMenu:leaveDeposit() end function ItemPcMenu:offerToDeposit(id, count) - -- .TryDepositItem's `.no_toss` arm is a bare ret: a KEY ITEM or HM stays in - -- the bag with no message at all. - if self:cantToss(id) then return end if (count or 0) < 1 then return end local def = self:def(id) local name = (def and def.name) or id + -- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no + -- quantity is always x1 and never reaches .AskQuantity. + if self:cantToss(id) then + self:deposit(id, name, 1) + return + end self:askQuantity(count, { "How many do you", "want to deposit?" }, function(qty) self:deposit(id, name, qty) end) @@ -519,8 +522,11 @@ function ItemPcMenu:drawList() 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. - Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) + -- 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). + if not self:cantToss(entry.id) then + Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) + end elseif i == self:listTotal() then if i == self.listIndex then Chrome.cursor(5, ty) end Chrome.print("CANCEL", 6, ty) diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 10384b76..721601af 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -192,6 +192,14 @@ function PackMenu:pocketOf(itemId) return (def and def.pocket) or "ITEM" end +-- engine/items/tmhm.asm:341 +function PackMenu:tmhmKey(itemId) + local def = self.items and self.items[itemId] + local n = def and tonumber(def.tmNumber) + if n then return n end + return 1000 + ((def and tonumber(def.index)) or 0) +end + -- The name on the row. An inventory key with no ItemAttributes row behind it -- (an older cache, a mod's own item, a driver seeding an id that is not in -- items.lua) still has to draw something a person can read, so the id stands @@ -239,6 +247,15 @@ 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. + if pocket == "TM_HM" then + table.sort(rows, function(a, b) + local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id) + if ka ~= kb then return ka < kb end + return a.id < b.id + end) + end self.rows = rows self.index = math.min(self.index, #rows + 1) if self.index < 1 then self.index = 1 end @@ -359,6 +376,9 @@ function PackMenu:useSelected() -- with sound_dex_fanfare_50_79 between them; the PACK's box here holds -- 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") + end self.message = { "There was a trophy", "inside!", "{PLAYER} sent the", "trophy home." } self:rebuild() @@ -641,6 +661,9 @@ function PackMenu:openTeachParty(row) if id == moveId then allowed = true end end 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 game.say then game:say(("%s can't learn %s!"):format( require("src.battle.gen2.Mon").displayName(mon), moveName)) @@ -740,7 +763,9 @@ function PackMenu:update(_dt) end -- engine/items/pack.asm:1290 Pack_InterpretJoypad .select +-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT. function PackMenu:armSwitch() + if self:pocket().id == "TM_HM" then return end if self:isCancel() then return end if not self.rows[self.index] then return end self.switching = self.index diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1bc821ee..e0391b3f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -150,6 +150,21 @@ local function pooledNPC(pool, data, mapId, obj) end OverworldState.pooledNPC = pooledNPC -- exposed for tests +local function nearestWalkableCell(map, x, y) + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + if math.abs(dx) == r or math.abs(dy) == r then + local nx, ny = x + dx, y + dy + if map:inBounds(nx, ny) and map:isWalkableCell(nx, ny) then + return nx, ny + end + end + end + end + end +end + -- connection hops rendered around the current map: two, so -- corner-adjacent maps (connections of connections) don't pop in and -- out of the survey zoom at the seams (constants.world.neighborHops) @@ -414,6 +429,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts) table.insert(self.npcs, npc) end end + if opts and opts.via == "boot" and self.map:inBounds(x, y) + and not self.map:isWalkableCell(x, y) and not self.map:isWaterCell(x, y) then + local rx, ry = nearestWalkableCell(self.map, x, y) + if rx then + Logger.warn("saved position %s (%d,%d) is not walkable; moved to (%d,%d)", + mapId, x, y, rx, ry) + x, y = rx, ry + end + end if self.player then self.player.cellX, self.player.cellY = x, y self.player.px, self.player.py = x * 16, y * 16 @@ -4113,7 +4137,7 @@ function OverworldState:checkBadgeGate() Game.stack:push(TextBox.new(Game, (t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!")) .. (t._Route22GateGuardICantLetYouPassText or ""), function() - self:scriptMove(p, "down", 1) + self:scriptMove(p, "down", 1, nil, { collide = true }) end)) return true end @@ -4141,7 +4165,7 @@ function OverworldState:checkBadgeGate() 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) + self:scriptMove(p, "down", 1, nil, { collide = true }) end)) return true end @@ -4181,7 +4205,7 @@ function OverworldState:checkForcedMovement() function() local back = ({ up = "down", down = "up", left = "right", right = "left" })[p.facing] - self:scriptMove(p, back, 1) + self:scriptMove(p, back, 1, nil, { collide = true }) end)) return true end @@ -4222,6 +4246,7 @@ function OverworldState:checkSeafoamCurrent() -- push so the B3F stair warps underfoot cannot bounce you back. self.forcedWarp = false require("src.core.Sound").play(Game.data, "Collision") + -- home/overworld.asm:1891 self:scriptMove(p, "up", c.y == 17 and 2 or 1) return true end @@ -4780,9 +4805,10 @@ end -- scripted movement -- ------------------------------------------------------------------------- -function OverworldState:scriptMove(entity, dir, tiles, onDone) +function OverworldState:scriptMove(entity, dir, tiles, onDone, opts) table.insert(self.scriptMoves, { entity = entity, dir = dir, remaining = tiles, onDone = onDone, + collide = opts and opts.collide or nil, }) end @@ -4822,14 +4848,20 @@ function OverworldState:updateScriptMoves() e.moving = true e.marching = true e.progress = 0 + mv.remaining = mv.remaining - 1 + elseif mv.collide + and not Collision.canMove(self.map, self.entities, e, mv.dir) then + -- home/overworld.asm:1224 + e.facing = mv.dir + mv.remaining = 0 else e.facing = mv.dir local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir) e.targetX, e.targetY = tx, ty e.moving = true e.progress = 0 + mv.remaining = mv.remaining - 1 end - mv.remaining = mv.remaining - 1 end end -- march_in_place toggles: re-arm the in-place cycle each time it ends. diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 28ea2689..d41587d0 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -83,6 +83,7 @@ local SFX = { WARP_TO = 19, EXIT_BUILDING = 35, JUMP_OVER_LEDGE = 0x16, + BUMP = 0x24, } local EMOTE_SHOCK = 0 @@ -2183,6 +2184,13 @@ 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. +function World:bumpSound() + if Sound.sfxBusy() then return end + self:playSfxNamed("Sfx_Bump", SFX.BUMP) +end + -- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: -- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes -- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the @@ -9031,6 +9039,9 @@ function World:movePlayer(dir) elseif result == "blocked" or result == "edge" then self.turningDirection = nil end + -- .NotMoving and .Ice's own arm (player_movement.asm:102 and :87), which + -- .TryJump's carry (:376) returns above. + if result == "blocked" then self:bumpSound() end -- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has -- already moved OBJECT_TILE_COLLISION onto the destination. if result == "moved" then self:playerStepGrass() end @@ -9738,7 +9749,8 @@ function World:stepBody() local result = self:movePlayer(dir) if result == "edge" then - self:tryConnection(dir) + -- A border block is a wall: engine/overworld/player_movement.asm:264 + if not self:tryConnection(dir) then self:bumpSound() end elseif result == "blocked" and p.facing == dir then -- .CheckNPC came back 2: something movable is in the way. The step is -- lost either way, and the boulder is what moves. diff --git a/tests/drivers/bide_anim_bug375_test.lua b/tests/drivers/bide_anim_bug375_test.lua index 72ea43d9..64d19608 100644 --- a/tests/drivers/bide_anim_bug375_test.lua +++ b/tests/drivers/bide_anim_bug375_test.lua @@ -83,15 +83,36 @@ return function(game) -- the announcement-time row performMove inserts directly as well as the -- ones animNext adds, which is the whole difference the fix makes. local function watchAnims(battle) - local seen, mark = {}, {} + local seen, mark, types = {}, {}, {} return seen, function() for _, row in ipairs(battle.queue) do if row.anim and not mark[row] then mark[row] = true seen[#seen + 1] = row.anim + types[row.anim] = row.hit and row.hit.animType or false end end + end, types + end + + -- engine/battle/animations.asm:427-437 + local function rideAndSample(battle, poll, frames, shotPath) + local peak, shot = 0, false + for i = 1, frames do + poll() + if i % 6 == 0 then U.tap(battle.game, "a") end + local fx = battle.fx + if fx and fx.shakeX and not battle.animPlaying then + local dx = math.abs(fx.shakeX) + if dx > peak then peak = dx end + if dx > 0 and not shot then + shot = true + U.shot(battle.game, shotPath) + end + end + U.wait(1) end + return peak end local function listOf(seen) @@ -136,23 +157,28 @@ return function(game) local battle = BattleState.newWild(game, "PIDGEY", 8) battle.onFinish = function() end ow:pushBattle(battle) - local seen, poll = watchAnims(battle) + local seen, poll, types = watchAnims(battle) check("the battle reached the menu", waitPhase(battle, "menu", 60, poll)) check("BIDE is the move on the list", pickBide(battle, poll)) for _ = 1, 20 do poll() U.wait(1) end U.shot(game, DIR .. "/bug375_store_spiral.png") - for _ = 1, 30 do poll() U.wait(1) end + + local peak = rideAndSample(battle, poll, 180, + DIR .. "/bug1564_store_shake.png") U.shot(game, DIR .. "/bug375_store_text.png") check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM")) check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE")) check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns", battle.player.bideTurns ~= nil) + check("the storing spiral carries wAnimationType 6 (#1564)", + types["XSTATITEM_ANIM"] == 6) + check(("the screen shook, peak dx = %d px (want 3)"):format(peak), peak == 3) -- ride out the locked turns: the storing text needs A presses and the menu -- never comes back until BIDE unleashes local released = false - for _ = 1, 120 do + for _ = 1, 220 do U.tap(game, "a") for _ = 1, 6 do poll() U.wait(1) end if has(seen, "BIDE") then @@ -179,7 +205,7 @@ return function(game) foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } } foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves ow:pushBattle(foeBattle) - local foeSeen, foePoll = watchAnims(foeBattle) + local foeSeen, foePoll, foeTypes = watchAnims(foeBattle) check("the foe's battle reached the menu", waitPhase(foeBattle, "menu", 60, foePoll)) pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does @@ -192,10 +218,20 @@ return function(game) check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: " .. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM")) + local foePeak = rideAndSample(foeBattle, foePoll, 180, + DIR .. "/bug1564_enemy_store_shake.png") + check("the foe's spiral carries wAnimationType 3 (#1564)", + foeTypes["XSTATITEM_DUPLICATE_ANIM"] == 3) + check(("the foe's shake peaked at %d px (want 6)"):format(foePeak), + foePeak == 6) + U.log("The pad is yours in a battle where both sides know only BIDE, so") U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes") - U.log("white and balls spiral inward, silently, and then it says storing") - U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it") + U.log("white and balls spiral inward, silently. Then, still in silence, the") + U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and") + U.log("back, twice, and only then does it say storing energy. On the foe's") + U.log("turn the same creep goes out to 6 px and takes twice as long.") + U.log("No flash on the RATTATA and no BIDE thud until the turn it") U.log("unleashes, where the thud and its animation come after the text and") U.log("before the enemy HP bar slides down. The foe's spiral looks the same.") diff --git a/tests/drivers/bike_gate_wall_bug1548_test.lua b/tests/drivers/bike_gate_wall_bug1548_test.lua new file mode 100644 index 00000000..d55c78d6 --- /dev/null +++ b/tests/drivers/bike_gate_wall_bug1548_test.lua @@ -0,0 +1,101 @@ +-- scripts/Route16Gate1F.asm:38 / home/overworld.asm:1224 +-- data/maps/force_bike_surf.asm:5 +-- POKEPORT_DRIVER=tests/drivers/bike_gate_wall_bug1548_test.lua \ +-- POKEPORT_IDENTITY=bug1548 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Watch the sprite after each teleport: it must never overlap the gate +-- building's black outline. Compare .bazinga/august21p2/media/1548-1.jpg. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local OverworldState = require("src.world.OverworldController") + + 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 settle() + for _ = 1, 12 do + if game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(25) + end + U.wait(60) + end + local function landed() + local ow = game.overworld + local p = ow.player + return p.cellX, p.cellY, ow.map:isWalkableCell(p.cellX, p.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) } + bikeless() + + local fm = game.data.field.forcedMovement + check("field.forcedMovement carries the Route 16 cells", + fm and fm.tiles and fm.tiles.ROUTE_16 ~= nil) + + for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 }, + { "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do + bikeless() + U.teleport(game, c[1], c[2], c[3], "left") + U.wait(20) + settle() + local x, y, walk = landed() + U.log(("%s (%d,%d) facing left -> (%d,%d) walkable=%s") + :format(c[1], c[2], c[3], x, y, tostring(walk))) + check(("%s (%d,%d): the refusal leaves the player on a walkable cell") + :format(c[1], c[2], c[3]), walk) + end + + bikeless() + U.teleport(game, "ROUTE_16", 17, 10, "left") + U.wait(20) + settle() + U.shot(game, SHOT_DIR .. "/bug1548_route16_after.png") + + bikeless() + U.teleport(game, "ROUTE_16", 16, 10, "right") + U.wait(20) + U.hold(game, "right", 24) + settle() + local bx, by = landed() + U.log(("stepped onto ROUTE_16 (17,10) from the west -> (%d,%d)"):format(bx, by)) + check("a shove with open ground behind it still moves one cell", + bx == 16 and by == 10) + + bikeless() + while game.stack:top() do game.stack:pop() end + game.stack:push(OverworldState, "ROUTE_16", 18, 10, "left") + game.overworld:setMap("ROUTE_16", 18, 10, "left", { via = "boot" }) + U.wait(20) + local cx, cy, cwalk = landed() + U.log(("boot inside the wall (18,10) -> (%d,%d) walkable=%s") + :format(cx, cy, tostring(cwalk))) + check("a save parked inside the gate wall is lifted out on load", cwalk) + settle() + local dx, dy, dwalk = landed() + U.log(("after the refusal that follows -> (%d,%d) walkable=%s") + :format(dx, dy, tostring(dwalk))) + check("and the refusal it lands on cannot push it back in", dwalk) + U.shot(game, SHOT_DIR .. "/bug1548_repaired.png") + + game.save.inventory.BICYCLE = 1 + game.save.onBike, game.save.forcedBike = false, nil + U.teleport(game, "ROUTE_16", 17, 10, "left") + U.wait(30) + local ex, ey, ewalk = landed() + check("with a BICYCLE the forced tile mounts instead of shoving", + game.save.onBike == true and ex == 17 and ey == 10 and ewalk) + + U.log(ok and "all clear" or "a check failed") + while true do coroutine.yield() end +end diff --git a/tests/drivers/errorhandler_probe_bug1517_test.lua b/tests/drivers/errorhandler_probe_bug1517_test.lua new file mode 100644 index 00000000..51b3bebd --- /dev/null +++ b/tests/drivers/errorhandler_probe_bug1517_test.lua @@ -0,0 +1,46 @@ +-- Driver: #1517's other half, the error handler that hid the crash. +-- +-- boot.lua's deferErrhand picks `love.errorhandler or love.errhand`, so a +-- love.errorhandler that returns nil ends love.run's `while func do` loop and +-- the process leaves to the OS with no error screen and nothing on stdout. +-- LOVE 11.5 pre-populates love.errhand only, so main.lua capturing +-- love.errorhandler captured nil and every Lua error in every shipped build +-- exited silently. Measured on the installed 11.5: +-- love.errorhandler type: nil / love.errhand type: function +-- +-- Nothing here can be a unit test: the deliverable is whether a human sees +-- LOVE's blue error screen or an app that vanishes. +-- +-- POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love . +-- +-- Pre-fix: the window closes on the first drawn frame after the probe arms, +-- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler +-- probe" plus the lua-error.log hint, and stays up until it is dismissed. +return function(game) + local U = dofile("tests/drivers/util.lua") + + U.log("[1517] love.errorhandler at boot was: " + .. (rawget(love, "errorhandler") and "a function" or "nil")) + U.log("[1517] love.errhand is: " .. type(rawget(love, "errhand"))) + + U.wait(30) + + -- Raise from a draw callback, not from this coroutine: main.lua resumes the + -- driver under coroutine.resume and prints its errors itself, which is the + -- one path that does NOT reach love.errorhandler. StateStack:draw only + -- calls the states it is actually holding, so the probe goes on the live + -- top state rather than on game.overworld, which is not on the stack while + -- the title screen or the load menu is up. + local target = game.stack:top() + U.log("[1517] arming the probe on " .. tostring(target and target.screenId)) + local realDraw = target.draw + target.draw = function(self, ...) + if realDraw then realDraw(self, ...) end + error("bug1517: error-handler probe") + end + + U.log("[1517] armed; the next drawn frame raises from love.draw") + U.log("[1517] you should now see LOVE's blue error screen, not a closed app") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_bug1425_test.lua b/tests/drivers/gold_bug1425_test.lua index b282ad18..ec4481c0 100644 --- a/tests/drivers/gold_bug1425_test.lua +++ b/tests/drivers/gold_bug1425_test.lua @@ -70,15 +70,18 @@ return function(game) U.tap(game, "right") shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none - -- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks - -- TM_HEADBUTT while "Where should this be moved to?" holds the box. + -- SELECT armed the row here for #1427; since #1567 it is filtered out of the + -- TM/HM pocket entirely (engine/items/tmhm.asm:207), so these taps must leave + -- the list exactly as shot 03 has it: no hollow ▷, no "Where should this be + -- moved to?", no reorder. U.tap(game, "down") U.tap(game, "select") - shot("04-tmhm-armed") + shot("04-tmhm-select-ignored") U.tap(game, "up") - shot("05-tmhm-destination") + shot("05-tmhm-still-numbered") U.tap(game, "a") - shot("06-tmhm-moved") -- HEADBUTT is now the first TM row + U.tap(game, "b") + shot("06-tmhm-unchanged") U.log("[driver] bag order:", table.concat(Bag.order(save), " ")) diff --git a/tests/drivers/gold_bug1486_test.lua b/tests/drivers/gold_bug1486_test.lua new file mode 100644 index 00000000..1bb5e6f9 --- /dev/null +++ b/tests/drivers/gold_bug1486_test.lua @@ -0,0 +1,112 @@ +-- Assertion driver: a KEY ITEM and an HM through the player's item PC. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1486_test.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Issue #1486. What the unit tier cannot show: the two screens a player +-- actually looks at. .DepositItem (engine/events/pokecenter_pc.asm:504) reads +-- CANT_TOSS only to force x1 and skip .AskQuantity, and PlaceMenuItemQuantity +-- (engine/menus/menu_2.asm:18) draws no ×NN under such a row. +-- +-- Shots land in /tmp/gold-bug1486: +-- +-- 01-key-items-pocket.png the DEPOSIT chooser on the KEY ITEMS pocket +-- 02-deposited.png "Deposited 1 BICYCLE(S)." -- before the fix this +-- is the unchanged pocket with no message at all +-- 03-pc-list.png the PC list: POTION with its ×03, BICYCLE and +-- HM01 with no ×NN at all +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1486" + local fails = 0 + + local function ok(cond, msg) + if cond then print("[1486] ok " .. msg) + else fails = fails + 1 print("[1486] FAIL " .. msg) end + return cond + end + + 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 4) + end + + U.wait(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save = game.save + + local Bag = require("src.inventory.Bag") + local Mon = require("src.battle.gen2.Mon") + save.party = { Mon.new(game.data, "CYNDAQUIL", 10) } + save.inventory = {} + save.pcItems = { POTION = 3 } + assert(Bag.add(save, "BICYCLE", 1, game.data), "BICYCLE into the bag") + assert(Bag.add(save, "HM_CUT", 1, game.data), "HM01 into the bag") + + local function topId() + local top = game.stack:top() + return top and top.screenId or nil + end + + assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"), + "setMap CHERRYGROVE_POKECENTER_1F failed") + U.wait(5) + local pcX, pcY + for cy = 0, w.map.heightCells - 1 do + for cx = 0, w.map.widthCells - 1 do + if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end + end + end + assert(pcX, "no COLL_PC tile in the Pokecenter") + assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"), + "setMap onto the PC tile failed") + U.wait(5) + + tap("a", 8) -- PCScript + tap("a", 4) -- the turn-on line + tap("down", 4) + tap("a", 4) -- 's PC + tap("a", 4) + tap("a", 6) -- both PokecenterPlayersPCText pages + ok(topId() == "Gen2ItemPcMenu", + "'s PC opens the item PC (top: " .. tostring(topId()) .. ")") + + -- DEPOSIT ITEM -> the PACK chooser -> the KEY ITEMS pocket. + tap("down", 4) + tap("a", 6) + tap("right", 4) -- ITEM -> BALL + tap("right", 4) -- BALL -> KEY ITEMS + U.shot(game, out .. "/01-key-items-pocket.png") + tap("a", 6) -- the BICYCLE row: x1, no prompt + ok(save.pcItems.BICYCLE == 1, + "the BICYCLE landed in the PC (" .. tostring(save.pcItems.BICYCLE) .. ")") + ok(save.inventory.BICYCLE == nil, + "and left the bag (" .. tostring(save.inventory.BICYCLE) .. ")") + U.shot(game, out .. "/02-deposited.png") + tap("a", 4) -- the Deposited line + + -- The same, one pocket over: an HM. + tap("right", 4) -- KEY ITEMS -> TM/HM + tap("a", 6) + ok(save.pcItems.HM_CUT == 1, + "HM01 landed in the PC (" .. tostring(save.pcItems.HM_CUT) .. ")") + ok(save.inventory.HM_CUT == nil, + "and left the bag (" .. tostring(save.inventory.HM_CUT) .. ")") + tap("a", 4) + tap("b", 6) -- close the PACK + + -- WITHDRAW ITEM: the list is where PlaceMenuItemQuantity shows. + tap("up", 4) + tap("a", 6) + U.shot(game, out .. "/03-pc-list.png") + tap("a", 6) -- the first row, x1 or the selector + print(("[1486] %d failures"):format(fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_bug1556_battle.lua b/tests/drivers/gold_bug1556_battle.lua new file mode 100644 index 00000000..d1cdc71e --- /dev/null +++ b/tests/drivers/gold_bug1556_battle.lua @@ -0,0 +1,142 @@ +-- #1556, the two battle jingles: _NewDexDataText's sound_slot_machine_start +-- (data/text/common_3.asm:285) on a first catch, and _LearnedMoveText's +-- sound_dex_fanfare_50_79 (data/text/common_3.asm:119) on a level-up move. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_battle.lua \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-battle (default) +-- +-- Both ride the message queue's `sfx` + `waitSfx` keys, which the "Gotcha!" +-- line and the "grew to level" line already used -- the two lines next door +-- to them did not. So the ear is comparing neighbours: the catch jingle must +-- be followed by a SECOND, different one over the #DEX line, and the level-up +-- fanfare by a second over "learned". +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Sound = require("src.core.Sound") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-battle" + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name) + heard[#heard + 1] = name + return realPlay(data, name) + end + local function reset() heard = {} end + local function report(label, want) + U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)") + U.log(" want:", want) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save, data = game.save, game.data + + local function battleScreen() + local top = game.stack:top() + return (top and top.battle) and top or nil + end + + local function tapUntil(predicate, tries, btn) + for _ = 1, tries or 400 do + if predicate() then return true end + U.tap(game, btn or "a") + U.wait(2) + end + return predicate() + end + + -- ---- 1. a first catch: "Gotcha!" then the #DEX line --------------------- + save.party = { Mon.new(data, "CYNDAQUIL", 20) } + save.inventory = { MASTER_BALL = 1 } + save.pokedexReceived = true + save.pokedex = { seen = {}, caught = {} } + + local wild = Mon.new(data, "PIDGEY", 3) + assert(wild, "the cache carries no PIDGEY") + assert(world:startBattle({ wild = wild }), "the wild battle refused to start") + assert(tapUntil(function() + local screen = battleScreen() + return screen ~= nil and screen.phase == "menu" + end), "the battle never reached BattleMenu") + local screen = battleScreen() + + -- BattleMenuHeader's 2x2 grid: 1 FIGHT / 2 PkMn, 3 PACK / 4 RUN. + if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end + if screen.menuIndex <= 2 then U.tap(game, "down") U.wait(3) end + U.tap(game, "a") + U.wait(6) + local pack = game.stack:top() + assert(pack and pack.rows, "the battle PACK did not open") + local ballRow + for index, row in ipairs(pack.rows) do + if row.id == "MASTER_BALL" then ballRow = index end + end + assert(ballRow, "the battle PACK does not show the MASTER BALL") + for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end + reset() + U.tap(game, "a") + U.wait(180) -- the throw, the wobbles, "Gotcha!" + report("01 catch:", + "Sfx_CaughtMon, then Sfx_SlotMachineStart over the #DEX line") + U.shot(game, out .. "/01-caught.png") + for _ = 1, 6 do U.tap(game, "a") U.wait(20) end + U.shot(game, out .. "/02-dex-line.png") + for _ = 1, 30 do + if not battleScreen() then break end + U.tap(game, "a") + U.wait(10) + end + + -- ---- 2. a level-up that teaches a move --------------------------------- + local SPECIES = "CYNDAQUIL" + local def = data.pokemon and data.pokemon[SPECIES] + local target + for _, entry in ipairs((def and def.levelMoves) or {}) do + if entry.level and entry.level > 6 and not target then target = entry.level end + end + if not target then + U.log("SKIP 02 -- no level-up move row for " .. SPECIES) + else + local hero = Mon.new(data, SPECIES, target - 1) + local growth = Mon.growthFor(data, def.growthRate) + hero.experience = Mon.experienceForLevel(growth, target) - 1 + -- Only three moves, so LearnMove takes the free slot rather than ForgetMove + -- (engine/pokemon/learn.asm:23-29). + while hero.moves and #hero.moves > 3 do table.remove(hero.moves) end + save.party = { hero } + save.inventory = {} + U.log(("levelling %s %d -> %d for its %s row") + :format(SPECIES, hero.level, target, tostring(target))) + + local prey = Mon.new(data, "PIDGEY", 2) + prey.hp = 1 + assert(world:startBattle({ wild = prey }), "the second battle refused") + assert(tapUntil(function() + local s = battleScreen() + return s ~= nil and s.phase == "menu" + end), "the second battle never reached BattleMenu") + screen = battleScreen() + if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end + if screen.menuIndex > 2 then U.tap(game, "up") U.wait(3) end + reset() + U.tap(game, "a") -- FIGHT + U.wait(6) + U.tap(game, "a") -- the first move + U.wait(240) + report("02 level-up move:", + "Sfx_DexFanfare5079 twice: \"grew to level\" and \"learned\"") + U.shot(game, out .. "/03-level-up.png") + for _ = 1, 8 do U.tap(game, "a") U.wait(30) end + U.shot(game, out .. "/04-learned.png") + end + + Sound.play = realPlay + U.log("done -- the controls are yours") +end diff --git a/tests/drivers/gold_bug1556_billspc.lua b/tests/drivers/gold_bug1556_billspc.lua new file mode 100644 index 00000000..3868fede --- /dev/null +++ b/tests/drivers/gold_bug1556_billspc.lua @@ -0,0 +1,196 @@ +-- #1556: Bill's PC -- the deposit / withdraw / release cry, and the six +-- SFX_WRONG refusals (engine/pokemon/bills_pc.asm). +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_billspc.lua \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-billspc (default) +-- +-- Eight moments, each seeded, driven through the real screen and screenshotted. +-- The log names what sounded; the ear is the point. DepositPokemon and +-- TryWithdrawPokemon both `call PlayMonCry` right after RemoveMonFromPartyOrBox +-- (:1762 and :1817), and PlayMonCry itself bails on an EGG +-- (home/pokemon.asm:113), so a boxed egg must move in silence. +-- +-- The run ends with the PC 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") +local Sound = require("src.core.Sound") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-billspc" + + local heard = {} + local realPlay, realCry = Sound.play, Sound.playCry + Sound.play = function(data, name) + heard[#heard + 1] = name + return realPlay(data, name) + end + Sound.playCry = function(data, species, clip) + heard[#heard + 1] = "cry:" .. tostring(species) + return realCry(data, species, clip) + end + + local function reset() heard = {} end + local function report(label, want) + U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)") + U.log(" want:", want) + end + + 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) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save = game.save + local data = game.data + + local function mon(species, level) + return Mon.new(data, species, level) + end + + 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 + + local function fillBox(index, n, species) + local box = Boxes.box(save, index) + for i = #box, 1, -1 do box[i] = nil end + for _ = 1, n do box[#box + 1] = mon(species or "RATTATA", 5) end + end + + local function setParty(n, species) + save.party = {} + for _ = 1, n do save.party[#save.party + 1] = mon(species or "CYNDAQUIL", 10) end + end + + save.currentBox = 1 + + -- ---- 1. WITHDRAW a mon: its cry ------------------------------------------ + setParty(1) + fillBox(1, 1, "PIDGEY") + local menu = openBox("withdraw") + reset() + tap("a") -- the submenu + tap("a", 12) -- WITHDRAW + report("01 withdraw:", "cry:PIDGEY") + U.shot(game, out .. "/01-withdraw-cry.png") + tap("a") -- clear anything on screen + close() + + -- ---- 2. DEPOSIT it back: the cry again ----------------------------------- + setParty(3) + save.party[#save.party + 1] = mon("PIDGEY", 5) + fillBox(1, 0) + menu = openBox("deposit") + for _ = 1, 3 do tap("down") end + reset() + tap("a") + tap("a", 12) -- DEPOSIT + report("02 deposit:", "cry:PIDGEY") + U.shot(game, out .. "/02-deposit-cry.png") + tap("a") + close() + + -- ---- 3. WITHDRAW into a full party: SFX_WRONG (:1845) -------------------- + setParty(Boxes.PARTY_SIZE) + fillBox(1, 1, "PIDGEY") + menu = openBox("withdraw") + reset() + tap("a") + tap("a", 12) + report("03 withdraw, party full:", "Sfx_Wrong + \"You can't take any more\"") + U.shot(game, out .. "/03-party-full.png") + tap("a") + close() + + -- ---- 4. DEPOSIT into a full box: SFX_WRONG (:1790) ---------------------- + setParty(3) + fillBox(1, Boxes.MONS_PER_BOX) + menu = openBox("deposit") + reset() + tap("a") + tap("a", 12) + report("04 deposit, box full:", "Sfx_Wrong + \"The BOX is full.\"") + U.shot(game, out .. "/04-box-full.png") + tap("a") + close() + + -- ---- 5. DEPOSIT the last healthy mon: SFX_WRONG ------------------------- + setParty(2) + save.party[2].hp = 0 + fillBox(1, 0) + menu = openBox("deposit") + reset() + tap("a") + tap("a", 12) + report("05 deposit, last healthy:", "Sfx_Wrong + \"can't deposit the last\"") + U.shot(game, out .. "/05-last-mon.png") + tap("a") + close() + + -- ---- 6. RELEASE a boxed mon: its cry (:1866) ---------------------------- + setParty(3) + fillBox(1, 1, "SENTRET") + menu = openBox("withdraw") + reset() + tap("select", 8) -- RELEASE asks first + tap("up") -- the YES/NO box defaults to NO + tap("a", 14) + report("06 release:", "cry:SENTRET before \"was released.\"") + U.shot(game, out .. "/06-release-cry.png") + tap("a") + close() + + -- ---- 7. RELEASE an EGG: SFX_WRONG, no cry (:1625) ----------------------- + setParty(3) + fillBox(1, 0) + local egg = mon("TOGEPI", 5) + egg.isEgg = true + Boxes.box(save, 1)[1] = egg + menu = openBox("withdraw") + reset() + tap("select", 10) + report("07 release an EGG:", "Sfx_Wrong, and NO cry:TOGEPI") + U.shot(game, out .. "/07-release-egg.png") + tap("a") + close() + + -- ---- 8. MOVE into a full box: SFX_WRONG (:1567) ------------------------- + setParty(3) + fillBox(1, 1, "HOOTHOOT") + fillBox(2, Boxes.MONS_PER_BOX) + menu = openBox("move") + reset() + tap("a") -- the MOVE/STATS/CANCEL submenu + tap("a", 8) -- MOVE -> the insert cursor + tap("right", 8) -- box 2, which is full + tap("a", 10) + report("08 move into a full box:", "Sfx_Wrong + \"There's no room!\"") + U.shot(game, out .. "/08-no-room.png") + + Sound.play, Sound.playCry = realPlay, realCry + U.log("done -- the PC is open; the controls are yours") +end diff --git a/tests/drivers/gold_bug1556_bump.lua b/tests/drivers/gold_bug1556_bump.lua new file mode 100644 index 00000000..d56139e5 --- /dev/null +++ b/tests/drivers/gold_bug1556_bump.lua @@ -0,0 +1,203 @@ +-- #1556: the bump sound, which the Gen 2 port never wired. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_bump.lua \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-bump (default) +-- +-- Nothing here can be asserted by a unit test: the deliverable is the sound. +-- What the log gives a reader is the RATE, which is the half that is easy to +-- get wrong -- .BumpSound is `call CheckSFX / ret c` +-- (engine/overworld/player_movement.asm:771), a busy-channel test rather than +-- a frame counter, so a held direction re-rings only once the previous Sfx_Bump +-- has stopped. A per-frame count means Sound.sfxBusy() is not seeing it. +-- +-- Five moments, in the cart's own terms: +-- 1. a wall .CheckLandPerms carry -> .bump (:264-265) +-- 2. an NPC .CheckNPC = 0 -> .bump (:267-269) +-- 3. a ledge hop .TryJump's carry returns ABOVE .NotMoving (:80-81), +-- so the hop is Sfx_JumpOverLedge and NO bump +-- 4. a map connection the step is taken; silence +-- 5. a map edge with no connection behind it: the border block is a wall +-- +-- The run ends in the overworld so a human takes the controls where it stops. +local U = require("tests.drivers.util") + +local Permissions = require("src.world.gen2.Permissions") +local Sound = require("src.core.Sound") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-bump" + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name) + heard[#heard + 1] = name + return realPlay(data, name) + end + + local function reset() heard = {} end + + local function report(label) + local counts, order = {}, {} + for _, name in ipairs(heard) do + if not counts[name] then order[#order + 1] = name end + counts[name] = (counts[name] or 0) + 1 + end + local parts = {} + for _, name in ipairs(order) do + parts[#parts + 1] = ("%s x%d"):format(name, counts[name]) + end + U.log(label, #parts > 0 and table.concat(parts, ", ") or "(silence)") + return counts + end + + U.wait(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + + local function walkable(map, x, y) + return map:inBounds(x, y) and map:isWalkable(x, y) + end + + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + + -- A standing cell whose neighbour in `dir` is a wall. + local function findWall(map) + for y = 0, map.heightCells - 1 do + for x = 0, map.widthCells - 1 do + if walkable(map, x, y) then + for dir, d in pairs(DELTA) do + local nx, ny = x + d[1], y + d[2] + if map:inBounds(nx, ny) and not map:isWalkable(nx, ny) then + return x, y, dir + end + end + end + end + end + end + + -- ---- 1. a wall ----------------------------------------------------------- + assert(w:setMap("PLAYERS_HOUSE_1F", 4, 4, "down"), "setMap PLAYERS_HOUSE_1F") + U.wait(5) + local wx, wy, wdir = findWall(w.map) + if wx then + assert(w:setMap("PLAYERS_HOUSE_1F", wx, wy, wdir)) + U.wait(8) + reset() + U.hold(game, wdir, 90) + U.wait(5) + local counts = report("01 wall (" .. wdir .. ", 90 frames held):") + U.log(" Sfx_Bump over 90 held frames:", counts.Sfx_Bump or 0, + "-- a per-frame count here means the CheckSFX gate is not working") + U.shot(game, out .. "/01-wall.png") + else + U.log("SKIP 01 wall -- no wall cell found in PLAYERS_HOUSE_1F") + end + + -- ---- 2. an NPC ----------------------------------------------------------- + local npc, npcDir + for _, e in ipairs(w.entities or {}) do + if e ~= w.player and e.cellX and not e.bigObject then + for dir, d in pairs(DELTA) do + local sx, sy = e.cellX - d[1], e.cellY - d[2] + if walkable(w.map, sx, sy) then npc, npcDir = { sx, sy }, dir break end + end + end + if npc then break end + end + if npc then + assert(w:setMap("PLAYERS_HOUSE_1F", npc[1], npc[2], npcDir)) + U.wait(8) + reset() + U.hold(game, npcDir, 45) + U.wait(5) + report("02 NPC (" .. npcDir .. "):") + U.shot(game, out .. "/02-npc.png") + else + U.log("SKIP 02 NPC -- no reachable NPC in PLAYERS_HOUSE_1F") + end + + -- ---- 3. a ledge ---------------------------------------------------------- + local ROUTES = { "ROUTE_29", "ROUTE_30", "ROUTE_31", "ROUTE_32", + "ROUTE_33", "ROUTE_34", "ROUTE_35", "ROUTE_36" } + local hopped = false + for _, mapId in ipairs(ROUTES) do + if w:setMap(mapId, 5, 5, "down") then + U.wait(5) + local map = w.map + for y = 0, map.heightCells - 1 do + for x = 0, map.widthCells - 1 do + local facings = Permissions.ledgeFacings(map:cellCollision(x, y)) + if facings then + for dir, on in pairs(facings) do + local d = on and DELTA[dir] + if d and walkable(map, x + d[1] * 2, y + d[2] * 2) then + assert(w:setMap(mapId, x, y, dir)) + U.wait(8) + reset() + U.hold(game, dir, 40) + U.wait(20) + local counts = report( + ("03 ledge hop (%s %s):"):format(mapId, dir)) + U.log(" want Sfx_JumpOverLedge and NO Sfx_Bump; bumps:", + counts.Sfx_Bump or 0) + U.shot(game, out .. "/03-ledge.png") + hopped = true + break + end + end + end + if hopped then break end + end + if hopped then break end + end + end + if hopped then break end + end + if not hopped then U.log("SKIP 03 ledge -- no hoppable ledge found") end + + -- ---- 4 and 5. map edges -------------------------------------------------- + assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN") + U.wait(5) + local DIR_CONN = { up = "north", down = "south", left = "west", + right = "east" } + local map = w.map + local function edgeCell(dir) + local d = DELTA[dir] + for y = 0, map.heightCells - 1 do + for x = 0, map.widthCells - 1 do + if walkable(map, x, y) + and not map:inBounds(x + d[1], y + d[2]) then + return x, y + end + end + end + end + for _, dir in ipairs({ "up", "down", "left", "right" }) do + local conn = map:connection(DIR_CONN[dir]) + local ex, ey = edgeCell(dir) + if ex then + assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir)) + U.wait(8) + reset() + U.hold(game, dir, 40) + U.wait(20) + local counts = report(("%s edge %s (connection: %s):") + :format(conn and "04" or "05", dir, + tostring(conn and conn.mapId or "none"))) + U.log(" want", conn and "silence" or "Sfx_Bump", "-- bumps:", + counts.Sfx_Bump or 0) + U.shot(game, out .. ("/0%s-edge-%s.png"):format(conn and "4" or "5", dir)) + assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir)) + U.wait(5) + end + end + + Sound.play = realPlay + assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down")) + U.log("done -- the controls are yours; walk into anything") +end diff --git a/tests/drivers/gold_bug1556_fanfare.lua b/tests/drivers/gold_bug1556_fanfare.lua new file mode 100644 index 00000000..ad5cf71e --- /dev/null +++ b/tests/drivers/gold_bug1556_fanfare.lua @@ -0,0 +1,207 @@ +-- #1556: the field jingles that ride a TX_SOUND at the end of a string -- +-- RARE CANDY's level-up, "X learned MOVE!", the trophy -- plus the TM/HM +-- refusals, which are `call PlaySFX` next to the message instead. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_fanfare.lua \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-fanfare (default) +-- +-- The one deliberate SILENCE is moment 4: TeachTMHM's `.compatible` arm is +-- `callfar KnowsMove / jr c, .nope` (engine/items/tmhm.asm:139-140) with no +-- PlaySFX at all, so "X already knows MOVE!" must stay mute. Only the +-- INCOMPATIBLE arm rings (:131). +-- +-- The box is supposed to HOLD for the fanfare before it takes a button: +-- TextCommand_SOUND is PlaySFX then WaitSFX (home/text.asm), which +-- TextBox.soundOpts models with auto.wait. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local PackMenu = require("src.ui.gen2.PackMenu") +local Sound = require("src.core.Sound") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-fanfare" + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name) + heard[#heard + 1] = name + return realPlay(data, name) + end + local function reset() heard = {} end + local function report(label, want) + U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)") + U.log(" want:", want) + end + + 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) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save, data = game.save, game.data + + local SPECIES = "CYNDAQUIL" + local def = data.pokemon and data.pokemon[SPECIES] + assert(def, "the cache carries no " .. SPECIES) + + -- Which TMs this species may and may not learn, straight off its BASE_TMHM. + local canLearn, cannotLearn = nil, nil + local allowed = {} + for _, moveId in ipairs(def.tmhm or {}) do allowed[moveId] = true end + for itemId, item in pairs(data.items or {}) do + if item.teaches and tostring(itemId):sub(1, 3) == "TM_" then + if allowed[item.teaches] then + canLearn = canLearn or itemId + else + cannotLearn = cannotLearn or itemId + end + end + end + U.log("TM that fits:", tostring(canLearn), + "-- TM that does not:", tostring(cannotLearn)) + + local function seed() + local mon = Mon.new(data, SPECIES, 12) + save.party = { mon } + return mon + end + + local function openPack() + local pack = PackMenu.new(game, { + save = save, world = w, onClose = function() end }) + game.stack:push(pack) + U.wait(6) + return pack + end + + local field = game.overworld or game.stack:top() + local function popToField() + for _ = 1, 12 do + if game.stack:top() == field or not game.stack:top() then break end + game.stack:pop() + U.wait(2) + end + end + + -- ---- 1. RARE CANDY: "X grew to level N!" + the level-up fanfare --------- + local mon = seed() + save.inventory = { RARE_CANDY = 2 } + reset() + game:usePartyItem("RARE_CANDY") + U.wait(8) + tap("a", 30) -- the party pick + report("01 rare candy:", "Sfx_DexFanfare5079 under \"grew to level 13!\"") + U.shot(game, out .. "/01-rare-candy.png") + tap("a", 10) + popToField() + + -- ---- 2. a TM the mon CAN learn: "X learned MOVE!" + the fanfare --------- + if canLearn then + mon = seed() + save.inventory = { [canLearn] = 1 } + local pack = openPack() + reset() + pack:openTeachParty({ id = canLearn }) + U.wait(10) + tap("a", 30) -- the party pick + report("02 TM " .. canLearn .. ":", + "Sfx_DexFanfare5079 under \"learned ...!\"") + U.shot(game, out .. "/02-tm-learned.png") + tap("a", 10) + popToField() + else + U.log("SKIP 02 -- no TM in this cache that " .. SPECIES .. " can learn") + end + + -- ---- 3. a TM the mon CANNOT learn: SFX_WRONG (tmhm.asm:131) ------------ + if cannotLearn then + mon = seed() + save.inventory = { [cannotLearn] = 1 } + local pack = openPack() + reset() + pack:openTeachParty({ id = cannotLearn }) + U.wait(10) + tap("a", 20) + report("03 TM " .. cannotLearn .. ":", + "Sfx_Wrong under \"can't learn ...!\"") + U.shot(game, out .. "/03-tm-refused.png") + tap("a", 10) + popToField() + else + U.log("SKIP 03 -- every TM in this cache fits " .. SPECIES) + end + + -- ---- 4. a TM the mon ALREADY KNOWS: SILENCE ---------------------------- + if canLearn then + mon = seed() + local moveId = data.items[canLearn].teaches + mon.moves = mon.moves or {} + mon.moves[1] = { id = moveId, pp = 10, maxPp = 10 } + save.inventory = { [canLearn] = 1 } + local pack = openPack() + reset() + pack:openTeachParty({ id = canLearn }) + U.wait(10) + tap("a", 20) + report("04 TM already known:", "SILENCE -- KnowsMove has no PlaySFX") + U.shot(game, out .. "/04-tm-already-known.png") + tap("a", 10) + popToField() + end + + -- ---- 5. a TM on an EGG: SFX_WRONG (tmhm.asm:104-111), a regression ----- + if canLearn then + mon = seed() + mon.isEgg = true + save.inventory = { [canLearn] = 1 } + local pack = openPack() + reset() + pack:openTeachParty({ id = canLearn }) + U.wait(10) + tap("a", 20) + report("05 TM on an EGG:", "Sfx_Wrong, and the list stays up") + U.shot(game, out .. "/05-tm-egg.png") + tap("b", 6) + popToField() + end + + -- ---- 6. the trophy box: _SentTrophyHomeText's fanfare ------------------ + if data.items and data.items.NORMAL_BOX then + seed() + save.inventory = { NORMAL_BOX = 1 } + save.decorations = save.decorations or {} + local pack = openPack() + pack:rebuild() + local row + for index, entry in ipairs(pack.rows) do + if entry.id == "NORMAL_BOX" then row = index end + end + if row then + pack.index = row + reset() + pack:useSelected() + U.wait(20) + report("06 trophy box:", "Sfx_DexFanfare5079 with the trophy line") + U.shot(game, out .. "/06-trophy.png") + tap("a", 6) + else + U.log("SKIP 06 -- the NORMAL BOX is not in the ITEM pocket here") + end + popToField() + else + U.log("SKIP 06 -- this cache has no NORMAL_BOX") + end + + Sound.play = realPlay + U.log("done -- the controls are yours") +end diff --git a/tests/drivers/gold_bug1567_test.lua b/tests/drivers/gold_bug1567_test.lua new file mode 100644 index 00000000..5e2cf9ad --- /dev/null +++ b/tests/drivers/gold_bug1567_test.lua @@ -0,0 +1,105 @@ +-- #1567: the TM/HM pocket's row order. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1567_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1567 (default) +-- +-- Nothing here can be asserted: the bug IS the order rows land in. +-- TMHM_DisplayPocketItems walks the fixed 57-byte wTMsHMs array from 1 to 57 +-- and prints every non-zero slot (engine/items/tmhm.asm:341), so the cart's +-- pocket is always TM01..TM50 then HM01..HM07 no matter what the player picked +-- up first. The port drew it from the acquisition-ordered bag list instead. +-- +-- The seed is deliberately scrambled -- gold_bug1425_test.lua seeds its TMs +-- already in numeric order, which is why its screenshots never showed this. +-- +-- The run ends with the PACK still open on the TM pocket, so a human takes the +-- controls exactly where the screenshots stop. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") + +-- Picked up back to front: TM/HM numbers 57, 50, 51, 5, 1, 53. TM_ROAR is the +-- one on the far side of the ITEM_C3 hole (id $c4, number 5), so it also pins +-- that the number and not the item id is what sorts. +local SEED = { + { "HM_WATERFALL", 1 }, + { "TM_NIGHTMARE", 2 }, + { "HM_CUT", 1 }, + { "TM_ROAR", 3 }, + { "TM_DYNAMICPUNCH", 12 }, + { "HM_SURF", 1 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1567" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + local id, count = entry[1], entry[2] + if not (game.data.items and game.data.items[id]) then + U.log("[driver] SKIP", id, "-- not in this cache") + else + save.inventory[id] = count + table.insert(Bag.order(save), id) + end + end + -- A POTION and a SUPER POTION so the ITEM pocket can be compared against the + -- TM pocket at the end: SELECT must still arm a row over there. + for _, entry in ipairs({ { "POTION", 5 }, { "SUPER_POTION", 2 } }) do + if game.data.items and game.data.items[entry[1]] then + save.inventory[entry[1]] = entry[2] + table.insert(Bag.order(save), entry[1]) + end + end + + U.log("[driver] pickup order:", table.concat(Bag.order(save), " ")) + + local pack = PackMenu.new(game, { save = save, world = game.world, + onClose = function() end }) + game.stack:push(pack) + + shot("00-items") -- POTION over SUPER POTION, pickup order + U.tap(game, "right") + U.tap(game, "right") + U.tap(game, "right") + -- TM01 DYNAMICPUNCH ×12, TM05 ROAR ×03, TM50 NIGHTMARE ×02, then HM01 CUT, + -- HM03 SURF, HM07 WATERFALL with no ×NN at all (engine/items/tmhm.asm:390). + -- Before the fix this read HM07, TM50, HM01, TM05, TM01, HM03. + shot("01-tmhm-numbered") + + local order = {} + for i = 1, #pack.rows do order[i] = pack.rows[i].id end + U.log("[driver] TM/HM rows:", table.concat(order, " ")) + + -- engine/items/tmhm.asm:207 filters SELECT out of this pocket: no hollow ▷, + -- no "Where should this be moved to?". + U.tap(game, "select") + shot("02-tmhm-select-ignored") + U.tap(game, "down") + U.tap(game, "select") + shot("03-tmhm-select-ignored-row2") + + -- The ITEM pocket, where SELECT still arms (engine/items/pack.asm:1290). + U.tap(game, "right") + U.tap(game, "select") + shot("04-items-select-arms") + U.tap(game, "b") + + -- Back to the TM pocket for the human. + U.tap(game, "right") + U.tap(game, "right") + U.tap(game, "right") + U.log("[driver] shots in " .. out .. " -- the PACK is yours") +end diff --git a/tests/drivers/gold_exp_participants_bug1513.lua b/tests/drivers/gold_exp_participants_bug1513.lua new file mode 100644 index 00000000..edf7f47a --- /dev/null +++ b/tests/drivers/gold_exp_participants_bug1513.lua @@ -0,0 +1,197 @@ +-- #1513: exp credit has to be per THIS appearance of the enemy's active mon. +-- ResetBattleParticipants falls through into AddBattleParticipant +-- (engine/battle/core.asm:3033 and :3037), and AI_Switch farcalls it right +-- after EnemySwitch (engine/battle/ai/items.asm:697), so the moment the rival +-- rotates, the only mon still credited is the one the player has on the field. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_exp_participants_bug1513.lua love . +-- +-- What to look for, in order: +-- 00 CYNDAQUIL is out against JOEY's GEODUDE. +-- 01 TOTODILE has been switched in through the real party menu, so BOTH +-- party mons have now "met" GEODUDE. +-- 02 "JOEY withdrew GEODUDE!" -- the rotation the ticket is about. +-- 03 the exp lines for the KO of the mon that came IN (PIDGEY). Only +-- TOTODILE may be named here. A "CYNDAQUIL gained N EXP. Points!" line +-- in this shot is the bug. +-- 04 the party screen: CYNDAQUIL's level and EXP bar are pixel-identical to +-- shot 01's, TOTODILE's have moved. +-- 05 GEODUDE comes back out and is KO'd. CYNDAQUIL still gains nothing -- +-- that half is correct cart behavior (ram/wram.asm:775-776, "All bits +-- cleared if the enemy faints") and must NOT be "fixed". +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 runToPhase(game, screen, phase, frames) + for _ = 1, (frames or 600) do + if screen.phase == phase then return true end + if screen.battle.over then return false end + U.tap(game, "a") + U.wait(3) + end + return false +end + +-- Page the queue out, shooting the first box that carries `needle`. +local function pageUntilMenu(game, screen, out, needles) + local shot = {} + for _ = 1, 1200 do + local message = screen.message + if message then + for needle, path in pairs(needles) do + if not shot[needle] and message:find(needle, 1, true) then + U.shot(game, out .. "/" .. path) + shot[needle] = message + end + end + end + if screen.phase == "menu" or screen.phase == "done" + or screen.battle.over then + break + end + U.tap(game, "a") + U.wait(3) + end + return shot +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1513" + 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 lead = Mon.new(game.data, "CYNDAQUIL", 20) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(game.data, "TOTODILE", 20) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + game.save.party = { lead, bench } + + local foe1 = Mon.new(game.data, "GEODUDE", 8) + foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local foe2 = Mon.new(game.data, "PIDGEY", 8) + foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + -- attributes[6] is the low byte of TRNATTR_AI_ITEM_SWITCH: OFTEN + -- (src/battle/gen2/Ai.lua:1307). The real Rival1 class is SWITCH_SOMETIMES + -- (pokegold:data/trainers/attributes.asm), the same path at a lower roll. + assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY", + party = { foe1, foe2 }, attributes = { 0, 0, 0, 0, 0, 0x01, 0 } } }), + "trainer startBattle failed") + local screen = battleScreen(game) + assert(runToPhase(game, screen, "menu", 400), "never reached the menu") + U.shot(game, out .. "/00-lead-out.png") + + ------------------------------------------------- the player's own switch + -- Through the real party menu, so the human sees the same path they used. + screen:chooseMenu("party") + for _ = 1, 240 do + if screen.battle.player == bench then break end + local top = game.stack:top() + if top ~= screen then + U.tap(game, "down") + U.wait(3) + U.tap(game, "a") + U.wait(6) + U.tap(game, "a") + U.wait(6) + else + U.wait(2) + end + end + check(screen.battle.player == bench, "TOTODILE was switched in") + check(screen.battle.participants[1] and screen.battle.participants[2], + "both party mons are credited against GEODUDE") + pageUntilMenu(game, screen, out, {}) + U.shot(game, out .. "/01-switched.png") + print(("[driver] before the rotation: cyndaquil %d totodile %d") + :format(lead.experience, bench.experience)) + local leadExp = lead.experience + local benchExp = bench.experience + + ------------------------------------------------------- the AI's rotation + -- Perish Song at one turn left is CheckAbleToSwitch's maximum score, the + -- cheapest way to make the AI commit to the rotation on demand. + screen.battle:volatile(screen.battle.enemy).perish = 1 + screen:submit({ kind = "move", move = "TACKLE" }) + local seen = pageUntilMenu(game, screen, out, { + ["withdrew"] = "02-rival-withdrew.png", + ["gained"] = "03-exp-lines.png", + }) + check(seen["withdrew"] ~= nil, "JOEY withdrew GEODUDE") + check(screen.battle.enemy == foe2, "and sent PIDGEY out") + check(screen.battle.participants[1] == nil, + "CYNDAQUIL lost its credit when the rival rotated") + check(screen.battle.participants[2] == true, + "and TOTODILE, the mon on the field, kept it") + + ------------------------------------------------------------ the KO payout + if not screen.battle.over and (foe2.hp or 0) > 0 then + if screen.phase ~= "menu" then + runToPhase(game, screen, "menu", 400) + end + foe2.hp = 1 + screen:submit({ kind = "move", move = "TACKLE" }) + local paid = pageUntilMenu(game, screen, out, { + ["gained"] = "03-exp-lines.png", + }) + if paid["gained"] then print("[driver] exp box: " .. paid["gained"]) end + end + print(("[driver] after the PIDGEY KO: cyndaquil %d totodile %d") + :format(lead.experience, bench.experience)) + check(lead.experience == leadExp, + "CYNDAQUIL earned NOTHING from the mon it never faced") + check(bench.experience > benchExp, "TOTODILE was paid for it") + + if screen.phase == "menu" then + screen:chooseMenu("party") + U.wait(20) + U.shot(game, out .. "/04-party-exp.png") + U.tap(game, "b") + U.wait(10) + if game.stack:top() ~= screen then + U.tap(game, "b") + U.wait(10) + end + end + + ------------------------------------------- GEODUDE comes back and faints + -- Correct cart behavior: no residual credit for a previous appearance. + leadExp = lead.experience + benchExp = bench.experience + if not screen.battle.over then + runToPhase(game, screen, "menu", 600) + if screen.phase == "menu" and screen.battle.enemy == foe1 then + foe1.hp = 1 + screen:submit({ kind = "move", move = "TACKLE" }) + pageUntilMenu(game, screen, out, { ["gained"] = "05-geodude-ko.png" }) + print(("[driver] after the GEODUDE KO: cyndaquil %d totodile %d") + :format(lead.experience, bench.experience)) + check(lead.experience == leadExp, + "CYNDAQUIL still earns nothing when GEODUDE comes back (correct)") + check(bench.experience > benchExp, "and TOTODILE takes the whole share") + else + print("[driver] GEODUDE never came back out; second half skipped") + end + end + + if #failures > 0 then + error(#failures .. " exp participant checks failed") + end + print("[driver] #1513 fixed; shots in " .. out) +end diff --git a/tests/drivers/gold_menu_shots.lua b/tests/drivers/gold_menu_shots.lua index fb3029f0..de673519 100644 --- a/tests/drivers/gold_menu_shots.lua +++ b/tests/drivers/gold_menu_shots.lua @@ -221,6 +221,13 @@ return function(game) stored[i] = mon(species, 10 + i) end Boxes.rename(save, 2, "GRASS") + -- $5d for a held item, $5c for MAIL (engine/pokemon/bills_pc.asm:1079-1094). + -- A boxed mon can never hold mail, so the letter goes on a party mon. + stored[1].item = "BERRY" + save.party[2].item = "FLOWER_MAIL" + local Mail = require("src.core.gen2.Mail") + Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!", + save.player.name or "GOLD", save.player.id or 0, save.party[2].species)) local pc = PcMenu.new(game, { save = save }) show("20-pc-menu", pc) @@ -228,12 +235,22 @@ return function(game) pc.pickIndex = 2 show("21-pc-changebox", pc) - show("22-pc-withdraw", BoxMenu.new(game, { - save = save, mode = "withdraw", - })) - show("23-pc-deposit", BoxMenu.new(game, { - save = save, mode = "deposit", - })) + -- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu + -- swaps the mon's colours in (engine/pokemon/bills_pc.asm:305-309, :356-369, + -- engine/gfx/cgb_layouts.asm:284-289); the icon is BG palette 0 either way. + local wd = BoxMenu.new(game, { save = save, mode = "withdraw" }) + show("22-pc-withdraw", wd) + wd.index = wd:total() -- CANCEL + wd:ensureVisible() + show("22b-pc-withdraw-cancel", wd) + wd.index = 1 + wd:ensureVisible() + wd.phase = "submenu" + show("22c-pc-withdraw-submenu", wd) + local dep = BoxMenu.new(game, { save = save, mode = "deposit" }) + show("23-pc-deposit", dep) + dep.index = 2 -- the mon holding FLOWER MAIL + show("23b-pc-deposit-mail", dep) -- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and -- SetDayOfWeek), each at its picker rather than at its opening page. diff --git a/tests/drivers/summary_stats_bug1517_test.lua b/tests/drivers/summary_stats_bug1517_test.lua new file mode 100644 index 00000000..97b30644 --- /dev/null +++ b/tests/drivers/summary_stats_bug1517_test.lua @@ -0,0 +1,120 @@ +-- Driver: #1517, a Gen 2 stat block in a Gen 1 party. +-- +-- 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 +-- 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. +-- +-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/yellow \ +-- ~/Library/Application\ Support/LOVE/bug1517/yellow +-- POKEPORT_VERSION=yellow POKEPORT_GAME=yellow POKEPORT_IDENTITY=bug1517 \ +-- POKEPORT_TOUCH=0 POKEPORT_SPEED=8 SHOT_DIR=/tmp/bug1517 \ +-- POKEPORT_DRIVER=tests/drivers/summary_stats_bug1517_test.lua love . +-- +-- The copy is not optional: a fresh POKEPORT_IDENTITY has no cache and the +-- boot hangs with no output. +-- +-- Pre-fix the LOVE window vanishes the moment STATS is chosen, with no error +-- screen and nothing on stdout (that silence is the other half of #1517). +-- Post-fix the status screen opens and SPECIAL reads the CalcStats value +-- logged below. The run ends on the open screen, so a human takes the +-- controls at exactly the frame the reporter's build died on. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1517" + local Pokemon = require("src.pokemon.Pokemon") + local Stats = require("src.pokemon.Stats") + local SaveData = require("src.core.SaveData") + local SummaryMenu = require("src.ui.SummaryMenu") + + local function top() return game.stack:top() end + + U.newGame(game) + U.wait(20) + + -- BUTTERFREE because the reporter's party led with one, and CONFUSION is a + -- special move, so the same block would have crashed Damage.applyStage. + local species = game.data.pokemon.BUTTERFREE and "BUTTERFREE" or "PIDGEY" + local def = game.data.pokemon[species] + local mon = Pokemon.new(game.data, species, 21) + local want = Stats.calc(def, 21, mon.dvs, mon.statExp) + + -- the shape src/battle/gen2/Mon.lua Mon.stats produces: no `special` + mon.stats = { + hp = want.hp, attack = want.attack, defense = want.defense, + speed = want.speed, specialAttack = want.special, specialDefense = want.special, + } + mon.hp = mon.stats.hp + game.save.party = { mon } + U.log(("[1517] fixture: %s :L21, stats.special = %s, CalcStats says %d") + :format(species, tostring(mon.stats.special), want.special)) + + SaveData.validate(game.save, game.data) + local after = game.save.party[1].stats + U.log("[1517] after SaveData.validate, special = " .. tostring(after.special) + .. ", specialAttack = " .. tostring(after.specialAttack)) + U.log("[1517] the repair layer is SaveData.validate -> scrubKnownMon -> " + .. "Stats.ensure; a nil above means it did not run") + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local function cursorTo(menu, field, wanted) + for _ = 1, 40 do + if not menu or menu[field] == wanted then return menu and menu[field] == wanted end + U.tap(game, menu[field] < wanted and "down" or "up") + U.wait(3) + end + return menu[field] == wanted + end + + U.tap(game, "start") + U.wait(10) + local menu = top() + if not (menu and menu.screenId == "StartMenu") then + U.log("[1517] FAIL no start menu; top = " .. tostring(menu and menu.screenId)) + while true do coroutine.yield() end + end + local row + for i, it in ipairs(menu.items or {}) do + if it.label == "POKéMON" then row = i break end + end + if not (row and cursorTo(menu, "index", row)) then + U.log("[1517] FAIL could not reach the POKéMON row") + while true do coroutine.yield() end + end + U.tap(game, "a") + U.wait(10) + + local party = top() + if not (party and party.screenId == "PartyMenu") then + U.log("[1517] FAIL no party menu; top = " .. tostring(party and party.screenId)) + while true do coroutine.yield() end + end + U.shot(game, DIR .. "/01-party-list.png") + U.tap(game, "a") + U.wait(8) + local subRow + for i, it in ipairs(party.subItems or {}) do + if it.action == "stats" then subRow = i break end + end + if not (subRow and cursorTo(party, "subIndex", subRow)) then + U.log("[1517] FAIL could not reach the STATS row") + while true do coroutine.yield() end + end + U.shot(game, DIR .. "/02-stats-selected.png") + + U.log("[1517] opening STATS -- pre-fix the app dies here, silently") + U.tap(game, "a") + U.wait(30) + + local screen = top() + local opened = getmetatable(screen) == SummaryMenu or screen.screenId == "SummaryMenu" + U.log("[1517] SURVIVED, top = " .. tostring(screen and screen.screenId)) + U.shot(game, DIR .. "/03-status-screen.png") + U.log(("[1517] %s: SPECIAL on screen must read %d") + :format(opened and "read it off the frame" or "WRONG SCREEN", want.special)) + U.log("[1517] shots in " .. DIR .. " -- the status screen is yours") + while true do coroutine.yield() end +end diff --git a/tests/engine/summary_stats_repair_bug1517.lua b/tests/engine/summary_stats_repair_bug1517.lua new file mode 100644 index 00000000..8e35f30e --- /dev/null +++ b/tests/engine/summary_stats_repair_bug1517.lua @@ -0,0 +1,100 @@ +-- A Gen 1 party mon carries one Special word (macros/ram.asm:28-37) and +-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48, +-- constants/battle_constants.asm:11-17), so a block missing a key is not a +-- Gen 1 record and the load-time repair must rebuild it. Gen 2 splits it +-- into SpclAtk/SpclDef (pokegold macros/ram.asm:29-42). #1517 + +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 Stats = require("src.pokemon.Stats") +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 } }, + items = {}, maps = { FIXMAP = { id = "FIXMAP" } }, + constants = { fallbackMove = "FIX_TACKLE" }, +} +local DEF = data.pokemon.FIXMON_A +local DVS = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 } + +-- src/battle/gen2/Mon.lua Mon.stats' shape: no `special` +local function gen2Shaped() + return { + species = "FIXMON_A", level = 21, hp = 62, exp = 9000, + dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }, + statExp = {}, + stats = { hp = 62, attack = 27, defense = 29, speed = 37, + specialAttack = 8, specialDefense = 8 }, + moves = { { id = "FIX_TACKLE", pp = 35 } }, + } +end + +do + local mon = gen2Shaped() + eq(mon.stats.special, nil, "premise: a Gen 2 block has no `special`") + Stats.ensure(DEF, mon) + local want = Stats.calc(DEF, 21, DVS, {}) + for _, key in ipairs(Stats.ORDER) do + eq(mon.stats[key], want[key], "ensure rebuilds " .. key .. " from CalcStats") + end + eq(mon.stats.specialAttack, nil, "and drops the Gen 2 keys") + eq(mon.stats.specialDefense, nil, "both of them") +end + +do + local mon = gen2Shaped() + local save = { party = { mon }, boxes = {}, inventory = {}, pcItems = {}, + player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } } + SaveData.validate(save, data) + check(type(save.party[1].stats.special) == "number", + "validate repairs a party mon carrying a Gen 2 stat block") + local ok = pcall(string.format, "%3d", save.party[1].stats.special) + check(ok, "SummaryMenu's ('%3d'):format over stats.special no longer raises") + ok = pcall(Stats.applyStage, save.party[1].stats.special, 0) + check(ok, "and Damage's applyStage over curStats.special no longer raises") +end + +-- a box mon reached through the same pass +do + local save = { party = {}, boxes = { { gen2Shaped() } }, inventory = {}, + pcItems = {}, + player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } } + SaveData.validate(save, data) + check(type(save.boxes[1][1].stats.special) == "number", + "the repair reaches box mons too") +end + +do -- a complete block is never rewritten + local want = Stats.calc(DEF, 21, DVS, {}) + local mon = { species = "FIXMON_A", level = 21, hp = 5, dvs = DVS, statExp = {}, + stats = { hp = want.hp, attack = 999, defense = want.defense, + speed = want.speed, special = want.special }, + moves = { { id = "FIX_TACKLE", pp = 35 } } } + Stats.ensure(DEF, mon) + eq(mon.stats.attack, 999, "a complete block is returned untouched") + eq(mon.hp, 5, "and a stored current HP below the max is kept") +end + +do -- a mon with no block at all still gets one (#233, #304 unchanged) + local mon = { species = "FIXMON_A", level = 21, dvs = DVS, statExp = {} } + Stats.ensure(DEF, mon) + local want = Stats.calc(DEF, 21, DVS, {}) + eq(mon.stats.special, want.special, "a box mon with no stats still gets them") + eq(mon.hp, want.hp, "and a missing current HP fills to the maximum") +end + +do -- no species definition: nothing to rebuild from, so leave it alone + local mon = { species = "MISSINGNO", level = 21, + stats = { hp = 62, attack = 27 } } + Stats.ensure(nil, mon) + eq(mon.stats.attack, 27, "an unknown species leaves a partial block as-is") + eq(mon.stats.special, nil, "rather than inventing one") +end + +T.finish() diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 8f8cfb84..573f4f5a 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -851,6 +851,83 @@ do check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35) end +-- ResetBattleParticipants falls through into AddBattleParticipant +-- (engine/battle/core.asm:3033 and :3037), so every ENEMY-initiated mon change +-- wipes both bitfields and re-credits the mon the player has out. The player's +-- own send-outs only ever call AddBattleParticipant (core.asm:2655, :2681, +-- :3783, :4989, :5014). +do + local function creditBattle() + local party = { + Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }), + Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }), + } + local foes = { + Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }), + Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }), + } + for _, mon in ipairs(party) do + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + end + for _, mon in ipairs(foes) do + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + end + local battle = Battle.new({ + data = DATA, party = party, + -- attributes[6] is the low byte of the switch flags: OFTEN. + trainer = { class = "YOUNGSTER", name = "JOEY", party = foes, + attributes = { 0, 0, 0, 0, 0, 0x01, 0 } }, + random = zeroRandom, + }) + battle:switch(2) + return battle, party, foes + end + + -- AI_Switch (engine/battle/ai/items.asm:697). + local rotate, rotateParty, rotateFoes = creditBattle() + check("both mons are credited before the rotation", + rotate.participants[1] and rotate.participants[2], true) + rotate:volatile(rotate.enemy).perish = 1 + check("the AI rotated", rotate:enemyTrySwitchOrItem(), true) + check("the rotation installed the second foe", rotate.enemy, rotateFoes[2]) + check("the bench mon lost its credit", rotate.participants[1], nil) + check("only the mon on the field keeps it", rotate.participants[2], true) + local benchExp = rotateParty[1].experience + local activeExp = rotateParty[2].experience + rotate:awardExperience(rotate.enemy) + check("the bench mon earns nothing from the new foe", + rotateParty[1].experience, benchExp) + check("and the mon that faced it is still paid", + rotateParty[2].experience > activeExp, true) + + -- ForceEnemySwitch (engine/battle/core.asm:2937), reached only from + -- BattleCommand_ForceSwitch (effect_commands.asm:4999). + local roar, _, roarFoes = creditBattle() + roar.firstMover = "enemy" + Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH(roar, roar.player, roar.enemy, + nil, "ROAR", true) + check("Roar dragged the second foe out", roar.enemy, roarFoes[2]) + check("the bench mon lost its credit to Roar", roar.participants[1], nil) + check("and the mon on the field keeps it", roar.participants[2], true) + + -- engine/battle/move_effects/baton_pass.asm:59. + local baton, _, batonFoes = creditBattle() + Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(baton, baton.enemy) + check("the baton passed to the second foe", baton.enemy, batonFoes[2]) + check("the bench mon lost its credit to the baton", + baton.participants[1], nil) + check("and the mon on the field keeps it", baton.participants[2], true) + + -- PassedBattleMonEntrance only adds (engine/battle/core.asm:5014): the + -- player's own baton pass must NOT wipe the set. + local playerBaton, playerBatonParty = creditBattle() + Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(playerBaton, playerBaton.player) + check("the player's baton pass moved the lead back in", + playerBaton.player, playerBatonParty[1]) + check("and credited both mons", + playerBaton.participants[1] and playerBaton.participants[2], true) +end + -- Switching costs the turn and adds the newcomer to the participant set. local switchParty = { Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }), diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index ac01c52d..6664e704 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -483,6 +483,72 @@ packInput:press("right") pack:update(0) check("pocket wraps around", pack:pocket().id, "ITEM") +-- engine/items/tmhm.asm:341 -- TMHM_DisplayPocketItems walks wTMsHMs 1..57, so +-- the pocket is TM01..TM50 then HM01..HM07 whatever order the player picked +-- them up in, and tmhm.asm:207 keeps SELECT out of it entirely. +do + local tmSave2 = Save.newGame() + tmSave2.inventory = { + HM_WATERFALL = 1, TM_NIGHTMARE = 2, HM_CUT = 1, TM_ROAR = 3, + TM_DYNAMICPUNCH = 12, TM_LEGACY = 1, + } + tmSave2.bagOrder = { + "HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_ROAR", "TM_DYNAMICPUNCH", + "TM_LEGACY", + } + local tmGame2, tmInput2 = newGame(tmSave2) + tmGame2.data.items = { + TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01", + pocket = "TM_HM", index = 191, tmNumber = 1 }, + TM_ROAR = { id = "TM_ROAR", name = "TM05", pocket = "TM_HM", + index = 195, tmNumber = 5 }, + TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM", + index = 240, tmNumber = 50 }, + HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", + index = 241, tmNumber = 51 }, + HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM", + index = 247, tmNumber = 57 }, + -- A cache (or a mod) with no tmNumber falls back to the ItemNames index. + TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM", + index = 300 }, + } + local tmPack = PackMenu.new(tmGame2, { pocket = "TM_HM" }) + local function ids(rows) + local out = {} + for i = 1, #rows do out[i] = rows[i].id end + return table.concat(out, ",") + end + check("TM/HM pocket is TM-number ordered, not pickup ordered", + ids(tmPack.rows), + "TM_DYNAMICPUNCH,TM_ROAR,TM_NIGHTMARE,HM_CUT,HM_WATERFALL,TM_LEGACY") + check("the first row is TM01", tmPack.rows[1].id, "TM_DYNAMICPUNCH") + check("and a tmNumber-less item lands after HM07", + tmPack.rows[#tmPack.rows].id, "TM_LEGACY") + check("the TM keeps its count", tmPack.rows[1].showCount, true) + check("the HM shows none", tmPack.rows[4].showCount, false) + + tmPack.index = 1 + tmInput2:press("select") + tmPack:update(0) + check("SELECT cannot arm a TM/HM row", tmPack.switching, nil) + check("and prints no move prompt", tmPack.message, nil) + + -- The other three pockets still reorder on SELECT (pack.asm:1290). + tmSave2.inventory.POTION = 1 + tmSave2.inventory.SUPER_POTION = 1 + tmSave2.bagOrder = { "POTION", "SUPER_POTION" } + tmGame2.data.items.POTION = + { id = "POTION", name = "POTION", pocket = "ITEM", index = 1 } + tmGame2.data.items.SUPER_POTION = + { id = "SUPER_POTION", name = "SUPER POTION", pocket = "ITEM", index = 2 } + tmPack.pocketIndex = 1 + tmPack:rebuild() + tmPack.index = 1 + tmInput2:press("select") + tmPack:update(0) + check("but the ITEM pocket still arms", tmPack.switching, 1) +end + -- CANCEL sits one past the last row. check("cancel is past the end", pack:total(), #pack.rows + 1) pack.index = pack:total() diff --git a/tests/gen2_pc_screens_test.lua b/tests/gen2_pc_screens_test.lua index 1c6f30dc..6ff8aac3 100644 --- a/tests/gen2_pc_screens_test.lua +++ b/tests/gen2_pc_screens_test.lua @@ -310,7 +310,8 @@ do eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag") end --- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently. +-- .DepositItem: CANT_TOSS only skips .AskQuantity. A KEY ITEM deposits x1, +-- and .Submenu withdraws it back the same way (issue #1486). do local save = newSave(1) local game, input = newGame(save) @@ -320,9 +321,63 @@ do press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket press(pc, input, "a") -- choose the BICYCLE eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM") - eq(pc.message, nil, "no message either: .no_toss is a bare ret") - eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag") - eq(save.inventory.BICYCLE, 1, "still there") + eq(save.pcItems.BICYCLE, 1, "the BICYCLE lands in the PC") + eq(save.inventory.BICYCLE, nil, "and leaves the bag") + eq(pc.message.pages[1][1], "Deposited 1", "_PlayersPCDepositItemsText") + press(pc, input, "a") -- clear it + press(pc, input, "b") -- close the PACK + press(pc, input, "up", "a") -- WITHDRAW ITEM + eq(pc.phase, "withdraw", "the PC item list opens on the KEY ITEM") + press(pc, input, "a") -- the BICYCLE row + eq(pc.qtyState, nil, "no quantity selector on the way back either") + eq(save.inventory.BICYCLE, 1, "the BICYCLE is back in the bag") + eq(save.pcItems.BICYCLE, nil, "and out of the PC") +end + +-- The TM_HM pocket's HMs are CANT_TOSS too, and deposit prompt-free x1. +do + local save = newSave(1) + local game, input = newGame(save) + Bag.add(save, "HM_CUT", 1, game.data) + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "down", "a") -- DEPOSIT ITEM + press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM + press(pc, input, "a") -- choose HM01 + eq(pc.qtyState, nil, "no quantity selector for an HM") + eq(save.pcItems.HM_CUT, 1, "HM01 lands in the PC") + eq(save.inventory.HM_CUT, nil, "and leaves the bag") +end + +-- PlaceMenuItemQuantity: the PC list draws no xNN for a CANT_TOSS row. +do + local save = newSave(1) + local game = newGame(save) + save.pcItems = { POTION = 3, HM_CUT = 1 } + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + local Chrome = require("src.ui.gen2.Chrome") + local TIMES = "\xc3\x97" + local saved = { print = Chrome.print, box = Chrome.box, + cursor = Chrome.cursor } + local printed = {} + Chrome.print = function(text, x, y) + printed[#printed + 1] = { text = text, x = x, y = y } + end + Chrome.box = function() end + Chrome.cursor = function() end + pc.phase = "withdraw" + pc:rebuild() + local drew, err = pcall(function() pc:drawList() end) + Chrome.print, Chrome.box, Chrome.cursor = saved.print, saved.box, saved.cursor + check(drew, "the PC list draws: " .. tostring(err)) + local rowY = {} + for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end + local counts = {} + for _, p in ipairs(printed) do + if p.x == 7 then counts[p.y] = p.text end + end + eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN") + eq(counts[rowY.HM_CUT + 1], nil, + "and the HM row draws none (PlaceMenuItemQuantity .done)") end -- An empty bag never opens the PACK (.CheckItemsInBag). diff --git a/tests/modkit/cases/cross_generation_require.lua b/tests/modkit/cases/cross_generation_require.lua new file mode 100644 index 00000000..d0ba37db --- /dev/null +++ b/tests/modkit/cases/cross_generation_require.lua @@ -0,0 +1,144 @@ +-- T4: a mod may not reach a Gen 2 engine module from a Gen 1 game. +-- +-- src/battle/gen2/Mon.lua returns { hp, attack, defense, speed, specialAttack, +-- specialDefense }; a Gen 1 party mon carries `special` instead. A Yellow mod +-- that required that module and ran refreshStats over save.party wrote the Gen +-- 2 block onto Gen 1 mons, and every reader of stats.special raised from then +-- on (#1517). The require shim refuses it now, whatever the manifest declares: +-- engine_internals is a disclosure, not a generation gate. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local PROBE = [[ + local mod = ... + local out = mod.exports + local function attempt(name) + local ok, result = pcall(require, name) + if ok then return nil, type(result) end + return tostring(result), nil + end + out.attempt = attempt + out.monErr, out.monType = attempt("src.battle.gen2.Mon") + out.saveErr = attempt("src.core.gen2.Save") + out.worldErr = attempt("src.ui.gen2.Chrome") + out.game2Err = attempt("src.core.Game2") + out.semverErr, out.semverType = attempt("src.mods.Semver") + out.statsErr, out.statsType = attempt("src.pokemon.Stats") + out.compatErr, out.compatType = attempt("src.mods.Gen2Compat") + out.loggerErr, out.loggerType = attempt("src.core.Logger") +]] + +local function manifest(id, permissions) + return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",' + .. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "") +end + +-- ------- Gen 1 boot: refused, and the permission does not unlock it + +local FILES = { + ["mods/gen2_declared/manifest.json"] = + manifest("gen2_declared", ',"permissions":["engine_internals"]'), + ["mods/gen2_declared/main.lua"] = PROBE, + ["mods/gen2_undeclared/manifest.json"] = manifest("gen2_undeclared"), + ["mods/gen2_undeclared/main.lua"] = PROBE, +} + +T.eq(package.loaded["src.battle.gen2.Mon"], nil, + "the Gen 2 mon module is not loaded before the Gen 1 boot") + +local run = T.sdk.loadMods({ "mods/gen2_declared", "mods/gen2_undeclared" }, + { fs = T.sdk.memfs(FILES), data = {}, generation = 1 }) +T.eq(#run.errors, 0, + "a mod that only probes still loads clean (" .. tostring(run.errors[1]) .. ")") + +local declared = run.loader.exports.gen2_declared or {} +local undeclared = run.loader.exports.gen2_undeclared or {} + +T.check(declared.monErr, "src.battle.gen2.Mon is refused on a Gen 1 game") +T.check(declared.monErr and declared.monErr:find("src.battle.gen2.Mon", 1, true), + "the refusal names the module: " .. tostring(declared.monErr)) +T.check(declared.monErr and declared.monErr:find("gen2_declared", 1, true), + "and the mod that asked for it") +T.check(declared.monErr and declared.monErr:find("mod.game", 1, true), + "and the surface to use instead") +T.eq(declared.monType, nil, "nothing came back for the mod to call") +T.eq(package.loaded["src.battle.gen2.Mon"], nil, + "and the module was never loaded at all") + +T.check(declared.saveErr, "src.core.gen2.Save is refused the same way") +T.check(declared.worldErr, "so is src.ui.gen2.Chrome") +T.check(declared.game2Err and declared.game2Err:find("src.core.Game2", 1, true), + "and src.core.Game2, the Gen 2 service owner: " .. tostring(declared.game2Err)) + +T.check(undeclared.monErr, + "a mod with no permissions is refused for the same reason") +T.eq(declared.monErr and declared.monErr:gsub("gen2_declared", "X"), + undeclared.monErr and undeclared.monErr:gsub("gen2_undeclared", "X"), + "engine_internals is a disclosure, not a generation gate: same refusal") + +-- ------- what the gate does NOT touch + +T.eq(declared.semverErr, nil, "the supported requires still resolve") +T.eq(declared.semverType, "table", "and hand back the module") +T.eq(declared.statsErr, nil, "src.pokemon.Stats is still published to mods") +T.eq(declared.compatErr, nil, + "src.mods.Gen2Compat is not a Gen 2 engine module: it is the adapter table") +T.eq(declared.compatType, "table", "so it still answers") +T.eq(declared.loggerErr, nil, + "an undeclared engine require is still a warning, not a block") +T.eq(declared.loggerType, "table", "and still resolves") +T.eq(undeclared.loggerErr, nil, + "including for a mod that declared no permissions at all") + +-- a lazy require made long after the entry chunk ran is gated too: the mod's +-- own require is what the shim identifies, not the load phase +do + local err = declared.attempt and declared.attempt("src.world.gen2.World") + T.check(err and err:find("src.world.gen2.World", 1, true), + "a require made after load is refused too: " .. tostring(err)) +end + +-- the shim only judges a mod's own require; engine and harness callers keep +-- the module they asked for +do + local ok, module = pcall(require, "src.world.gen2.WorldAPI") + T.check(ok, "a non-mod caller still reaches a Gen 2 module: " .. tostring(module)) + T.eq(type(module), "table", "and gets the real one") +end + +run.release() + +-- ------- an unguarded reach fails the whole mod, on the boot error feed + +local HARD = { + ["mods/gen2_hard/manifest.json"] = manifest("gen2_hard", + ',"permissions":["engine_internals"]'), + ["mods/gen2_hard/main.lua"] = [[ + local mod = ... + local Mon = require("src.battle.gen2.Mon") + mod.exports.reached = Mon ~= nil + ]], +} +local hard = T.sdk.loadMods({ "mods/gen2_hard" }, + { fs = T.sdk.memfs(HARD), data = {}, generation = 1 }) +T.eq(hard.loader.mods.gen2_hard.state, "failed", + "a mod that reaches across the generation does not load") +T.eq(hard.loader.exports.gen2_hard, nil, "and publishes nothing") +T.check(hard.errors[1] and hard.errors[1]:find("src.battle.gen2.Mon", 1, true), + "the boot error feed names the module: " .. tostring(hard.errors[1])) +hard.release() + +-- ------- Gen 2 boot: the same require is the mod's own generation + +local gen2 = T.sdk.loadMods({ "mods/gen2_declared" }, + { fs = T.sdk.memfs(FILES), data = {}, generation = 2 }) +local onGold = gen2.loader.exports.gen2_declared or {} +T.eq(onGold.monErr, nil, + "on a Gen 2 game the same require is answered: " .. tostring(onGold.monErr)) +T.eq(onGold.monType, "table", "with the real Gen 2 mon module") +T.eq(onGold.game2Err, nil, "and src.core.Game2 is this game's service owner") +gen2.release() + +T.finish("cross_generation_require") diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 3d7df56d..d40e1a64 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition Game.save.onBike = false Game.save.inventory.BICYCLE = nil +-- home/overworld.asm:1224 +local function settle(o) + drainText() + for _ = 1, 60 do o:updateScriptMoves(); o.player:update() end +end +for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 }, + { "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do + Game.save.inventory.BICYCLE = nil + Game.save.onBike, Game.save.forcedBike = false, nil + pushOW(c[1], c[2], c[3], "left") + ow = OW + check(not ow.map:isWalkableCell(c[2] + 1, c[3]), + ("%s (%d,%d): the cell behind a left-facing arrival is the gate wall") + :format(c[1], c[2], c[3])) + settle(ow) + eq(#ow.scriptMoves, 0, + ("%s (%d,%d): the refusal shove settles"):format(c[1], c[2], c[3])) + check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY), + ("%s (%d,%d): the bikeless Cycling Road refusal never shoves into the " + .. "gate wall"):format(c[1], c[2], c[3])) +end + +Game.save.onBike, Game.save.forcedBike = false, nil +pushOW("ROUTE_16", 17, 10, "right") +ow = OW +settle(ow) +eq(ow.player.cellX, 16, "an unobstructed refusal shove still moves one cell") +eq(ow.player.cellY, 10, "and only along the arrival axis") + +Game.save.onBike, Game.save.forcedBike = false, nil +ow:setMap("ROUTE_16", 18, 10, "left", { via = "boot" }) +check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY), + "a save parked inside the Route 16 gate wall is lifted out on load") +eq(ow.player.cellY, 10, "the repair stays on the row it was saved on") +settle(ow) +check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY), + "and the refusal it lands on cannot push it back in") +popToOW() +Game.save.onBike, Game.save.forcedBike = false, nil + -- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only -- until BOTH boulders are down (CheckBothEventsSet) Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil diff --git a/tests/parity_bide_anim.lua b/tests/parity_bide_anim.lua index 1447eb7b..a6234c84 100644 --- a/tests/parity_bide_anim.lua +++ b/tests/parity_bide_anim.lua @@ -42,7 +42,8 @@ local function trace(tb) if row.text then out[#out + 1] = { text = (row.text:gsub("\n", " ")) } elseif row.anim then - out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer } + out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer, + hit = row.hit } elseif row.drain then out[#out + 1] = { drain = true } end @@ -95,6 +96,9 @@ do check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM") eq(rows[spiral] and rows[spiral].isPlayer, true, "on the player's side of the field") + -- effects.asm:1461-1471 + eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6, + "PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)") local used = indexOfText(rows, "used BIDE!") local storing = indexOfText(rows, "storing energy!") check(used and spiral and used < spiral, @@ -119,6 +123,8 @@ do local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM") check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM") check(dup and not rows[dup].isPlayer, "attributed to the enemy side") + eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3, + "and wAnimationType 3 on the enemy's turn (#1564)") end -- locked turns: .BideCheck decrements with no text of its own in pokered and @@ -151,6 +157,10 @@ do check(unleashed ~= nil, "the release prints UnleashedEnergyText") check(hit ~= nil, "the release plays BIDE's own animation (#375)") eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side") + -- core.asm:3526-3528 -> effects.asm:1476-1477 + check(rows[hit] and rows[hit].hit == nil, + ".UnleashEnergy rejoins PlayCurrentMoveAnimation, which zeroes " + .. "wAnimationType") check(unleashed and hit and unleashed < hit, "the animation follows the text") local drainAt diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 02828172..656c9f99 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -699,6 +699,9 @@ REQUIRED_SYMBOLS = { # marker without this symbol. "MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons", "PokedexDataPointerTable", + # engine/pokemon/bills_pc.asm:2170-2173 (the four vTiles2 $5c tiles + # PCMonInfo prints at :1086/:1091) and engine/gfx/cgb_layouts.asm:287. + "PCMailGFX", "BillsPCOrangePalette", # New-game / Pokecenter respawn table (data/maps/spawn_points.asm) "SpawnPoints", "NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks", diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 8e191e63..8c250e00 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -12912,6 +12912,10 @@ 105, 16837 ], + "BillsPCOrangePalette": [ + 2, + 21965 + ], "BlastoiseBackpic": [ 25, 30882 @@ -12936,6 +12940,10 @@ 107, 21761 ], + "BoltEmote": [ + 5, + 17737 + ], "BugCatchingContestantEventFlagTable": [ 4, 32186 @@ -12968,6 +12976,30 @@ 26, 26667 ], + "CardFlipLZ01": [ + 56, + 21795 + ], + "CardFlipLZ02": [ + 56, + 22197 + ], + "CardFlipLZ03": [ + 56, + 21736 + ], + "CardFlipOffButtonGFX": [ + 56, + 21763 + ], + "CardFlipOnButtonGFX": [ + 56, + 21779 + ], + "CardFlipTilemap": [ + 56, + 22809 + ], "CardStatusGFX": [ 9, 22287 @@ -13612,6 +13644,10 @@ 106, 19800 ], + "FishEmote": [ + 5, + 17865 + ], "FishGroups": [ 36, 27127 @@ -13956,6 +13992,10 @@ 4, 26664 ], + "HeartEmote": [ + 5, + 17673 + ], "HeldItemIcons": [ 35, 26843 @@ -15420,6 +15460,10 @@ 5, 18398 ], + "PCMailGFX": [ + 56, + 31768 + ], "PackGFX": [ 4, 21553 @@ -16096,22 +16140,6 @@ 5, 17417 ], - "HeartEmote": [ - 5, - 17673 - ], - "BoltEmote": [ - 5, - 17737 - ], - "SleepEmote": [ - 5, - 17801 - ], - "FishEmote": [ - 5, - 17865 - ], "Shrink1Pic": [ 62, 30142 @@ -16156,6 +16184,26 @@ 106, 22935 ], + "SleepEmote": [ + 5, + 17801 + ], + "Slots1LZ": [ + 36, + 31138 + ], + "Slots2LZ": [ + 36, + 31522 + ], + "Slots3LZ": [ + 36, + 32130 + ], + "SlotsTilemap": [ + 36, + 30898 + ], "SlowbroBackpic": [ 26, 32198 @@ -16356,6 +16404,14 @@ 105, 22509 ], + "StatsScreenPagePals": [ + 2, + 21715 + ], + "StatsScreenPageTilesGFX": [ + 62, + 19106 + ], "StatsScreen_PlaceFrontpic": [ 20, 20675 @@ -17667,46 +17723,6 @@ "wBaseUnusedFrontpic": [ 1, 53554 - ], - "Slots1LZ": [ - 36, - 31138 - ], - "Slots2LZ": [ - 36, - 31522 - ], - "Slots3LZ": [ - 36, - 32130 - ], - "SlotsTilemap": [ - 36, - 30898 - ], - "CardFlipLZ01": [ - 56, - 21795 - ], - "CardFlipLZ02": [ - 56, - 22197 - ], - "CardFlipLZ03": [ - 56, - 21736 - ], - "CardFlipOnButtonGFX": [ - 56, - 21779 - ], - "CardFlipOffButtonGFX": [ - 56, - 21763 - ], - "CardFlipTilemap": [ - 56, - 22809 ] }, "tilesets": { diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index ff9b91d4..065dc1c6 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -12912,6 +12912,10 @@ 105, 16840 ], + "BillsPCOrangePalette": [ + 2, + 21965 + ], "BlastoiseBackpic": [ 25, 31647 @@ -12972,6 +12976,30 @@ 26, 27406 ], + "CardFlipLZ01": [ + 56, + 21795 + ], + "CardFlipLZ02": [ + 56, + 22197 + ], + "CardFlipLZ03": [ + 56, + 21736 + ], + "CardFlipOffButtonGFX": [ + 56, + 21763 + ], + "CardFlipOnButtonGFX": [ + 56, + 21779 + ], + "CardFlipTilemap": [ + 56, + 22809 + ], "CardStatusGFX": [ 9, 22287 @@ -15432,6 +15460,10 @@ 5, 18398 ], + "PCMailGFX": [ + 56, + 31768 + ], "PackGFX": [ 4, 21553 @@ -16156,6 +16188,22 @@ 5, 17801 ], + "Slots1LZ": [ + 36, + 31138 + ], + "Slots2LZ": [ + 36, + 31522 + ], + "Slots3LZ": [ + 36, + 32130 + ], + "SlotsTilemap": [ + 36, + 30898 + ], "SlowbroBackpic": [ 26, 31900 @@ -16356,6 +16404,14 @@ 105, 22501 ], + "StatsScreenPagePals": [ + 2, + 21715 + ], + "StatsScreenPageTilesGFX": [ + 62, + 19106 + ], "StatsScreen_PlaceFrontpic": [ 20, 20675 @@ -17667,46 +17723,6 @@ "wBaseUnusedFrontpic": [ 1, 53554 - ], - "Slots1LZ": [ - 36, - 31138 - ], - "Slots2LZ": [ - 36, - 31522 - ], - "Slots3LZ": [ - 36, - 32130 - ], - "SlotsTilemap": [ - 36, - 30898 - ], - "CardFlipLZ01": [ - 56, - 21795 - ], - "CardFlipLZ02": [ - 56, - 22197 - ], - "CardFlipLZ03": [ - 56, - 21736 - ], - "CardFlipOnButtonGFX": [ - 56, - 21779 - ], - "CardFlipOffButtonGFX": [ - 56, - 21763 - ], - "CardFlipTilemap": [ - 56, - 22809 ] }, "tilesets": {