diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 8c5c08b5..e09077d6 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -266,7 +266,10 @@ function OakSpeech.new(game, onDone) -- RedSprite: the walking sprite the pic shrinks into (frame 0 = -- standing, facing down) local playerSprites = (game.data.field and game.data.field.playerSprites) or {} - local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED + -- The fallback has to read the same guarded table: reaching for + -- game.data.sprites.SPRITE_RED after the `and` already found it nil threw. + local sprites = game.data.sprites or {} + local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED self.walkSheet = tryImage(red and red.image) return self end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 44630d52..ce9f5a8d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3517,10 +3517,21 @@ end function OverworldState:onStepComplete() local p = self.player - local suppressWildEncounter = self.wildEncounterGraceSteps > 0 - if suppressWildEncounter then - self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1 + -- Defaulted: a state built without the constructor (a mod harness, a test + -- fixture) reaches this before :234 ever ran, and nil > 0 threw the step. + local grace = self.wildEncounterGraceSteps or 0 + if grace > 0 then + self.wildEncounterGraceSteps = grace - 1 end + -- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable + -- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the + -- player did not take never rolls, which is why Oak's escort walks to the lab + -- through Pallet's grass without being jumped. + local runner = self.runner + local scripted = (runner and runner.isRunning and runner:isRunning()) + or #(self.scriptMoves or {}) > 0 + or self.engaging or self.emote or self.teleportOut + local suppressWildEncounter = grace > 0 or scripted and true or false self.todSteps = (self.todSteps or 0) + 1 -- UpdatePikachuHappinessAndMood rides the step counter (poison.asm) require("src.world.PikachuFollower").onStep(Game.save) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 087b7bfe..f2ca4150 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -3946,7 +3946,8 @@ function World:rollEncounter(kind, terrain, tables, vanilla) local ctx = { mapId = map and map.id, terrain = terrain, - rng = love.math.random, + -- Same guard World:rockRandom uses: a headless suite has no love global. + rng = (love and love.math and love.math.random) or math.random, kind = kind, daytime = self.daytime, environment = map and map.def and map.def.environment, diff --git a/tests/gen2_battle_end_test.lua b/tests/gen2_battle_end_test.lua index f947a976..3b98194b 100644 --- a/tests/gen2_battle_end_test.lua +++ b/tests/gen2_battle_end_test.lua @@ -252,10 +252,15 @@ do local outcome screen.onDone = function(result) outcome = result end + -- Battle lines end in `prompt` and PromptButton waits on A/B with no + -- countdown (home/joypad.asm:383-412), so the drain presses. local function drain(cap) for _ = 1, (cap or 3000) do + local waiting = (screen.messageTimer or 0) > 0 + if waiting then Input:overlayPressed("a") end Input:step() screen:update(1 / 60) + if waiting then Input:overlayReleased("a") end if screen.phase == "menu" or screen.phase == "done" then return true end end return false diff --git a/tests/gen2_battle_items_test.lua b/tests/gen2_battle_items_test.lua index bfe81afb..dbfc8527 100644 --- a/tests/gen2_battle_items_test.lua +++ b/tests/gen2_battle_items_test.lua @@ -159,10 +159,16 @@ local function newScreen(opts) return screen, battle, player, save, pushed end +-- Battle lines end in `prompt`, and PromptButton waits on A or B with no +-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a +-- player does rather than wait for a timer that never runs out. local function runToMenu(screen, cap) for _ = 1, (cap or 3000) do + local waiting = (screen.messageTimer or 0) > 0 + if waiting then Input:overlayPressed("a") end Input:step() screen:update(1 / 60) + if waiting then Input:overlayReleased("a") end if screen.phase == "menu" then return true end end return false diff --git a/tests/gen2_battle_pack_test.lua b/tests/gen2_battle_pack_test.lua index 95608d0f..9de5bc3a 100644 --- a/tests/gen2_battle_pack_test.lua +++ b/tests/gen2_battle_pack_test.lua @@ -157,10 +157,16 @@ local function newBattleOverWorld(inventory) return screen, battle, world, save, pushed, player end +-- Battle lines end in `prompt`, and PromptButton waits on A or B with no +-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a +-- player does rather than wait for a timer that never runs out. local function runToMenu(screen, cap) for _ = 1, (cap or 3000) do + local waiting = (screen.messageTimer or 0) > 0 + if waiting then Input:overlayPressed("a") end Input:step() screen:update(1 / 60) + if waiting then Input:overlayReleased("a") end if screen.phase == "menu" then return true end end return false diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index b03a4f8a..e6090a1c 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -262,10 +262,21 @@ local function run(screen, frames) end end +-- One frame of a drain. Battle lines end in `prompt` and PromptButton waits +-- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits +-- the same way (engine/battle/core.asm:7069), so a drain presses rather than +-- idling for a timer that never runs out. +local function drainStep(screen) + local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box" + if waiting then Input:overlayPressed("a") end + Input:step() + screen:update(1 / 60) + if waiting then Input:overlayReleased("a") end +end + local function runToMenu(screen, cap) for _ = 1, (cap or 3000) do - Input:step() - screen:update(1 / 60) + drainStep(screen) if screen.phase == "menu" then return true end end return false @@ -366,8 +377,7 @@ do local crawled, restarted = false, false for _ = 1, 3000 do - Input:step() - screen:update(1 / 60) + drainStep(screen) if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then crawled = true end @@ -404,8 +414,7 @@ do local maxBefore = player.maxHp screen:submit({ kind = "move", move = "TACKLE" }) for _ = 1, 3000 do - Input:step() - screen:update(1 / 60) + drainStep(screen) if screen.phase == "done" then break end end check(player.maxHp > maxBefore, "the level-up raised the maximum") @@ -601,8 +610,7 @@ do local function runToPhase(screen, phase, cap) for _ = 1, (cap or 4000) do - Input:step() - screen:update(1 / 60) + drainStep(screen) if screen.phase == phase then return true end end return false @@ -622,9 +630,15 @@ do noBattle.enemy.hp = 1 local noTurn = noBattle.turn noScreen:submit({ kind = "move", move = "TACKLE" }) - check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch") + -- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the + -- offer (data/text/battle.asm:222-231): the incoming mon is named on an + -- earlier page, and only the last one carries the yes/no box (#1158). + check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch") check((noScreen.message or ""):find("is about to use"), "with BattleText_EnemyIsAboutToUseWillPlayerChangeMon") + check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question") + check((noScreen.message or ""):find("change POK"), + "whose last page is the one YesNoBox opens over") -- NO falls through to the send-out with nothing switched and nothing spent. noScreen.messageTimer = 0 @@ -1083,8 +1097,7 @@ do -- not ring is anything from the faint onward. local latched, rangAfterFaint, sawExpLine = false, false, false for _ = 1, 3000 do - Input:step() - screen:update(1 / 60) + drainStep(screen) if screen.lowHealthAlarmDisabled then latched = true end if latched and siren then rangAfterFaint = true end if latched and (screen.message or ""):find("EXP") then sawExpLine = true end @@ -1155,7 +1168,9 @@ do eq(screen.phase, "refuse-move", "the disabled row is refused") eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled") eq(wild.hp, before, "and the enemy got no free turn") - run(screen, 120) + -- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the + -- list comes back on a press, not on a timer. + tap("a") eq(screen.phase, "moves", "the list comes back") -- The same for a spent row. @@ -1168,7 +1183,7 @@ do eq(screen.message, "There's no PP left for this move!", "with BattleText_TheresNoPPLeftForThisMove") eq(wild.hp, before, "and still no enemy turn") - run(screen, 120) + tap("a") eq(screen.phase, "moves", "and the list comes back again") end @@ -1221,8 +1236,7 @@ end -- so the question is reached by pages rather than by one button. local function runToPhase(screen, phase, cap) for _ = 1, (cap or 900) do - Input:step() - screen:update(1 / 60) + drainStep(screen) if screen.phase == phase then return true end end return false @@ -1236,11 +1250,15 @@ do check(runToPhase(screen, "ask-forget"), "and its pages run into the question") eq(screen.message, "move to make room\nfor EMBER?", "whose last page is the one YesNoBox opens over") - run(screen, 60) local tap = tapper(screen) + -- The question's own `prompt` is read first and YesNoBox opens after it, + -- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the + -- answer is the second press, not the first. + tap("a") + eq(screen.phase, "ask-forget", "the last page holds until it is read") tap("b") eq(screen.phase, "stop-learning", "NO there asks whether to stop learning") - run(screen, 60) + tap("a") tap("b") eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask") check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit") @@ -1251,8 +1269,8 @@ end do local screen, lead = learnScreen() check(runToPhase(screen, "ask-forget"), "the pages reach the question") - run(screen, 60) local tap = tapper(screen) + tap("a") -- read the question tap("a") -- YES eq(screen.phase, "choose-forget", "YES opens the picker") tap("a") -- slot 1 @@ -1263,9 +1281,9 @@ end do local screen, lead = learnScreen() check(runToPhase(screen, "ask-forget"), "the pages reach the question") - run(screen, 60) local tap = tapper(screen) - tap("a") + tap("a") -- read the question + tap("a") -- YES eq(screen.phase, "choose-forget", "the picker is up") screen.forgetIndex = 4 tap("a") @@ -1273,7 +1291,7 @@ do eq(screen.message, "HM moves can't be\nforgotten now.", "with MoveCantForgetHMText") eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1") - run(screen, 60) + tap("a") eq(screen.phase, "choose-forget", "the picker is still up after the line") eq(lead.moves[4].id, "SURF", "and SURF is still there") end diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 229aab7e..00054183 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -356,11 +356,14 @@ local optionsGame, optionsInput = newGame(Save.newGame()) local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) --- The cart's seven value rows, then the port's audio, speed and display --- rows, then CANCEL -- which is what makes this screen scroll. -check("sixteen rows", #OptionsMenu.ROWS, 16) +-- The cart's seven value rows, then CONTROLS, the port's audio, speed and +-- display rows, the three touch rows and CANCEL -- which is what makes this +-- screen scroll. The touch three are gated to mobile by buildRows; ROWS +-- itself carries every descriptor. +check("twenty rows", #OptionsMenu.ROWS, 20) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") -check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol") +check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") +check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) check("starts on TEXT SPEED", options:row().key, "textSpeed") check("default text speed", options.options.textSpeed, "MID") @@ -401,7 +404,9 @@ local exiting = OptionsMenu.new(exitGame, { options = Save.defaultOptions(), onDone = function(o) savedOptions = o end, }) -exiting.index = #OptionsMenu.ROWS +-- The screen's own rows, not ROWS: buildRows drops the touch three off a +-- desktop, so the raw descriptor count overshoots CANCEL. +exiting.index = #exiting.rows exitInput:press("a") exiting:update(0) check("CANCEL leaves", savedOptions ~= nil, true) diff --git a/tests/gen2_nests_test.lua b/tests/gen2_nests_test.lua index 1addd400..9e08a04c 100644 --- a/tests/gen2_nests_test.lua +++ b/tests/gen2_nests_test.lua @@ -17,14 +17,17 @@ local Nests = require("src.core.gen2.Nests") -- Landmark indices from constants/landmark_constants.asm: Johto runs below -- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28. local DATA = { - maps = { + -- gen2Maps, matching Game2's key for the Gold map table. + gen2Maps = { ROUTE_29 = { landmark = 2 }, -- Johto ILEX_FOREST = { landmark = 11 }, -- Johto ROUTE_1 = { landmark = 0x2f }, -- Kanto UNION_CAVE_1F = { landmark = 9 }, -- Johto NOWHERE = { }, -- no landmark at all }, - encounters = { + -- Game2 loads the encounter tables under gen2Encounters, not `encounters`: + -- the flat name is Gen 1's and Gen2Compat only maps it for mods. + gen2Encounters = { grass = { ROUTE_29 = { slots = { MORN = { { species = "PIDGEY" }, { species = "SENTRET" } }, diff --git a/tests/gen2_save_test.lua b/tests/gen2_save_test.lua index e8a10659..e2b80b2b 100644 --- a/tests/gen2_save_test.lua +++ b/tests/gen2_save_test.lua @@ -417,22 +417,25 @@ check("only the written byte is stored", memCount, 1) local _, _, _, loadReport = Save.load("gold") check("load reports a clean save", Save.emptyReport(loadReport), true) --- A second write backs the first up, so the previous file is always recoverable. +-- A second write backs the first up, so the previous file is always +-- recoverable. The file lives at saves//.lua since Gold grew +-- launcher slots (#1107); the flat save_gold.lua is only the migration source. +local SLOT = "saves/gold/slot1.lua" written.player.money = 5555 Save.save(written) -check("backup written", files["save_gold.lua.bak"] ~= nil, true) +check("backup written", files[SLOT .. ".bak"] ~= nil, true) check("new value loads", Save.load("gold").player.money, 5555) -- A corrupt main file falls back to the backup rather than losing the game. -files["save_gold.lua"] = "this is not a lua table" +files[SLOT] = "this is not a lua table" local recoveredSave, how = Save.load("gold") check("recovered from the backup", recoveredSave ~= nil, true) check("recovery reported", how, "bak") check("backup held the previous money", recoveredSave.player.money, 4321) -- A staged .tmp is preferred over the backup: it is the newer of the two. -files["save_gold.lua"] = nil -files["save_gold.lua.tmp"] = files["save_gold.lua.bak"] +files[SLOT] = nil +files[SLOT .. ".tmp"] = files[SLOT .. ".bak"] local staged, stagedHow = Save.load("gold") check("recovered from the staged copy", staged ~= nil, true) check("staged recovery reported", stagedHow, "tmp") diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index b2c4121e..a8bc0987 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -3315,7 +3315,8 @@ local function grassLatchChecks() check(hp.jumping, "and it is a jump") check(not hp.grassShake, "no rustle spawns for the airborne cells") check(not hp.inGrass, "and IN_GRASS is clear for the whole hop") - for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end + -- A hop clears two cells, so it runs two step-times, not one (#1165). + for _ = 1, PlayerMod.STEP_FRAMES * 2 + 1 do hopGrass:step() end check(hp.inGrass, "the landing tile latches it once the hop ends") end grassLatchChecks() diff --git a/tests/integration/title_checkpoint_cold_start.lua b/tests/integration/title_checkpoint_cold_start.lua index 6f754155..cfbf9c92 100644 --- a/tests/integration/title_checkpoint_cold_start.lua +++ b/tests/integration/title_checkpoint_cold_start.lua @@ -48,10 +48,15 @@ function fs.load(path) end function fs.getDirectoryItems(path) local items = {} + -- -print plus a basename in Lua, not -printf: that is a GNU extension and + -- BSD find (macOS) fails the whole call, which silently emptied the listing + -- and left the probe mod undiscovered. local pipe = io.popen("find " .. quote(full(path)) - .. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null") + .. " -mindepth 1 -maxdepth 1 -print 2>/dev/null") if pipe then - for item in pipe:lines() do items[#items + 1] = item end + for item in pipe:lines() do + items[#items + 1] = item:match("[^/]+$") or item + end pipe:close() end table.sort(items) diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua index eaa2839a..fb89e9e4 100644 --- a/tests/mod_link_tests.lua +++ b/tests/mod_link_tests.lua @@ -21,6 +21,7 @@ local Net = require("src.link.Net") local Pokemon = require("src.pokemon.Pokemon") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") +local Session = require("src.link.Session") local S = require("tests.harness").suite("mod link") local check, eq = S.check, S.eq @@ -529,11 +530,19 @@ local function linkGame(name, species, data) return { data = data or Data, save = save, stack = stack, input = mkInput() } end +-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua +-- :75 wraps every backend the same way), so a loopback end has to be wrapped +-- here too or :update reaches for a getStatus the transport does not have. +local function linkSession(transport, role) + return Session.new(transport, { role = role, kind = "link" }) +end + -- two paired states, host already listening and guest already dialling local function pairStates(gameA, gameB) local netA, netB = Net.loopbackPair() local host, guest = LinkState.new(gameA), LinkState.new(gameB) - host.net, guest.net = netA, netB + host.net = linkSession(netA, "host") + guest.net = linkSession(netB, "guest") host.stage, guest.stage = "hosting", "joining" gameA.stack:push(host) gameB.stack:push(guest) @@ -573,7 +582,7 @@ check(host.trade.strict, "a v2 verdict unpacks strictly") local gameOld = linkGame("RED", "PIDGEY") local oldNet, peerNet = Net.loopbackPair() local v1guest = LinkState.new(gameOld) -v1guest.net = oldNet +v1guest.net = linkSession(oldNet, "guest") v1guest.stage = "joining" gameOld.stack:push(v1guest) v1guest:update(1 / 60) @@ -591,7 +600,7 @@ check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules") local gameLone = linkGame("RED", "PIDGEY") local loneNet, silentNet = Net.loopbackPair() local v1host = LinkState.new(gameLone) -v1host.net = loneNet +v1host.net = linkSession(loneNet, "host") v1host.stage = "hosting" gameLone.stack:push(v1host) v1host:update(1 / 60) diff --git a/tests/parity_yellow_pallet_pikachu.lua b/tests/parity_yellow_pallet_pikachu.lua index 57a2c910..f9b44580 100644 --- a/tests/parity_yellow_pallet_pikachu.lua +++ b/tests/parity_yellow_pallet_pikachu.lua @@ -64,6 +64,17 @@ local function freshGame(mapX, mapY) worldViewSize = function() return 160, 144 end, setSGBZones = function() end, } + -- OverworldState is a singleton and :enter does not clear the movement + -- latches, so a scenario that runs after a few thousand other checks can + -- inherit a half-finished script and never walk. Reset them here: this + -- suite asserts a frame budget, so it has to start from a known state. + OverworldState.scriptMoves = {} + OverworldState.pendingScripts = {} + OverworldState.emote = nil + OverworldState.engaging = false + OverworldState.teleportOut = nil + OverworldState.transitioning = nil + OverworldState.wildEncounterGraceSteps = 0 StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up") Game.overworld = OverworldState return pressed @@ -114,6 +125,11 @@ end -- keeps running straight into the lab. -- ===================================================================== scenario(function() + -- The trigger tile is in Pallet's north grass, so the escort walk rolls for + -- wild encounters as it goes. Pin the stream: run standalone this suite got + -- one draw sequence and run inside tests/run_tests.lua another, and one of + -- them dropped a battle on top of the escort and ate the frame budget. + math.randomseed(require("tests.harness").SEED) GameVersion.set("red") local pressed = freshGame(8, 2) local played = {} diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 3122785f..21c4790f 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2788,15 +2788,23 @@ do press("down") eq(om.index, 25, "cursor reaches CONTROLS") press("down") - eq(om.index, 26, "CANCEL stays the fixed final row") - eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen") + eq(om.index, 26, "cursor reaches DATE FORMAT") + press("down") + eq(om.index, 27, "cursor reaches TIME FORMAT") + press("down") + -- CANCEL is appended after the descriptor list rather than living in it, so + -- it lands one past #rows and the window holds the last six boxes. Counted + -- off #rows so the next row added here is not read as a wrap bug. + local cancelRow = #om.rows + 1 + eq(om.index, cancelRow, "CANCEL stays the fixed final row") + eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 26, "up from the top wraps to CANCEL") - eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, cancelRow, "up from the top wraps to CANCEL") + eq(om2.scroll, cancelRow - 5, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options)