From daf8673954fcdf71e45a9de473b7f4171142357d Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sat, 10 Oct 2026 09:37:06 -0400 Subject: [PATCH 1/2] CLOSES #2840, CLOSES #2841 --- src/ui/ListMenu.lua | 10 ++- src/ui/TownMap.lua | 2 +- tests/drivers/pc_item_box_order_2840.lua | 69 +++++++++++++++++++ .../drivers/town_map_marker_bug1437_test.lua | 2 +- tests/drivers/town_map_og_obp0_2841.lua | 38 ++++++++++ tests/engine/pc_item_box_order_bug2840.lua | 48 +++++++++++++ tests/engine/town_map_og_obp0_bug2841.lua | 64 +++++++++++++++++ tests/quick.list | 2 + 8 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 tests/drivers/pc_item_box_order_2840.lua create mode 100644 tests/drivers/town_map_og_obp0_2841.lua create mode 100644 tests/engine/pc_item_box_order_bug2840.lua create mode 100644 tests/engine/town_map_og_obp0_bug2841.lua diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 99111bec..85f62744 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -118,6 +118,7 @@ function ListMenu.new(game, title, items, opts) self.onChoose = opts.onChoose self.onCancel = opts.onCancel self.footer = opts.footer + self.listPrompt = opts.footer self.pageJump = opts.pageJump -- Left/Right move a page at a time self.wrap = opts.wrap -- Up on first / Down on last wraps self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll @@ -312,6 +313,10 @@ end -- PrintListMenuEntries, minus the price column StartMenu_Item never asks for -- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm) function ListMenu:drawItemBox() + local hasBox = self.messageBox or self.footer + -- engine/menus/players_pc.asm:151-162 + local boxUnder = hasBox and self.footer == self.listPrompt + if boxUnder then drawMessageBox(self) end love.graphics.setColor(1, 1, 1, 1) Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th) love.graphics.setColor(0, 0, 0, 1) @@ -356,9 +361,8 @@ function ListMenu:drawItemBox() and (self.arrowBlink or 0) < ARROW_BLINK_ON then Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y) end - -- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID, - -- so the bottom box sits under the list from the first frame - if self.messageBox or self.footer then drawMessageBox(self) end + -- engine/menus/players_pc.asm:173 + if hasBox and not boxUnder then drawMessageBox(self) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index c84f6d4b..53d48463 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -159,7 +159,7 @@ local function markerSheet(def, seed) end if not colors then if PaletteFX.usesSpriteObp() then - colors, group = PaletteFX.ogObjLit() -- engine/items/town_map.asm:325 + colors, group = PaletteFX.ogObjNormal() -- home/palettes.asm:24 else colors, group = PaletteFX.dmgObjLit() end diff --git a/tests/drivers/pc_item_box_order_2840.lua b/tests/drivers/pc_item_box_order_2840.lua new file mode 100644 index 00000000..2dbffc2e --- /dev/null +++ b/tests/drivers/pc_item_box_order_2840.lua @@ -0,0 +1,69 @@ +-- engine/menus/players_pc.asm:151-173 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Menu = require("src.ui.Menu") + local Font = require("src.render.Font") + + local ok = true + local function check(label, cond) + if not cond then ok = false end + U.log(cond and "PASS" or "FAIL", label) + end + local function hasLabel(menu, needle) + if getmetatable(menu) ~= Menu then return false end + for _, it in ipairs(menu.items or {}) do + if type(it.label) == "string" and it.label:find(needle, 1, true) then return true end + end + return false + end + local function mash(cond) + for _ = 1, 200 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return false + end + local function boxOrder(state) + local seen = {} + local real = Font.drawBox + Font.drawBox = function(tx, ty, ...) + seen[#seen + 1] = tx .. "," .. ty + return real(tx, ty, ...) + end + local good, err = pcall(state.draw, state) + Font.drawBox = real + if not good then error(err, 0) end + return table.concat(seen, " ") + end + local function done() + love.event.quit(ok and 0 or 1) + end + + game.save.pcItems = { POTION = 3 } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_POKEDEX = true + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + U.tap(game, "a") + check("pc main menu opens", mash(function() return hasLabel(game.stack:top(), "LOG OFF") end)) + U.wait(8) + U.tap(game, "down") + U.wait(3) + U.tap(game, "a") + check("player pc opens", mash(function() return hasLabel(game.stack:top(), "WITHDRAW ITEM") end)) + U.wait(8) + U.tap(game, "a") + U.wait(10) + local list = game.stack:top() + check("withdraw list open", list and list.kind == "pc_item_withdraw") + if not (list and list.kind == "pc_item_withdraw") then return done() end + check("prompt box under the list", boxOrder(list):find("0,12 4,2", 1, true) ~= nil) + U.still(game, DIR .. "/2840_01_withdraw_list_over_prompt.png") + + U.tap(game, "a") + U.wait(10) + check("How many? box over the list", boxOrder(list):find("4,2 0,12", 1, true) ~= nil) + U.still(game, DIR .. "/2840_02_how_many_over_list.png") + done() +end diff --git a/tests/drivers/town_map_marker_bug1437_test.lua b/tests/drivers/town_map_marker_bug1437_test.lua index 08cb51a0..5e2546c8 100644 --- a/tests/drivers/town_map_marker_bug1437_test.lua +++ b/tests/drivers/town_map_marker_bug1437_test.lua @@ -41,7 +41,7 @@ return function(game) local red = sprites.SPRITE_RED local colors, group if PaletteFX.usesSpriteObp() then - colors, group = PaletteFX.ogObj() + colors, group = PaletteFX.ogObjNormal() else colors, group = PaletteFX.dmgObj() end diff --git a/tests/drivers/town_map_og_obp0_2841.lua b/tests/drivers/town_map_og_obp0_2841.lua new file mode 100644 index 00000000..b2d57f76 --- /dev/null +++ b/tests/drivers/town_map_og_obp0_2841.lua @@ -0,0 +1,38 @@ +-- engine/items/town_map.asm:325 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local PaletteFX = require("src.render.PaletteFX") + local SpriteRenderer = require("src.render.SpriteRenderer") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + + local ok = true + local function check(label, cond) + if not cond then ok = false end + U.log(cond and "PASS" or "FAIL", label) + end + + game.save.visited = { PALLET_TOWN = true, VIRIDIAN_CITY = true } + U.teleport(game, "VIRIDIAN_CITY", 20, 20, "down") + U.wait(10) + game.save.options = game.save.options or {} + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + U.wait(4) + Screens.push(game, "TownMap") + U.wait(20) + local top = game.stack:top() + check("town map is up", top and top.playerQuad ~= nil) + local red = (game.data.sprites or {}).SPRITE_RED + local colors, group = PaletteFX.ogObjNormal() + local want = red and SpriteRenderer.obpImage(red.image, colors, group) + check("marker is the rOBP0 $D0 bake", top and want ~= nil and top.playerSheet == want) + local base = PaletteFX.ogObjBase() + check("OBJ color 1 is white", table.concat(colors[2], ",") == table.concat(base[1], ",")) + for _ = 1, 60 do + if top.blink < 16 then break end + coroutine.yield() + end + U.shot(game, DIR .. "/2841_01_town_map_ogred_marker.png") + love.event.quit(ok and 0 or 1) +end diff --git a/tests/engine/pc_item_box_order_bug2840.lua b/tests/engine/pc_item_box_order_bug2840.lua new file mode 100644 index 00000000..332e9791 --- /dev/null +++ b/tests/engine/pc_item_box_order_bug2840.lua @@ -0,0 +1,48 @@ +-- engine/menus/players_pc.asm:151-173 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local Font = require("src.render.Font") +local ListMenu = require("src.ui.ListMenu") + +local game = { data = { items = {} }, save = {}, input = { wasPressed = function() return false end } } + +local realBox, realDraw, realCode = Font.drawBox, Font.draw, Font.drawCode +local boxes +Font.drawBox = function(tx, ty) boxes[#boxes + 1] = tx .. "," .. ty end +Font.draw = function() end +Font.drawCode = function() end + +local list = ListMenu.new(game, nil, { { label = "POTION", count = 1 }, { label = "CANCEL", cancel = true } }, { + messageBox = true, + footer = "What do you want\nto withdraw?", +}) + +local function order() + boxes = {} + list:draw() + return table.concat(boxes, " ") +end + +eq(order(), "0,12 4,2", "prompt box drawn first, list border on top") +list.footer = "How many?" +eq(order(), "4,2 0,12", "How many? box drawn over the list") +list.footer = "Withdrew\nPOTION." +eq(order(), "4,2 0,12", "withdrew box drawn over the list") +list.footer = "What do you want\nto withdraw?" +eq(order(), "0,12 4,2", "back at the prompt the list is on top again") + +local bag = ListMenu.new(game, "ITEMS", { { label = "CANCEL", cancel = true } }, { itemBox = true }) +boxes = {} +bag:draw() +eq(table.concat(boxes, " "), "4,2", "bag list draws no bottom box") +bag.footer = "The party is full!" +boxes = {} +bag:draw() +eq(table.concat(boxes, " "), "4,2 0,12", "late message box goes over the list") + +Font.drawBox, Font.draw, Font.drawCode = realBox, realDraw, realCode +T.finish("pc item box order bug 2840") diff --git a/tests/engine/town_map_og_obp0_bug2841.lua b/tests/engine/town_map_og_obp0_bug2841.lua new file mode 100644 index 00000000..8fd71b7d --- /dev/null +++ b/tests/engine/town_map_og_obp0_bug2841.lua @@ -0,0 +1,64 @@ +-- engine/items/town_map.asm:325 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local SpriteRenderer = require("src.render.SpriteRenderer") +local TownMap = require("src.ui.TownMap") + +local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode +local realObp = SpriteRenderer.obpImage + +local baked = {} +SpriteRenderer.obpImage = function(path, colors, group) + baked[#baked + 1] = { path = path, colors = colors, group = group } + return nil +end + +local function game() + return { + data = { + field = { + townMap = { PALLET_TOWN = { x = 1, y = 2, name = "PALLET TOWN" } }, + playerSprites = { walk = "SPRITE_RED" }, + }, + sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } }, + maps = {}, + }, + save = {}, + overworld = { map = { id = "PALLET_TOWN" } }, + } +end + +for _, version in ipairs({ "red", "blue" }) do + GameVersion.set(version) + PaletteFX.setMode("ogred") + baked = {} + TownMap.new(game(), {}) + local bake + for _, b in ipairs(baked) do + if b.path == "assets/generated/sprites/red_walk.png" then bake = b end + end + check(bake ~= nil, version .. ": player marker baked") + if bake then + local want, group = PaletteFX.ogObjNormal() + local base = PaletteFX.ogObjBase() + eq(bake.group, group, version .. ": marker uses the rOBP0 $D0 bake") + for i = 2, 4 do + eq(table.concat(bake.colors[i], ","), table.concat(want[i], ","), + version .. ": OBJ color " .. (i - 1)) + end + eq(table.concat(bake.colors[2], ","), table.concat(base[1], ","), + version .. ": OBJ color 1 is the white shade") + end +end + +SpriteRenderer.obpImage = realObp +GameVersion.set(prevVersion) +PaletteFX.setMode(prevMode) + +T.finish("town map OG rOBP0 bug 2841") diff --git a/tests/quick.list b/tests/quick.list index 66ef58ae..946b8199 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -897,3 +897,5 @@ tests/engine/save_editor_toast_s6.lua tests/engine/launcher_save_toast_s6.lua tests/engine/save_editor_map_point_buttons_s8.lua tests/engine/love_run_shell_matrix.lua +tests/engine/pc_item_box_order_bug2840.lua +tests/engine/town_map_og_obp0_bug2841.lua From e81362907abbef0eea01b4759fd52fe12753b7b2 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sat, 10 Oct 2026 14:06:59 -0400 Subject: [PATCH 2/2] CLOSES #2843, CLOSES #2844, CLOSES #2845, CLOSES #2846 --- data/scripts/story2.lua | 11 +- main.lua | 4 +- src/audio/AudioMix.lua | 23 ++++ src/core/Music.lua | 10 +- src/core/game3/audio.lua | 6 +- src/world/PikachuFollower.lua | 12 ++ src/world/gen2/World.lua | 17 ++- tests/drivers/2843_oak_textbox.lua | 69 ++++++++++ .../drivers/continue_fade_no_jump_bug2844.lua | 120 ++++++++++++++++++ tests/drivers/gen2_contest_whiteout_2845.lua | 82 ++++++++++++ .../pikachu_hidden_until_step_2846.lua | 82 ++++++++++++ tests/engine/audio_focus_mode_test.lua | 9 +- .../continue_fade_audio_reset_bug2844.lua | 91 +++++++++++++ tests/gen2_contest_whiteout_test.lua | 119 +++++++++++++++++ tests/parity_yellow_pikachu_under_player.lua | 60 +++++++++ 15 files changed, 695 insertions(+), 20 deletions(-) create mode 100644 src/audio/AudioMix.lua create mode 100644 tests/drivers/2843_oak_textbox.lua create mode 100644 tests/drivers/continue_fade_no_jump_bug2844.lua create mode 100644 tests/drivers/gen2_contest_whiteout_2845.lua create mode 100644 tests/drivers/pikachu_hidden_until_step_2846.lua create mode 100644 tests/engine/continue_fade_audio_reset_bug2844.lua create mode 100644 tests/gen2_contest_whiteout_test.lua create mode 100644 tests/parity_yellow_pikachu_under_player.lua diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 643c615e..663273b6 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -309,11 +309,14 @@ M.PALLET_TOWN = { game.stack:push(TextBox.new(game, t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!", - nil, { auto = { delay = 10, overlap = 10, onOverlap = function() - -- .HeyWaitDontGoOutText turns the player to face down (toward - -- the approaching Oak) before the exclamation bubble + function() + ow.emote = nil + oakAppearsAndWalks() + end, { auto = { delay = 10, overlap = 60, onOverlap = function() + -- scripts/PalletTown.asm:186 ow.player.facing = "down" - ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks } + -- scripts/PalletTown.asm:185 + ow.emote = { npc = ow.player, frames = 60 } end } })) return true end, diff --git a/main.lua b/main.lua index ff8d0187..79d97370 100644 --- a/main.lua +++ b/main.lua @@ -214,9 +214,7 @@ local function applySavedAudioMode() end) if not ok or type(savedOptions) ~= "table" then savedOptions = {} end local mode = savedOptions.audioMode or "both" - if love and love.audio and love.audio.setMixWithSystem then - pcall(love.audio.setMixWithSystem, mode ~= "game_only") - end + require("src.audio.AudioMix").set(mode ~= "game_only") end local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch diff --git a/src/audio/AudioMix.lua b/src/audio/AudioMix.lua new file mode 100644 index 00000000..057df1ee --- /dev/null +++ b/src/audio/AudioMix.lua @@ -0,0 +1,23 @@ +local AudioMix = {} + +local sent + +function AudioMix.set(mix) + mix = mix and true or false + if sent == mix then return false end + if not (love and love.audio and love.audio.setMixWithSystem) then + return false + end + if pcall(love.audio.setMixWithSystem, mix) then sent = mix end + return true +end + +function AudioMix.current() + return sent +end + +function AudioMix._resetForTest() + sent = nil +end + +return AudioMix diff --git a/src/core/Music.lua b/src/core/Music.lua index 8bb6880e..bf0b1557 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -573,9 +573,7 @@ end function Music.setAudioMode(mode) currentAudioMode = mode or "both" - if love and love.audio and love.audio.setMixWithSystem then - pcall(love.audio.setMixWithSystem, currentAudioMode ~= "game_only") - end + require("src.audio.AudioMix").set(currentAudioMode ~= "game_only") Music.setVolumeLevel(storedVolumeLevel) end @@ -634,7 +632,11 @@ function Music.onDeviceReset() local src = require("src.core.ChipAudio").currentSource() if not src then return end state.source = src - applyVolume(src) + if state.fade then + fadeSetLevel(state.fade, state.fade.level) + else + applyVolume(src) + end applyFilter(src) return end diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 3934cbf5..bcfee7d7 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -370,9 +370,7 @@ function Audio.applyEngineOptions(opts) if type(opts) ~= "table" then return end local mode = opts.audioMode or "both" Audio._audioMode = mode - if love and love.audio and love.audio.setMixWithSystem then - pcall(love.audio.setMixWithSystem, mode ~= "game_only") - end + require("src.audio.AudioMix").set(mode ~= "game_only") if mode == "external_only" then Audio._bgmVolume = 0 else @@ -467,7 +465,6 @@ function Audio.playSong(id, opts) } Audio._mapSong = Audio._mapSong or id - -- A new song owns the bus — cancel stale fades and fanfares (oak exit fade was killing lab BGM). Audio._fadeOut = nil Audio._fadeIn = nil Audio._fanfareActive = false @@ -1743,7 +1740,6 @@ function Audio.onFocusGained() end end ---- Device reset: QueueableSource may be dead — rebuild then refill. function Audio.rebuildPlayback() Audio._suspended = false if not (love and love.audio and love.audio.newQueueableSource) then return false end diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index eb3241d9..db4d1a7a 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -204,9 +204,21 @@ local function makeFollower(game, ow, x, y, facing) if idle and idle.phase then return idle.phase % 2 end return NPC.walkPhase(self) end + npc.draw = function(self, camX, camY) + if PikachuFollower.underPlayer(ow, self) then return end + return NPC.draw(self, camX, camY) + end return npc end +-- engine/pikachu/pikachu_follow.asm:1030 +function PikachuFollower.underPlayer(ow, npc) + local p = ow and ow.player + if not (p and npc) or npc.moving then return false end + return npc.cellX == (p.targetX or p.cellX) + and npc.cellY == (p.targetY or p.cellY) +end + local function findFollower(ow) for i, npc in ipairs(ow.npcs or {}) do if npc.pikachuFollower then return npc, i end diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index df83c861..b7bf5c60 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -7067,7 +7067,8 @@ function World:startBattle(opts, onDone) -- whatever stood there. if whiteout then self:healParty() - if not BugContest.isActive(game.save) then + local inContest = BugContest.isActive(game.save) + if not inContest then CallAsm.run(self, "HalveMoney") CallAsm.run(self, "GetWhiteoutSpawn") end @@ -7078,6 +7079,12 @@ function World:startBattle(opts, onDone) Runtime.emit("world.blacked_out", { save = game.save, healTarget = self:healPoint() }) end + if inContest then + self:restoreMapMusic() + -- engine/events/whiteout.asm:24-25 + self:bugContestResults() + return + end -- engine/events/whiteout.asm:19-20 self:runMapSetup(MAPSETUP.WARP, function() self:warpToSpawn() @@ -11078,9 +11085,15 @@ end function World:whiteOut() self:showText( Strings("You have no more\nPOKéMON that can\011fight!"), function() + self:healParty() + -- engine/events/whiteout.asm:15-16 + if BugContest.isActive(self.game and self.game.save) then + -- engine/events/whiteout.asm:24-25 + self:bugContestResults() + return + end CallAsm.run(self, "HalveMoney") CallAsm.run(self, "GetWhiteoutSpawn") - self:healParty() -- Guarded because healPoint walks the spawn table to answer: with nobody -- listening the blackout must not pay for a lookup warpToSpawn is about to -- make again anyway. diff --git a/tests/drivers/2843_oak_textbox.lua b/tests/drivers/2843_oak_textbox.lua new file mode 100644 index 00000000..01257dbe --- /dev/null +++ b/tests/drivers/2843_oak_textbox.lua @@ -0,0 +1,69 @@ +-- scripts/PalletTown.asm:177-188 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + + local ok = true + local function check(label, cond) + if not cond then ok = false end + U.log(cond and "PASS" or "FAIL", label) + end + local function done() + love.event.quit(ok and 0 or 1) + end + + local f = game.save.flags + f.EVENT_FOLLOWED_OAK_INTO_LAB = nil + f.EVENT_GOT_STARTER = nil + U.teleport(game, "PALLET_TOWN", 10, 3, "up") + local ow = game.stack:top() + + local box + for _ = 1, 120 do + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + U.wait(1) + local top = game.stack:top() + if getmetatable(top) == TextBox then box = top break end + end + game.input.state.up = false + check("hey wait text box opened", box ~= nil) + if not box then return done() end + + local bubbleStart + for _ = 1, 600 do + if ow.emote and ow.emote.npc == ow.player then bubbleStart = true break end + U.wait(1) + end + check("bubble appears while text box is up", bubbleStart ~= nil and game.stack:top() == box) + if not bubbleStart then return done() end + U.wait(30) + check("text box still up mid-bubble", game.stack:top() == box) + U.still(game, DIR .. "/2843_01_bubble_with_textbox.png") + + local boxGone, bubbleGone + for _ = 1, 200 do + U.wait(1) + if not boxGone and game.stack:top() ~= box then boxGone = U.frame() end + if not bubbleGone and not (ow.emote and ow.emote.npc == ow.player) then bubbleGone = U.frame() end + if boxGone and bubbleGone then break end + end + local held = box.autoTimer and box.auto and (box.autoTimer - box.auto.delay) + U.log("bubble frames under box", tostring(held)) + check("text box and bubble close on the same frame", + boxGone ~= nil and boxGone == bubbleGone) + check("bubble lasts 60 frames under the box", held == 60) + U.still(game, DIR .. "/2843_02_box_and_bubble_gone.png") + + local oakShown = false + for _ = 1, 60 do + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "PALLETTOWN_OAK" then oakShown = true end + end + if oakShown then break end + U.wait(1) + end + check("oak appears after the bubble", oakShown) + done() +end diff --git a/tests/drivers/continue_fade_no_jump_bug2844.lua b/tests/drivers/continue_fade_no_jump_bug2844.lua new file mode 100644 index 00000000..eaaed83e --- /dev/null +++ b/tests/drivers/continue_fade_no_jump_bug2844.lua @@ -0,0 +1,120 @@ +-- engine/menus/main_menu.asm:106-112, home/fade_audio.asm:12-45 +return function(game) + local U = dofile("tests/drivers/util.lua") + local SaveData = require("src.core.SaveData") + local Music = require("src.core.Music") + local ChipAudio = require("src.core.ChipAudio") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots/2844" + + local fails = 0 + local function check(label, ok, detail) + print((ok and "PASS " or "FAIL ") .. label + .. (detail ~= nil and (" " .. tostring(detail)) or "")) + if not ok then fails = fails + 1 end + return ok + end + local function finish() + print(fails == 0 and "PASS continue_fade_no_jump_bug2844" + or "FAIL continue_fade_no_jump_bug2844") + love.event.quit(fails == 0 and 0 or 1) + while true do coroutine.yield() end + end + local function waitFor(pred, limit) + for _ = 1, limit or 1800 do + if pred() then return true end + U.wait(1) + end + return false + end + + local mixCalls = 0 + local realMix = love.audio.setMixWithSystem + love.audio.setMixWithSystem = function(mix) + mixCalls = mixCalls + 1 + love.event.push("audioreset") + if realMix then return realMix(mix) end + return true + end + + if not check("new game reached the overworld", U.newGame(game)) then + return finish() + end + local ow = game.overworld + game.save.player.map = ow.map.id + game.save.player.x, game.save.player.y = ow.player.cellX, ow.player.cellY + check("save written", SaveData.save(game.save) ~= false) + local mapSong = game.data.audio.mapSongs[ow.map.id] + + game:returnToTitle() + local title = game.stack:top() + for _ = 1, 120 do + title = game.stack:top() + if title and title.screenId == "TitleState" and title.phase == "loop" then + break + end + U.tap(game, "start") + U.wait(10) + end + check("title reached its loop", title and title.phase == "loop") + local titleSong + check("title music playing", waitFor(function() + titleSong = Music.current() + return titleSong ~= nil and titleSong ~= mapSong + and ChipAudio.currentSource() ~= nil + end, 600), titleSong) + + U.tap(game, "start") + check("main menu open", waitFor(function() + return title.menuOpen and game.stack:top() ~= title + end, 600)) + U.wait(4) + local menu = game.stack:top() + U.tap(game, "a") + check("CONTINUE info window open", waitFor(function() + local top = game.stack:top() + return top ~= menu and top and top.titleUiBox and top.titleUiBox[1] == 4 + end, 300)) + U.wait(4) + + local titleSource = ChipAudio.currentSource() + mixCalls = 0 + U.tap(game, "a") + + local fadeFrames, swapped, rose, lastVol = 0, false, false, nil + local switchFrame, shot = nil, false + for f = 1, 300 do + local top = game.stack:top() + if top and top.screenId == "QuarantineReport" then + table.insert(game.input.pressQueue, "a") + end + local cur = Music.current() + local level, _, vol + if Music.fadeLevel then level, _, vol = Music.fadeLevel() end + if level and cur == titleSong then + fadeFrames = fadeFrames + 1 + if ChipAudio.currentSource() ~= titleSource then swapped = true end + local live = ChipAudio.currentSource() + local ok, v = pcall(function() return live:getVolume() end) + v = ok and v or vol + if lastVol and v and v > lastVol + 1e-6 then rose = true end + lastVol = v or lastVol + if fadeFrames == 30 and not shot then + shot = true + U.still(game, SHOT_DIR .. "/2844_01_overworld_title_fading.png") + end + end + if not switchFrame and cur == mapSong then switchFrame = f end + if switchFrame and f > switchFrame + 5 then break end + U.wait(1) + end + + check("CONTINUE sends no setMixWithSystem (no audioreset)", mixCalls == 0, + mixCalls) + check("title fade ran", fadeFrames >= 80, fadeFrames) + check("title keeps its queued stream through the fade (no position jump)", + not swapped) + check("source volume never rises during the fade", not rose) + check("map song takes over after the fade", switchFrame ~= nil, switchFrame) + love.audio.setMixWithSystem = realMix + return finish() +end diff --git a/tests/drivers/gen2_contest_whiteout_2845.lua b/tests/drivers/gen2_contest_whiteout_2845.lua new file mode 100644 index 00000000..bc94b372 --- /dev/null +++ b/tests/drivers/gen2_contest_whiteout_2845.lua @@ -0,0 +1,82 @@ +-- ../pokecrystal/engine/events/whiteout.asm:9-25 +-- ../pokecrystal/engine/events/std_scripts.asm:308-315 +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") +local BugContest = require("src.core.gen2.BugContest") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-contest-whiteout-2845" + +return function(game) + os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null') + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[2845] " .. (cond and "PASS " or "FAIL ") .. line) + return cond + end + local function finish() + print("[2845] " .. (fails == 0 and "all claims passed" or (fails .. " claims failed"))) + love.event.quit(fails == 0 and 0 or 1) + while true do U.wait(60) end + end + local function mapId(world) + return world.map and world.map.def and world.map.def.id + end + + U.wait(45) + local world, save = game.world, game.save + if not ok(world and world.map and world.vm and save, "the world booted") then finish() end + + local lead = Mon.new(game.data, "SENTRET", 3) + lead.hp = 1 + save.party = { lead } + save.player = save.player or {} + save.player.money = 3000 + BugContest.start(save) + world:warpToMapId("NATIONAL_PARK", 10, 20, "down") + U.wait(45) + ok(mapId(world) == "NATIONAL_PARK", "standing in the park mid-contest") + ok(BugContest.isActive(save), "the contest is running") + + local wild = Mon.new(game.data, "SCYTHER", 50) + wild.moves = { { id = "QUICK_ATTACK", pp = 30, maxPp = 30 } } + ok(world:startBattle({ wild = wild, contest = true }), "a contest battle started") + local screen + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + if not ok(screen ~= nil and screen.contest, "the contest battle screen came up") then finish() end + + for _ = 1, 3000 do + if game.stack:top() ~= screen then break end + U.tap(game, "a") + U.wait(3) + end + ok(screen.battle and screen.battle.outcome == "lose", "the battle ended in a loss") + + local atGate = false + for _ = 1, 600 do + if mapId(world) == "ROUTE_36_NATIONAL_PARK_GATE" then atGate = true break end + U.wait(2) + end + ok(atGate, "the wipe warped to the national park gate, not a Pokecenter (map " + .. tostring(mapId(world)) .. ")") + U.wait(30) + U.still(game, SHOT_DIR .. "/2845_01_gate_after_wipe.png") + + local deadline = love.timer.getTime() + 15 + while world.vm:running() and love.timer.getTime() < deadline do + U.tap(game, "a") + U.wait(3) + end + ok(not world.vm:running(), "the judging script ran to completion") + ok(not BugContest.isActive(save), "the contest is over") + ok(mapId(world) == "ROUTE_36_NATIONAL_PARK_GATE", "still at the gate after judging") + ok(save.player.money == 3000, "the wallet was not halved") + ok((save.party[1] and save.party[1].hp or 0) > 0, "the party was healed") + U.wait(10) + U.still(game, SHOT_DIR .. "/2845_02_judged_at_gate.png") + finish() +end diff --git a/tests/drivers/pikachu_hidden_until_step_2846.lua b/tests/drivers/pikachu_hidden_until_step_2846.lua new file mode 100644 index 00000000..69e01217 --- /dev/null +++ b/tests/drivers/pikachu_hidden_until_step_2846.lua @@ -0,0 +1,82 @@ +-- engine/pikachu/pikachu_follow.asm:1030 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local GameVersion = require("src.core.GameVersion") + local NPC = require("src.world.NPC") + local PF = require("src.world.PikachuFollower") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local failures = 0 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end + return ok + end + + check("running as Yellow", GameVersion.isYellow()) + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.pikachuInBall = false + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.onBike = false + + local pikaDraws = 0 + local realDraw = NPC.draw + NPC.draw = function(self, ...) + if self.pikachuFollower then pikaDraws = pikaDraws + 1 end + return realDraw(self, ...) + end + + local function plain(map, x, y) + return map:inBounds(x, y) and map:isWalkableCell(x, y) + and not map:isGrassCell(x, y) + end + + U.teleport(game, "ROUTE_1", 10, 20, "down") + U.wait(5) + local ow = game.overworld + local sx, sy + for y = 4, 30 do + for x = 2, 16 do + if not sx and plain(ow.map, x, y) and plain(ow.map, x, y + 1) + and plain(ow.map, x, y + 2) and plain(ow.map, x - 1, y) + and plain(ow.map, x + 1, y) then + sx, sy = x, y + end + end + end + check("found an open Route 1 cell", sx ~= nil) + sx, sy = sx or 10, sy or 20 + U.teleport(game, "ROUTE_1", sx, sy, "down") + U.wait(30) + ow = game.overworld + + local npc = PF.current(ow) + local p = ow.player + check("follower spawned on the Route 1 map load", npc ~= nil) + check("spawn state 0 parks Pikachu on the player's cell", + npc and npc.cellX == p.cellX and npc.cellY == p.cellY) + + pikaDraws = 0 + local shotOk = U.still(game, SHOT_DIR .. "/2846_01_route1_before_step.png") + check("Pikachu is not drawn before the first step (draws=" .. pikaDraws .. ")", + shotOk and pikaDraws == 0) + + U.hold(game, "down", 20) + U.wait(20) + npc = PF.current(ow) + p = ow.player + check("after one step Pikachu trails one cell behind", + npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1) + pikaDraws = 0 + shotOk = U.still(game, SHOT_DIR .. "/2846_02_route1_after_step.png") + check("Pikachu is drawn after the first step (draws=" .. pikaDraws .. ")", + shotOk and pikaDraws > 0) + + NPC.draw = realDraw + U.log(failures == 0 and "PASS all" or ("FAIL " .. failures .. " check(s)")) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/engine/audio_focus_mode_test.lua b/tests/engine/audio_focus_mode_test.lua index c6096649..b7cdde05 100644 --- a/tests/engine/audio_focus_mode_test.lua +++ b/tests/engine/audio_focus_mode_test.lua @@ -36,10 +36,15 @@ Music.applyOptions({ audioMode = "both", musicVol = 7 }) eq(Music.audioMode(), "both", "Music mode set to 'both'") eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'both' enables mixWithSystem") +mixWithSystemCalls = {} +Music.applyOptions({ audioMode = "both", musicVol = 7 }) +eq(#mixWithSystemCalls, 0, "re-applying unchanged options sends no setMixWithSystem (#2844)") + mixWithSystemCalls = {} Music.applyOptions({ audioMode = "external_only", musicVol = 7 }) eq(Music.audioMode(), "external_only", "Music mode set to 'external_only'") -eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'external_only' enables mixWithSystem") +eq(#mixWithSystemCalls, 0, "'both' -> 'external_only' keeps mixWithSystem on without a resend") +eq(require("src.audio.AudioMix").current(), true, "audioMode 'external_only' enables mixWithSystem") mixWithSystemCalls = {} Music.applyOptions({ audioMode = "game_only", musicVol = 7 }) @@ -60,7 +65,7 @@ Game3Audio.applyEngineOptions({ audioMode = "external_only", musicVol = 7, sfxVo eq(Game3Audio._audioMode, "external_only", "Game3 audioMode set to 'external_only'") eq(Game3Audio._bgmVolume, 0, "Game3 'external_only' mutes in-game BGM") check(Game3Audio._sfxVolume > 0, "Game3 'external_only' keeps SFX active") -eq(mixWithSystemCalls[#mixWithSystemCalls], true, "Game3 'external_only' enables mixWithSystem") +eq(require("src.audio.AudioMix").current(), true, "Game3 'external_only' enables mixWithSystem") mixWithSystemCalls = {} Game3Audio.applyEngineOptions({ audioMode = "game_only", musicVol = 7, sfxVol = 7 }) diff --git a/tests/engine/continue_fade_audio_reset_bug2844.lua b/tests/engine/continue_fade_audio_reset_bug2844.lua new file mode 100644 index 00000000..fd1442fa --- /dev/null +++ b/tests/engine/continue_fade_audio_reset_bug2844.lua @@ -0,0 +1,91 @@ +-- home/fade_audio.asm:12-35 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end + +local current +local resets = 0 +local fakeChip = { + playMusic = function() + current = setmetatable({ playing = true }, Source) + return current + end, + currentSource = function() return current end, + rebuildPlayback = function() + current = setmetatable({ playing = true }, Source) + resets = resets + 1 + return true + end, + stopMusic = function() current = nil end, + update = function() end, + ensureMusicPlaying = function() end, + holdMusic = function() end, + awaitingFirstBuffer = function() return false end, + applyOptions = function() return false end, + setStereo = function() end, +} +package.loaded["src.core.ChipAudio"] = fakeChip + +local Music = require("src.core.Music") + +local mixCalls = 0 +love.audio = { + newSource = function() return setmetatable({}, Source) end, + setMixWithSystem = function() + mixCalls = mixCalls + 1 + fakeChip.rebuildPlayback() + Music.onDeviceReset() + return true + end, +} + +local data = { audio = { + songs = { + Music_TitleScreen = { chip = true }, + Music_PalletTown = { chip = true }, + }, + mapSongs = { PALLET_TOWN = "Music_PalletTown" }, +} } + +local opts = { audioMode = "both", musicVol = 7, musicFilter = 0 } + +Music.applyOptions(opts) +Music.play(data, "Music_TitleScreen") +local title = current +eq(Music.current(), "Music_TitleScreen", "title theme playing") + +mixCalls, resets = 0, 0 +Music.applyOptions(opts) +eq(mixCalls, 0, "CONTINUE re-applying the same options sends no setMixWithSystem") +eq(resets, 0, "so the playing title stream is not rebuilt mid-song") +check(current == title, "the title keeps its queued stream") + +local full = current.volume +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +eq(Music.current(), "Music_TitleScreen", "title fades before the map theme") +for _ = 1, 3 * (Music.MAP_FADE + 1) do Music.update(data) end +local faded = current.volume +check(faded < full, "fade has lowered the title volume") + +fakeChip.rebuildPlayback() +Music.onDeviceReset() +check(math.abs(current.volume - faded) < 1e-9, + "a device reset mid-fade keeps the faded level") + +T.finish("continue_fade_audio_reset_bug2844") diff --git a/tests/gen2_contest_whiteout_test.lua b/tests/gen2_contest_whiteout_test.lua new file mode 100644 index 00000000..7c098f3c --- /dev/null +++ b/tests/gen2_contest_whiteout_test.lua @@ -0,0 +1,119 @@ +-- engine/events/whiteout.asm:9-25, engine/events/std_scripts.asm:308-315 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 contest whiteout") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Screens = require("src.ui.Screens") +local Events = require("src.world.gen2.Events") +local Save = require("src.core.gen2.Save") +local BugContest = require("src.core.gen2.BugContest") + +local DATA = { + pokemon = { + CATERPIE = { id = "CATERPIE", name = "CATERPIE", index = 10, baseExp = 53, + growthRate = "MEDIUM_FAST", stats = { hp = 45, attack = 30, + defense = 35, speed = 45, specialAttack = 20, specialDefense = 20 }, + types = { "BUG" } }, + RATTATA = { id = "RATTATA", name = "RATTATA", index = 19, baseExp = 57, + growthRate = "MEDIUM_FAST", stats = { hp = 30, attack = 56, + defense = 35, speed = 72, specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL" } }, + }, + moves = {}, +} + +local function mon(species, level, hp) + return { species = species, name = species, nickname = species, + level = level, hp = hp, maxHp = 30, + stats = { hp = 30, attack = 10, defense = 10, speed = 10, + specialAttack = 10, specialDefense = 10 }, + moves = {}, experience = 0, statExp = {}, dvs = {} } +end + +local function contestSave() + local save = Save.normalize({ party = { mon("RATTATA", 10, 0) }, + player = { name = "GOLD", money = 3000 } }) + BugContest.start(save) + return save +end + +local function contestWorld(save) + local captured, log = {}, {} + Screens.invalidate() + local game = { + data = { + pokemon = DATA.pokemon, moves = DATA.moves, + screens = { + Gen2BattleState = function(_, opts) + captured.opts = opts + return { screenId = "Gen2BattleState" } + end, + }, + }, + save = save, + stack = { push = function() end, pop = function() end }, + } + local world = World.new(game) + game.world = world + world.map = { def = { id = "NATIONAL_PARK" } } + world.maps = { NATIONAL_PARK = world.map.def } + world.events = Events.new() + world.playBattleMusic = function() end + world.battleMusicContext = function() return nil end + world.pushBattleTransition = function() return nil end + world.restoreMapMusic = function() end + world.roamMonsAfterBattle = function() end + world.healParty = function() log[#log + 1] = "heal" end + world.runMapSetup = function(_, _, fn) log[#log + 1] = "mapsetup" fn() end + world.warpToSpawn = function() log[#log + 1] = "spawn" end + world.bugContestResults = function() log[#log + 1] = "results" return true end + world.bugContestOver = function() log[#log + 1] = "over" return true end + world.showText = function(_, _, onDone) if onDone then onDone() end end + return world, captured, log +end + +local function has(log, entry) + for _, e in ipairs(log) do if e == entry then return true end end + return false +end + +do + local save = contestSave() + local world, captured, log = contestWorld(save) + world:startBattle({ wild = mon("CATERPIE", 7, 20), contest = true }) + check(captured.opts ~= nil, "a contest encounter pushes the battle screen") + captured.opts.onDone("lose") + check(has(log, "heal"), "the party is healed") + check(has(log, "results"), + "a contest wipe runs BugContestResultsWarpScript") + check(not has(log, "spawn"), "and does not warp to the last Pokecenter") + check(not has(log, "over"), "and does not also run the out-of-balls script") + eq(save.player.money, 3000, "nor halve the wallet") +end + +do + local save = Save.normalize({ party = { mon("RATTATA", 10, 0) }, + player = { name = "GOLD", money = 3000 } }) + local world, captured, log = contestWorld(save) + world:startBattle({ wild = mon("CATERPIE", 7, 20) }) + captured.opts.onDone("lose") + check(has(log, "spawn"), "outside the contest a wipe still warps home") + check(not has(log, "results"), "and never runs the contest results") + eq(save.player.money, 1500, "and halves the wallet") +end + +do + local save = contestSave() + local world, _, log = contestWorld(save) + world:whiteOut() + check(has(log, "results"), + "a poison wipe in the park also goes to the contest results") + check(not has(log, "spawn"), "rather than to the last Pokecenter") + eq(save.player.money, 3000, "and keeps the wallet whole") +end + +Screens.invalidate() + +return S.finish() diff --git a/tests/parity_yellow_pikachu_under_player.lua b/tests/parity_yellow_pikachu_under_player.lua new file mode 100644 index 00000000..ee6fb971 --- /dev/null +++ b/tests/parity_yellow_pikachu_under_player.lua @@ -0,0 +1,60 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("parity Yellow Pikachu under player") +local check, eq = S.check, S.eq +local Data = require("src.core.Data") +Data:load() +local NPC = require("src.world.NPC") +local Follower = require("src.world.PikachuFollower") + +local spriteDef = Data.sprites and (Data.sprites.SPRITE_PIKACHU or Data.sprites.SPRITE_RED) +if not spriteDef then + for _, def in pairs(Data.sprites or {}) do spriteDef = def break end +end +if not spriteDef then + print("[skip] parity Yellow Pikachu under player: no sprite table") + S.finish() + return +end + +local game = { data = { sprites = { SPRITE_PIKACHU = spriteDef }, field = {} }, + save = { party = {} } } +local walkable = function() return true end +local ow = { + map = { id = "ROUTE_1", inBounds = walkable, isWalkableCell = walkable }, + player = { cellX = 5, cellY = 9, facing = "down" }, + npcs = {}, entities = {}, +} + +local restore = Follower.setShouldSpawn(function() return true end) +local draws = 0 +local realDraw = NPC.draw +NPC.draw = function() draws = draws + 1 end + +Follower.onMapEntered(game, ow, nil, true) +local pika = Follower.current(ow) +check(pika, "a map load spawns the follower") +eq(pika and pika.cellX, 5, "spawn state 0 puts Pikachu on the player's column") +eq(pika and pika.cellY, 9, "spawn state 0 puts Pikachu on the player's row") +check(pika and Follower.underPlayer(ow, pika), "the follower counts as under the player") +pika:draw(0, 0) +eq(draws, 0, "Pikachu on the player's cell draws nothing before the first step") + +ow.player.targetX, ow.player.targetY = 5, 10 +pika:draw(0, 0) +eq(draws, 1, "once the player commits a step off the cell Pikachu is drawn") +ow.player.cellX, ow.player.cellY = 5, 10 +ow.player.targetX, ow.player.targetY = nil, nil +pika:draw(0, 0) +eq(draws, 2, "and stays drawn one cell behind") + +Follower.onMapEntered(game, ow, { pikachuSpawn = 1 }, true) +pika = Follower.current(ow) +check(pika and not Follower.underPlayer(ow, pika), "a warp spawn beside the player is not hidden") +pika:draw(0, 0) +eq(draws, 3, "and draws") + +NPC.draw = realDraw +Follower.setShouldSpawn(restore) +S.finish()