diff --git a/data/scripts/flavor/viridian_city.lua b/data/scripts/flavor/viridian_city.lua index 9213bb6c..82e6485f 100644 --- a/data/scripts/flavor/viridian_city.lua +++ b/data/scripts/flavor/viridian_city.lua @@ -34,25 +34,22 @@ end M.VIRIDIAN_CITY = { talk = { - -- ViridianCityGambler1Text (scripts/ViridianCity.asm): normally - -- comments that the gym is "always closed"; once the 7th badge is - -- earned (badges == ~EARTHBADGE) but Giovanni hasn't been beaten - -- yet, he instead says the gym leader returned. + -- pokered/scripts/ViridianCity.asm:150 + -- pokeyellow/scripts/ViridianCity_2.asm:10 TEXT_VIRIDIANCITY_GAMBLER1 = function(game, ow, npc, done) local t = text(game) local sevenBadges = game.save.inventory and game.save.inventory.BOULDERBADGE and game.save.inventory.CASCADEBADGE and game.save.inventory.THUNDERBADGE and game.save.inventory.RAINBOWBADGE and game.save.inventory.SOULBADGE and game.save.inventory.MARSHBADGE - and game.save.inventory.VOLCANOBADGE - -- (pokered checks EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI; the port's flag for - -- that win is EVENT_BEAT_GIOVANNI, set by victories.lua OPP_GIOVANNI#3) - if sevenBadges and not (game.save.flags and game.save.flags.EVENT_BEAT_GIOVANNI) then - push(game, t._ViridianCityGambler1GymLeaderReturnedText - or "VIRIDIAN GYM's\nLEADER returned!", done) + and game.save.inventory.VOLCANOBADGE and not game.save.inventory.EARTHBADGE + local flags = game.save.flags or {} + local beatGiovanni = flags.EVENT_BEAT_GIOVANNI + or flags.EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI + if sevenBadges or beatGiovanni then + push(game, t._ViridianCityGambler1GymLeaderReturnedText, done) else - push(game, t._ViridianCityGambler1GymAlwaysClosedText - or "This POKéMON GYM\nis always closed.\nI wonder who the\nLEADER is?", done) + push(game, t._ViridianCityGambler1GymAlwaysClosedText, done) end end, diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 61363338..2d8aea3f 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -1297,6 +1297,8 @@ local function disobedient(M) local species = tonumber(user.species) local ob = 0 if not ((species == 151 or species == 410) and mon.fatefulEncounter == false) then + -- pokeemerald/src/battle_util.c:3924 + if BattleProfile.isRse(st) and Kinds.has(st, "frontier") then return nil end if not Engine.isTradedMon(st, mon) or Engine.hasBadge(st, 8) then return nil end ob = 10 if Engine.hasBadge(st, 2) then ob = 30 end diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 091690c0..ba78a01b 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -402,6 +402,10 @@ local function finish(result) require("src.core.game3.battle.link_guard").disarm() Battle._active = false Battle._phase = nil + Ui.clearCaughtDexScene() + Battle._catchDexReturn = nil + if Battle._rseDex then require("src.ui.game3.rse.pokedex").reset() end + Battle._rseDex = nil Battle._residualEvents = nil Battle._residualIndex = 1 Battle._residualStepState = nil @@ -3551,12 +3555,15 @@ local function finish_catch_flow(catchRes, ename, nicknamed) return end end + Ui.clearCaughtDexScene() Battle._actions = {} Battle._pendingEnd = "catch" Battle._phase = "ending" end local function start_post_catch_flow(catchRes) + Ui.clearCaughtDexScene() + Battle._catchDexReturn = nil -- pokefirered/data/battle_scripts_2.s:99 BattleScript_OldMan_Pokedude_CaughtMessage if Battle._headless or (Battle._st and (Battle._st.oldManTutorial or Battle._st.pokedude or Wally.active(Battle._st))) then @@ -3580,14 +3587,17 @@ local function start_post_catch_flow(catchRes) or Pokemon.name(sp) local gender = (mon and (mon.gender or (mon.isFemale and 1))) or (enemy and enemy.gender) or 0 local personality = (mon and mon.personality) or 0 + local caughtState = Battle._st local function prompt_nickname() Battle._phase = "catch_nickname_prompt" -- pokefirered/src/battle_message.c:477 Ui.askYesNo(BattleText.get("STRINGID_GIVENICKNAMECAPTURED", { opponentMon1 = ename }), function(yes) + if not Battle._active or Battle._st ~= caughtState or Battle._phase ~= "catch_nickname_prompt" then return end if yes then local okN, Naming = pcall(require, "src.ui.game3.naming") if okN and Naming and Naming.open then + Ui.clearCaughtDexScene() Battle._phase = "catch_naming" Naming.open({ template = "CAUGHT_MON", @@ -3603,6 +3613,7 @@ local function start_post_catch_flow(catchRes) -- was 141px wide and spilled over the frame's right edge. title = Naming.monTitle(Pokemon.name(sp)), onDone = function(nick) + if not Battle._active or Battle._st ~= caughtState or Battle._phase ~= "catch_naming" then return end if nick and nick ~= "" and nick ~= ename then if mon then mon.nickname = nick end end @@ -3622,12 +3633,20 @@ local function start_post_catch_flow(catchRes) local Runtime = package.loaded["src.core.game3.runtime"] Battle._phase = "pokedex_reg" Battle._rseDex = true + -- pokeemerald/src/battle_script_commands.c:10115 + local dexMon = (enemy and enemy.mon) or mon require("src.ui.game3.rse.pokedex").showCaughtMon(sp, { session = Runtime and Runtime.getSession and Runtime.getSession(), - personality = personality, - onDone = function() + personality = dexMon and dexMon.personality or personality, + otId = dexMon and dexMon.otId, + otSecretId = dexMon and dexMon.otSecretId, + shiny = Pokemon.isShiny(dexMon), + onDone = function(caught) + if not Battle._active or Battle._st ~= caughtState or Battle._phase ~= "pokedex_reg" then return end Battle._rseDex = nil - prompt_nickname() + Ui.beginCaughtDexScene(caught) + Battle._phase = "catch_dex_return" + Battle._catchDexReturn = prompt_nickname end, }) return @@ -3641,7 +3660,19 @@ local function start_post_catch_flow(catchRes) Pokedex.showRegistration(sp, { session = session, onDone = function() - prompt_nickname() + if not Battle._active or Battle._st ~= caughtState or Battle._phase ~= "pokedex_reg" then return end + -- pokefirered/src/battle_script_commands.c:9699 + local target = (enemy and enemy.mon) or mon + local pid = target and target.personality or personality + local shiny = Pokemon.isShiny(target) + local pic = Pokemon.frontPic(Pokemon.picSpecies(sp, pid), nil, shiny, pid) + Ui.beginCaughtDexScene({ + family = "frlg", personality = pid, + otId = target and target.otId, otSecretId = target and target.otSecretId, + shiny = shiny, sprite = { img = pic.image, x = 120, y = 64 }, + }) + Battle._phase = "catch_dex_return" + Battle._catchDexReturn = prompt_nickname end, }) return @@ -4030,6 +4061,15 @@ update_body = function(dt, game) return end + if Battle._phase == "catch_dex_return" then + if Ui.updateCaughtDexScene() then + local cb = Battle._catchDexReturn + Battle._catchDexReturn = nil + if cb then cb() end + end + return + end + if Battle._phase == "catch_naming" then return end @@ -4196,6 +4236,10 @@ end -- pokefirered/src/main.c:480 function Battle.reset() + Ui.clearCaughtDexScene() + Battle._catchDexReturn = nil + if Battle._rseDex then require("src.ui.game3.rse.pokedex").reset() end + Battle._rseDex = nil stop_low_hp_song() require("src.core.game3.battle.link_guard").disarm() Battle._active = false diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 2cc34ebf..4ab8b6f5 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -204,6 +204,7 @@ end function Ui.reset(opts) opts = opts or {} + Ui._caughtDexScene = nil Ui._timed = nil Ui._linger = false Ui._queue = {} @@ -2551,11 +2552,55 @@ local function draw_party_bars(stage) end end +-- pokeemerald/src/battle_script_commands.c:10131 +function Ui.beginCaughtDexScene(caught) + local Pal = require("src.core.game3.pal_fade") + caught.pal = Pal.new() + -- pokefirered/src/battle_script_commands.c:9709 + caught.pal:beginFade(caught.family == "frlg" and 0x1FFFF or Pal.BG, 0, 16, 0, Pal.BLACK) + Ui._caughtDexScene = caught +end + +-- pokeemerald/src/pokedex.c:4079 +function Ui.updateCaughtDexScene() + local c = Ui._caughtDexScene + if not c then return true end + local spr = c.sprite + local centerY = c.family == "frlg" and 64 or 80 + if spr.x < 120 then spr.x = math.min(120, spr.x + 2) end + if spr.x > 120 then spr.x = math.max(120, spr.x - 2) end + if spr.y < centerY then spr.y = math.min(centerY, spr.y + 1) end + if spr.y > centerY then spr.y = math.max(centerY, spr.y - 1) end + c.pal:updateFade() + return not c.pal:fadeActive() +end + +function Ui.clearCaughtDexScene() + Ui._caughtDexScene = nil +end + function Ui.draw(w, h) if not (love and love.graphics) then return end w = w or Display.W h = h or Display.H + local caught = Ui._caughtDexScene + if caught then + -- pokeemerald/src/battle_script_commands.c:10133 + local Fx = require("src.core.game3.gba_fx") + Fx.draw(function() + BattleChrome.drawPostDexBg(BattleBg.sheetKey()) + BattleChrome.drawPanel("none") + end, caught.pal:fx(0)) + local spr = caught.sprite + Fx.draw(function() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(spr.img, spr.x + (spr.x2 or 0), spr.y + (spr.y2 or 0), 0, 1, spr.scaleY or 1, 32, 32) + end, caught.pal:fx(16)) + if Choice and Choice.active and Choice.draw then Choice.draw() end + return + end + local st = Ui._st local stage = Anim.stage and Anim.stage() diff --git a/src/core/game3/bike/rse.lua b/src/core/game3/bike/rse.lua index 5f592d47..2df8c3c7 100644 --- a/src/core/game3/bike/rse.lua +++ b/src/core/game3/bike/rse.lua @@ -183,6 +183,8 @@ local function objectEventCollision(game, x, y, dir, beh) local P = player() if Collision.ledgeLanding(game, P.cellX, P.cellY, dir) then return C.LEDGE_JUMP end if not Collision.inBounds(x, y) then return C.NONE end + -- pokeemerald/src/event_object_movement.c:4663 + if Collision.isWarpDoor(beh) then return C.IMPASSABLE end local ok, why = Collision.canEnter(game, x, y, { fromX = P.cellX, fromY = P.cellY, dir = dir, surfing = P.surfing, elevation = P.currentElevation, }) diff --git a/src/core/game3/rse/frontier/arena.lua b/src/core/game3/rse/frontier/arena.lua index dd888dfa..f0298b17 100644 --- a/src/core/game3/rse/frontier/arena.lua +++ b/src/core/game3/rse/frontier/arena.lua @@ -35,7 +35,7 @@ function Arena.init(sess) if bit.band(tonumber(f.winStreakActiveFlags) or 0, streakFlag(sess)) == 0 then Util.set1(f.arenaWinStreaks, lvl(sess), 0) end - sess.dynamicWarp = { map = sess.map, warpId = 0xFF } + sess.dynamicWarp = { map = sess.map, warpId = 0xFF, x = sess.x, y = sess.y } sess.frontierOpponentA = 0 end @@ -74,10 +74,11 @@ end -- pokeemerald/src/battle_arena.c:731 function Arena.save(ctx, sess) local f = Util.frontier(sess) - f.challengeStatus = Rse.specialVar(ctx, Util.VAR_0x8005) - Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) - f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.saveChallenge(sess, "VAR_TEMP_CHALLENGE_STATUS", function() + f.challengeStatus = Rse.specialVar(ctx, Util.VAR_0x8005) + Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) + f.challengePaused = 1 + end) end -- pokeemerald/src/battle_arena.c:739 diff --git a/src/core/game3/rse/frontier/dome.lua b/src/core/game3/rse/frontier/dome.lua index 72cab902..096e5000 100644 --- a/src/core/game3/rse/frontier/dome.lua +++ b/src/core/game3/rse/frontier/dome.lua @@ -148,7 +148,7 @@ function Dome.init(sess) if bit.band(tonumber(f.winStreakActiveFlags) or 0, streakFlag(mode, lvl)) == 0 then Util.set2(f.domeWinStreaks, mode, lvl, 0) end - sess.dynamicWarp = { map = sess.map, warpId = 0xFF } + sess.dynamicWarp = { map = sess.map, warpId = 0xFF, x = sess.x, y = sess.y } sess.frontierOpponentA = 0 end @@ -639,10 +639,11 @@ end -- pokeemerald/src/battle_dome.c:3020 function Dome.save(ctx, sess) local f = Util.frontier(sess) - f.challengeStatus = Rse.specialVar(ctx, Util.VAR_0x8005) - Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) - f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.saveChallenge(sess, "VAR_TEMP_CHALLENGE_STATUS", function() + f.challengeStatus = Rse.specialVar(ctx, Util.VAR_0x8005) + Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) + f.challengePaused = 1 + end) end -- pokeemerald/src/battle_dome.c:5415 diff --git a/src/core/game3/rse/frontier/factory.lua b/src/core/game3/rse/frontier/factory.lua index 1be13920..8440c14a 100644 --- a/src/core/game3/rse/frontier/factory.lua +++ b/src/core/game3/rse/frontier/factory.lua @@ -109,7 +109,7 @@ function Factory.init(sess) local r = rentals(sess) for i = 1, Factory.RENTAL_COUNT do r[i].monId = Factory.NO_MON end sess.frontierTempParty = { Factory.NO_MON, Factory.NO_MON, Factory.NO_MON } - sess.dynamicWarp = { map = sess.map, warpId = 0xFF } + sess.dynamicWarp = { map = sess.map, warpId = 0xFF, x = sess.x, y = sess.y } sess.frontierOpponentA = 0 end @@ -155,10 +155,11 @@ end -- pokeemerald/src/battle_factory.c:269 function Factory.save(ctx, sess) local f = Util.frontier(sess) - f.challengeStatus = specialVar(ctx, Util.VAR_0x8005) - Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) - f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.saveChallenge(sess, "VAR_TEMP_CHALLENGE_STATUS", function() + f.challengeStatus = specialVar(ctx, Util.VAR_0x8005) + Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) + f.challengePaused = 1 + end) end -- pokeemerald/src/battle_factory.c:298 diff --git a/src/core/game3/rse/frontier/palace.lua b/src/core/game3/rse/frontier/palace.lua index bec4a055..d3f827fd 100644 --- a/src/core/game3/rse/frontier/palace.lua +++ b/src/core/game3/rse/frontier/palace.lua @@ -48,7 +48,7 @@ function Palace.init(sess) if bit.band(tonumber(f.winStreakActiveFlags) or 0, streakFlag(mode, lvl)) == 0 then Util.set2(f.palaceWinStreaks, mode, lvl, 0) end - sess.dynamicWarp = { map = sess.map, warpId = 0xFF } + sess.dynamicWarp = { map = sess.map, warpId = 0xFF, x = sess.x, y = sess.y } sess.frontierOpponentA = 0 end @@ -131,10 +131,11 @@ end -- pokeemerald/src/battle_palace.c:182 function Palace.save(ctx, sess) local f = Util.frontier(sess) - f.challengeStatus = Rse.specialVar(ctx, Util.VAR_0x8005) - Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) - f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.saveChallenge(sess, "VAR_TEMP_CHALLENGE_STATUS", function() + f.challengeStatus = Rse.specialVar(ctx, Util.VAR_0x8005) + Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) + f.challengePaused = 1 + end) end -- pokeemerald/src/battle_palace.c:190 diff --git a/src/core/game3/rse/frontier/pike.lua b/src/core/game3/rse/frontier/pike.lua index dcaa07fb..9ac1053f 100644 --- a/src/core/game3/rse/frontier/pike.lua +++ b/src/core/game3/rse/frontier/pike.lua @@ -207,7 +207,7 @@ function Pike.save(ctx, sess) f.challengeStatus = specialVar(ctx, Util.VAR_0x8005) Rse.setVar("VAR_TEMP_CHALLENGE_STATUS", 0, sess) f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.persist() end -- pokeemerald/src/battle_pike.c:727 diff --git a/src/core/game3/rse/frontier/tents.lua b/src/core/game3/rse/frontier/tents.lua index d7cf9470..35c17e08 100644 --- a/src/core/game3/rse/frontier/tents.lua +++ b/src/core/game3/rse/frontier/tents.lua @@ -27,7 +27,7 @@ function Tents.init(sess) f.challengeStatus = 0 f.curChallengeBattleNum = 0 f.challengePaused = 0 - sess.dynamicWarp = { map = sess.map, warpId = 0xFF } + sess.dynamicWarp = { map = sess.map, warpId = 0xFF, x = sess.x, y = sess.y } end -- pokeemerald/src/battle_tent.c:118 @@ -43,10 +43,11 @@ end -- pokeemerald/src/battle_tent.c:140 function Tents.save(ctx, sess) local f = Util.frontier(sess) - f.challengeStatus = specialVar(ctx, Util.VAR_0x8005) - Rse.setVar("VAR_TEMP_0", 0, sess) - f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.saveChallenge(sess, "VAR_TEMP_0", function() + f.challengeStatus = specialVar(ctx, Util.VAR_0x8005) + Rse.setVar("VAR_TEMP_0", 0, sess) + f.challengePaused = 1 + end) end -- pokeemerald/src/battle_tent.c:148 diff --git a/src/core/game3/rse/frontier/tower.lua b/src/core/game3/rse/frontier/tower.lua index f968bf82..c88cb0fe 100644 --- a/src/core/game3/rse/frontier/tower.lua +++ b/src/core/game3/rse/frontier/tower.lua @@ -58,7 +58,7 @@ function Tower.init(sess) if bit.band(tonumber(f.winStreakActiveFlags) or 0, streakFlag(mode, lvl)) == 0 then Util.set2(f.towerWinStreaks, mode, lvl, 0) end - sess.dynamicWarp = { map = sess.map, warpId = 0xFF } + sess.dynamicWarp = { map = sess.map, warpId = 0xFF, x = sess.x, y = sess.y } sess.frontierOpponentA = 0 end @@ -262,16 +262,17 @@ end -- pokeemerald/src/battle_tower.c:2194 function Tower.save(ctx, sess) local f = Util.frontier(sess) - local mode, lvl = modeLvl(sess) - local challengeNum = math.floor(Util.get2(f.towerWinStreaks, mode, lvl) / D.STAGES_PER_CHALLENGE) - local status = specialVar(ctx, Util.VAR_0x8005) - if status == 0 and (challengeNum > 1 or (tonumber(f.curChallengeBattleNum) or 0) ~= 0) then - Tower.saveBattleTowerRecord(sess) - end - f.challengeStatus = status - Rse.setVar("VAR_TEMP_0", 0, sess) - f.challengePaused = 1 - return Util.saveGameFrontier(sess) + return Util.saveChallenge(sess, "VAR_TEMP_0", function() + local mode, lvl = modeLvl(sess) + local challengeNum = math.floor(Util.get2(f.towerWinStreaks, mode, lvl) / D.STAGES_PER_CHALLENGE) + local status = specialVar(ctx, Util.VAR_0x8005) + if status == 0 and (challengeNum > 1 or (tonumber(f.curChallengeBattleNum) or 0) ~= 0) then + Tower.saveBattleTowerRecord(sess) + end + f.challengeStatus = status + Rse.setVar("VAR_TEMP_0", 0, sess) + f.challengePaused = 1 + end) end -- pokeemerald/src/battle_tower.c:1936 diff --git a/src/core/game3/rse/frontier/util.lua b/src/core/game3/rse/frontier/util.lua index 9f3ee34b..db8603a4 100644 --- a/src/core/game3/rse/frontier/util.lua +++ b/src/core/game3/rse/frontier/util.lua @@ -200,7 +200,7 @@ function Util.newGame(sess) Util.clearRankingHallRecords(sess) end -Util.SAVE_FIELDS = { "frontier", "hallRecords1P", "hallRecords2P" } +Util.SAVE_FIELDS = { "frontier", "hallRecords1P", "hallRecords2P", "savedPlayerParty" } local okS, SaveSections = pcall(require, "src.core.game3.save_sections") if okS and SaveSections then @@ -786,14 +786,70 @@ end -- pokeemerald/src/frontier_util.c:2421 function Util.saveGameFrontier(sess) + local warp = sess.dynamicWarp + if type(warp) ~= "table" or type(warp.map) ~= "string" or not tonumber(warp.x) or not tonumber(warp.y) then + return false, "The saved Frontier entrance is incomplete. The challenge was not saved." + end + if type(sess.savedPlayerParty) ~= "table" then + return false, "The original party is unavailable. The challenge was not saved." + end local party = {} for i, mon in ipairs(sess.party or {}) do party[i] = deepCopy(mon) end story().loadPlayerParty(sess) + sess.continueGameWarp = deepCopy(sess.dynamicWarp) + sess.specialSaveWarpFlags = bit.bor(tonumber(sess.specialSaveWarpFlags) or 0, 1) local f = Util.frontier(sess) + local savedGame = f.savedGame f.savedGame = 1 - local ok = Util.persist() + local ok, written = pcall(Util.persist) + sess.specialSaveWarpFlags = bit.band(tonumber(sess.specialSaveWarpFlags) or 0, bit.bnot(1)) sess.party = party - return ok + if not ok or written ~= true then + f.savedGame = savedGame + return false, "The challenge could not be saved." + end + return true +end + +function Util.saveChallenge(sess, tempName, prepare) + local f = Util.frontier(sess) + local before = deepCopy(f) + local id = assert(Rse.varId(tempName, sess)) + local store = Rse.store() + local vars = store and store.vars + local temp = vars and vars[id] + prepare() + local ok, err = Util.saveGameFrontier(sess) + if not ok then + for k in pairs(f) do f[k] = nil end + for k, v in pairs(before) do f[k] = v end + if vars then vars[id] = temp end + end + return ok, err +end + +function Util.saveFromNative(ctx, adapters, sess, save) + local ok, err = save(ctx, sess) + if ok then return false end + local done = false + if adapters and adapters.closeMessage then adapters.closeMessage() end + ctx.messageOpen = true + ctx.mode, ctx.status = "native", "waiting" + ctx.nativePoll = function() + if not done then return false end + if adapters and adapters.closeMessage then adapters.closeMessage() end + ctx.messageOpen = false + ctx.pc, ctx.stack = nil, {} + return true + end + if adapters and adapters.openMessageAsync then + adapters.openMessageAsync(err, function() done = true end) + else + if adapters and adapters.openMessage then adapters.openMessage(err) end + if adapters and adapters.waitButton then adapters.waitButton(function() done = true end) + else done = true end + end + return true end -- pokeemerald/src/battle_tower.c:1956 diff --git a/src/core/game3/scripting/natives_arena.lua b/src/core/game3/scripting/natives_arena.lua index 206ba858..a039355b 100644 --- a/src/core/game3/scripting/natives_arena.lua +++ b/src/core/game3/scripting/natives_arena.lua @@ -16,7 +16,7 @@ function NativesArena.call(ctx, adapters) if id == F.INIT then A.init(s) elseif id == F.GET_DATA then A.getData(ctx, s) elseif id == F.SET_DATA then A.setData(ctx, s) - elseif id == F.SAVE then A.save(ctx, s) + elseif id == F.SAVE then return Util.saveFromNative(ctx, adapters, s, A.save) elseif id == F.SET_PRIZE then A.setPrize(s) elseif id == F.GIVE_PRIZE then A.givePrize(ctx, adapters, s) elseif id == F.GET_TRAINER_NAME then A.bufferOpponentName(ctx, adapters, s) diff --git a/src/core/game3/scripting/natives_dome.lua b/src/core/game3/scripting/natives_dome.lua index 3701d22e..ff26fbf0 100644 --- a/src/core/game3/scripting/natives_dome.lua +++ b/src/core/game3/scripting/natives_dome.lua @@ -19,7 +19,9 @@ function NativesDome.call(ctx, adapters) local id = Rse.specialVar(ctx, Util.VAR_0x8004) if not s then return false end local F = Dome().FUNC - if id == F.SHOW_OPPONENT_INFO then + if id == F.SAVE then + return Util.saveFromNative(ctx, adapters, s, Dome().save) + elseif id == F.SHOW_OPPONENT_INFO then -- pokeemerald/src/battle_dome.c:3043 local D = Dome() local tid = D.tournamentId(s, D.playerOpponentTrainerId(s)) diff --git a/src/core/game3/scripting/natives_factory.lua b/src/core/game3/scripting/natives_factory.lua index 56b3e95b..f2b9c621 100644 --- a/src/core/game3/scripting/natives_factory.lua +++ b/src/core/game3/scripting/natives_factory.lua @@ -11,7 +11,7 @@ local FUNCS = {} FUNCS[FN.INIT] = function(_, _, s) Factory.init(s) end FUNCS[FN.GET_DATA] = function(ctx, _, s) Factory.getData(ctx, s) end FUNCS[FN.SET_DATA] = function(ctx, _, s) Factory.setData(ctx, s) end -FUNCS[FN.SAVE] = function(ctx, _, s) Factory.save(ctx, s) end +FUNCS[FN.SAVE] = function(ctx, adapters, s) return Util.saveFromNative(ctx, adapters, s, Factory.save) end FUNCS[FN.NULL] = function() end FUNCS[FN.NULL2] = function() end FUNCS[FN.SELECT_RENT_MONS] = function(ctx, adapters, s) return Factory.selectScreen(ctx, adapters, s) end diff --git a/src/core/game3/scripting/natives_palace.lua b/src/core/game3/scripting/natives_palace.lua index 55b4647d..21ba0637 100644 --- a/src/core/game3/scripting/natives_palace.lua +++ b/src/core/game3/scripting/natives_palace.lua @@ -20,7 +20,7 @@ function NativesPalace.call(ctx, adapters) elseif id == F.SET_OPPONENT then P.setOpponent(s) elseif id == F.GET_OPPONENT_INTRO then P.opponentIntro(ctx, adapters, s) elseif id == F.INCREMENT_STREAK then P.incrementStreak(s) - elseif id == F.SAVE then P.save(ctx, s) + elseif id == F.SAVE then return Util.saveFromNative(ctx, adapters, s, P.save) elseif id == F.SET_PRIZE then P.setPrize(s) elseif id == F.GIVE_PRIZE then P.givePrize(ctx, adapters, s) else diff --git a/src/core/game3/scripting/natives_tents.lua b/src/core/game3/scripting/natives_tents.lua index edea1449..97a3422f 100644 --- a/src/core/game3/scripting/natives_tents.lua +++ b/src/core/game3/scripting/natives_tents.lua @@ -30,14 +30,14 @@ local function build() VERDANTURF[V.SET_OPPONENT_GFX] = function(_, _, s) Tents().setVerdanturfGfx(s) end -- pokeemerald/src/battle_tent.c:134 VERDANTURF[V.GET_OPPONENT_INTRO] = function(ctx, adapters, s) Tents().opponentIntro(ctx, adapters, s, F.PALACE) end - VERDANTURF[V.SAVE] = function(ctx, _, s) Tents().save(ctx, s) end + VERDANTURF[V.SAVE] = function(ctx, adapters, s) return Util.saveFromNative(ctx, adapters, s, Tents().save) end VERDANTURF[V.SET_RANDOM_PRIZE] = function(_, _, s) Tents().setRandomPrize(s, "verdanturf") end VERDANTURF[V.GIVE_PRIZE] = function(ctx, adapters, s) Tents().givePrize(ctx, adapters, s, "verdanturf") end -- pokeemerald/src/battle_tent.c:172 FALLARBOR[Fa.INIT] = function(_, _, s) Tents().init(s) end FALLARBOR[Fa.GET_PRIZE] = function(ctx, _, s) Tents().getPrize(ctx, s, "fallarbor") end FALLARBOR[Fa.SET_PRIZE] = function(ctx, _, s) Tents().setPrize(ctx, s, "fallarbor") end - FALLARBOR[Fa.SAVE] = function(ctx, _, s) Tents().save(ctx, s) end + FALLARBOR[Fa.SAVE] = function(ctx, adapters, s) return Util.saveFromNative(ctx, adapters, s, Tents().save) end FALLARBOR[Fa.SET_RANDOM_PRIZE] = function(_, _, s) Tents().setRandomPrize(s, "fallarbor") end FALLARBOR[Fa.GIVE_PRIZE] = function(ctx, adapters, s) Tents().givePrize(ctx, adapters, s, "fallarbor") end -- pokeemerald/src/battle_tent.c:203 @@ -46,7 +46,7 @@ local function build() SLATEPORT[S.INIT] = function(_, _, s) Tents().init(s) end SLATEPORT[S.GET_PRIZE] = function(ctx, _, s) Tents().getPrize(ctx, s, "slateport") end SLATEPORT[S.SET_PRIZE] = function(ctx, _, s) Tents().setPrize(ctx, s, "slateport") end - SLATEPORT[S.SAVE] = function(ctx, _, s) Tents().save(ctx, s) end + SLATEPORT[S.SAVE] = function(ctx, adapters, s) return Util.saveFromNative(ctx, adapters, s, Tents().save) end SLATEPORT[S.SET_RANDOM_PRIZE] = function(_, _, s) Tents().setRandomPrize(s, "slateport") end SLATEPORT[S.GIVE_PRIZE] = function(ctx, adapters, s) Tents().givePrize(ctx, adapters, s, "slateport") end -- pokeemerald/src/battle_tent.c:272 diff --git a/src/core/game3/scripting/natives_tower_rse.lua b/src/core/game3/scripting/natives_tower_rse.lua index 47b00e28..50aaee06 100644 --- a/src/core/game3/scripting/natives_tower_rse.lua +++ b/src/core/game3/scripting/natives_tower_rse.lua @@ -25,7 +25,7 @@ local function build() -- pokeemerald/src/battle_tower.c:2769 FUNCS[F.GIVE_RIBBONS] = function(ctx, _, s) Tower().giveRibbons(ctx, s) end -- pokeemerald/src/battle_tower.c:2194 - FUNCS[F.SAVE] = function(ctx, _, s) Tower().save(ctx, s) end + FUNCS[F.SAVE] = function(ctx, adapters, s) return Util.saveFromNative(ctx, adapters, s, Tower().save) end -- pokeemerald/src/battle_tower.c:1936 FUNCS[F.GET_OPPONENT_INTRO] = function(ctx, adapters, s) Tower().opponentIntro(ctx, adapters, s) end -- pokeemerald/src/battle_tower.c:2209 @@ -57,8 +57,7 @@ function NativesTowerRse.call(ctx, adapters) Rse.missing("tower", "CallBattleTowerFunc " .. tostring(id), adapters and adapters.log) return false end - fn(ctx, adapters, s) - return false + return fn(ctx, adapters, s) == true end NativesTowerRse.FUNCS = FUNCS diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 934ab642..5b77a04c 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -699,6 +699,11 @@ do frlg[#frlg + 1] = string.format("%s%s_%d_female.png", hoenn, side, stars) end end + -- pokefirered/src/battle_script_commands.c:9693 + local terrainKeys = require("src.import.gba.battle_chrome_extract").TERRAIN_KEYS + for id = 0, 19 do + frlg[#frlg + 1] = "data/generated/gba/pokemon/battle/terrain_" .. terrainKeys[id] .. "_post_dex.rgba" + end end CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.leafgreen = {} for i, path in ipairs(CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.firered) do @@ -760,6 +765,16 @@ function CacheContract.planFilesFor(version) end for _, path in ipairs(CacheContract.PLAN_CORE_FILES) do add(path) end for _, path in ipairs(Plans.required(Plans.of(version), CachePaths.CACHE_ROOT)) do add(path) end + if version == "emerald" then + local cfg = require("src.import.gba.games.emerald").BATTLE_UI + local keys = require("src.import.gba.battle_chrome_extract").TERRAIN_KEYS + -- pokeemerald/src/battle_script_commands.c:10133 + local function postDex(key) + add(CachePaths.CACHE_ROOT .. "/pokemon/battle/terrain_" .. key .. "_post_dex.rgba") + end + for id = 0, cfg.terrain_count - 1 do postDex(keys[id]) end + for _, scene in ipairs(cfg.scenes) do postDex(scene.key) end + end composed[version] = files return files end diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index b71b566f..16a6673f 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -543,6 +543,10 @@ end local function maxWorkers(taskCount) local override = tonumber(os.getenv("POKEPORT_EXTRACT_WORKERS") or "") if override and override >= 1 then return math.min(taskCount, math.floor(override)) end + if os.getenv("HANDHELD") == "1" or os.getenv("POKEPORT_HANDHELD") == "1" + or os.getenv("PORTMASTER") == "1" then + return math.min(taskCount, 1) + end local osName = love.system and love.system.getOS and love.system.getOS() or "" if osName == "iOS" or osName == "Android" or osName == "NX" then return math.min(taskCount, 2) diff --git a/src/import/gba/battle_chrome_extract.lua b/src/import/gba/battle_chrome_extract.lua index 22c3fbf5..5e600f20 100644 --- a/src/import/gba/battle_chrome_extract.lua +++ b/src/import/gba/battle_chrome_extract.lua @@ -471,13 +471,23 @@ local function bake_terrain(get, cache, root, key, tr) local tGfx = Lz77.decompress(get, tr.tiles) local tPal = Lz77.decompress(get, tr.pal) local tMap = Lz77.decompress(get, tr.tilemap) + if byte_len(tMap) < 4096 then + error("battle_chrome_extract: post-dex terrain map missing right screen for " .. key) + end local rgba, trW, trH = bake_tilemap_rgba(tGfx, tPal, tMap, 32, 32, { transparent0 = false, bgPalBase = 2 }) cache:write(root .. "/terrain_" .. key .. ".rgba", rgba) local bgRgba, enemyRgba, playerRgba = split_terrain_layers(rgba, tMap) cache:write(root .. "/terrain_bg_" .. key .. ".rgba", bgRgba) cache:write(root .. "/terrain_enemy_" .. key .. ".rgba", enemyRgba) cache:write(root .. "/terrain_player_" .. key .. ".rgba", playerRgba) - return { w = trW, h = trH } + -- pokeemerald/src/battle_script_commands.c:10133 + -- pokefirered/src/battle_script_commands.c:9693 + local postMap, bytes = {}, bytes_to_array(tMap) + for i = 1, 2048 do postMap[i] = bytes[2048 + i] end + local postRgba = bake_tilemap_rgba(tGfx, tPal, postMap, 32, 32, { transparent0 = false, bgPalBase = 2 }) + local postDexFile = "terrain_" .. key .. "_post_dex.rgba" + cache:write(root .. "/" .. postDexFile, postRgba) + return { w = trW, h = trH, postDexFile = postDexFile } end -- pokeemerald/src/battle_bg.c:859 @@ -522,7 +532,7 @@ function BattleChromeExtract.runRse(rom, cache, opts, cfg) for _, key in ipairs(terrainOrder) do local m = terrainMeta[key] terrainLines[#terrainLines + 1] = string.format( - ' %s = { file = "terrain_%s.rgba", w = %d, h = %d },', key, key, m.w, m.h) + ' %s = { file = "terrain_%s.rgba", postDexFile = %q, w = %d, h = %d },', key, key, m.postDexFile, m.w, m.h) end for _, sc in ipairs(cfg.scenes or {}) do sceneKeys[#sceneKeys + 1] = string.format("%q", sc.key) end local envKeys = {} @@ -632,19 +642,7 @@ function BattleChromeExtract.run(rom, cache, opts) for _, t in ipairs(terrains) do local tr = t.cfg if tr then - local tGfx = Lz77.decompress(get, tr.tiles) - local tPal = Lz77.decompress(get, tr.pal) - local tMap = Lz77.decompress(get, tr.tilemap) - local rgba, trW, trH = bake_tilemap_rgba(tGfx, tPal, tMap, 32, 32, { - transparent0 = false, - bgPalBase = 2, - }) - cache:write(root .. "/terrain_" .. t.key .. ".rgba", rgba) - local bgRgba, enemyRgba, playerRgba = split_terrain_layers(rgba, tMap) - cache:write(root .. "/terrain_bg_" .. t.key .. ".rgba", bgRgba) - cache:write(root .. "/terrain_enemy_" .. t.key .. ".rgba", enemyRgba) - cache:write(root .. "/terrain_player_" .. t.key .. ".rgba", playerRgba) - terrainMeta[t.key] = { w = trW, h = trH } + terrainMeta[t.key] = bake_terrain(get, cache, root, t.key, tr) terrainOrder[#terrainOrder + 1] = t.key end end @@ -654,7 +652,7 @@ function BattleChromeExtract.run(rom, cache, opts) for _, key in ipairs(terrainOrder) do local m = terrainMeta[key] terrainLines[#terrainLines + 1] = string.format( - ' %s = { file = "terrain_%s.rgba", w = %d, h = %d },', key, key, m.w, m.h) + ' %s = { file = "terrain_%s.rgba", postDexFile = %q, w = %d, h = %d },', key, key, m.postDexFile, m.w, m.h) end -- Party summary bar (128×8); balls use elements tiles 66..69. diff --git a/src/import/gba/games/emerald.lua b/src/import/gba/games/emerald.lua index 07da0c1e..ba272fc9 100644 --- a/src/import/gba/games/emerald.lua +++ b/src/import/gba/games/emerald.lua @@ -3,7 +3,7 @@ local VersionsRse = require("src.import.gba.versions_rse") local V = VersionsRse.new("emerald") local sym, count = V.sym, V.count -V.CACHE_VERSION = 13 +V.CACHE_VERSION = 14 V.NATIVE_VERSION = 2 V.OW_VERSION = 1 V.ANIM_VERSION = 1 diff --git a/src/import/gba/versions_frlg.lua b/src/import/gba/versions_frlg.lua index 8759c294..8ef8535a 100644 --- a/src/import/gba/versions_frlg.lua +++ b/src/import/gba/versions_frlg.lua @@ -43,7 +43,7 @@ Versions.ROM_SIZE = 16777216 -- v115: LeafGreen profiles, edition-specific title assets and Deoxys stats. -- v121: chrome/fonts/japanese_{normal,small}_* and japanese_widths.lua, the -- cart's Japanese fonts, for text a Japanese translation mod prints. -Versions.CACHE_VERSION = 127 +Versions.CACHE_VERSION = 128 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 3 Versions.ANIM_VERSION = 1 diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 83d4c1a0..786120f4 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -174,11 +174,10 @@ function TextBox.new(game, text, onDone, opts) local marks text, marks = stripPauses(text) local save = game and game.save - local gen1 = doubleLine - and not (save and (save.generation == 2 or save.generation == 3 + self.fixedGen1Rows = not (save and (save.generation == 2 or save.generation == 3 or save.version == "gold")) and require("src.core.GameVersion").generation() == 1 - self.pages = TextBox.paginate(text, self.maxCols, gen1) + self.pages = TextBox.paginate(text, self.maxCols, doubleLine and self.fixedGen1Rows) -- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is -- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213) self.pauseSounds = opts and opts.pauseSounds @@ -190,6 +189,7 @@ function TextBox.new(game, text, onDone, opts) self.lineIndex = 1 self.charIndex = 0 self.shown = {} -- visible lines (max 2), each a list of glyph codes + self.shownSource = {} self.waiting = false self.contAdvance = false self.done = false @@ -204,9 +204,12 @@ function TextBox.new(game, text, onDone, opts) -- twice, then the next line is written at TEXTBOX_INNERY + 2, i.e. the -- bottom row. Taking the first two would walk the text backwards the -- instant the prompt appears. - for index = math.max(1, #page - 1), #page do - self.shown[#self.shown + 1] = Font.encode(page[index]) + for index = 1, #page do + self.lineIndex = index + self:beginLine() + self.shown[#self.shown] = self.codes end + self.scrollPx = nil self.lineIndex = #page self.codes = self.shown[#self.shown] or {} self.charIndex = #self.codes @@ -381,19 +384,27 @@ end function TextBox:beginLine() self.charIndex = 0 self.codes = Font.encode(self:currentLine()) + local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex] + -- pokered/home/text.asm:262 + if self.fixedGen1Rows and conts and conts[self.lineIndex] and #self.shown == 1 then + table.insert(self.shown, {}) + table.insert(self.shownSource, false) + end if #self.shown >= 2 then table.remove(self.shown, 1) + table.remove(self.shownSource, 1) self.scrollPx = 8 -- pixel scroll-up (ScrollTextUpOneLine) end table.insert(self.shown, {}) + table.insert(self.shownSource, self.lineIndex) end function TextBox:visibleText() local page = self.pages[self.pageIndex] if not page then return nil end - local out, count = {}, #(self.shown or {}) - for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do - if page[i] ~= nil then out[#out + 1] = page[i] end + local out = {} + for _, source in ipairs(self.shownSource) do + out[#out + 1] = source and page[source] or "" end return #out > 0 and out or nil end @@ -636,6 +647,7 @@ function step(self, dt) self.holdFrames = Timing.TEXT_SCROLL_PAIR else self.shown = {} + self.shownSource = {} self.pageIndex = self.pageIndex + 1 self.lineIndex = 1 self:beginLine() diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index 14e3b719..62db1443 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -130,6 +130,7 @@ function BattleChrome.terrain(key) bgImage = rgba_to_image(read_bytes(root .. "/terrain_bg_" .. key .. ".rgba"), 256, 160), enemyPlat = rgba_to_image(read_bytes(root .. "/terrain_enemy_" .. key .. ".rgba"), 256, 160), playerPlat = rgba_to_image(read_bytes(root .. "/terrain_player_" .. key .. ".rgba"), 256, 160), + postDexImage = info.postDexFile and rgba_to_image(read_bytes(root .. "/" .. info.postDexFile), 256, 256) or nil, w = w, h = h, } @@ -137,6 +138,24 @@ function BattleChrome.terrain(key) return entry end +-- pokeemerald/src/battle_script_commands.c:10131 +-- pokefirered/src/battle_script_commands.c:9691 +function BattleChrome.drawPostDexBg(key) + local entry = BattleChrome.terrain(key) + if not (entry and entry.postDexImage) then + error("battle_chrome: missing ROM post-dex background for " .. tostring(key)) + end + local qKey = "post_dex_" .. tostring(key) + local q = BattleChrome._quads[qKey] + if not q then + q = love.graphics.newQuad(0, 0, 240, 112, 256, 256) + BattleChrome._quads[qKey] = q + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(entry.postDexImage, q, 0, 0) + return true +end + local TERRAIN_MT = { __index = function(_, key) return BattleChrome.terrain(key) end, } diff --git a/src/ui/game3/rse/pokedex.lua b/src/ui/game3/rse/pokedex.lua index ca20c8ec..ab1ee5d0 100644 --- a/src/ui/game3/rse/pokedex.lua +++ b/src/ui/game3/rse/pokedex.lua @@ -2004,9 +2004,15 @@ function tasks.caughtExit(s) if not s.pal:fadeActive() then local c = s.caught local Stack = require("src.ui.game3.stack") + -- pokeemerald/src/pokedex.c:4069 + local species = Pokedex.speciesOf(c.dexNum) + local pic = pokemon().frontPic(pokemon().picSpecies(species, c.personality or 0), nil, c.shiny, + c.personality or 0) + c.mon.img = assert(pic and pic.image, "caught mon palette missing from the cache") Pokedex.Host._s = nil Stack.pop(Pokedex.ID) - if c.onDone then c.onDone() end + if c.onDone then c.onDone({ sprite = c.mon, species = species, personality = c.personality, + otId = c.otId, otSecretId = c.otSecretId, shiny = c.shiny }) end end end @@ -2384,7 +2390,8 @@ function Pokedex.showCaughtMon(species, opts) opts = opts or {} local s = newView(opts) local nat = pokemon().national(species) or species - s.caught = { dexNum = nat, personality = opts.personality, onDone = opts.onDone } + s.caught = { dexNum = nat, personality = opts.personality, otId = opts.otId, otSecretId = opts.otSecretId, + shiny = opts.shiny, onDone = opts.onDone } s.fn = "caught" s.state = 0 return push(s) diff --git a/tests/drivers/em_bike_doors_2637.lua b/tests/drivers/em_bike_doors_2637.lua new file mode 100644 index 00000000..36490d29 --- /dev/null +++ b/tests/drivers/em_bike_doors_2637.lua @@ -0,0 +1,75 @@ +local U = require("tests.drivers.util") +local F = require("tests.drivers.em_fa_util") +local S = F.new("em_bike_doors_2637", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_bike_doors_2637") + +return function(game) + if not F.boot(game, S) then return S.finish() end + local Runtime = require("src.core.game3.runtime") + local Player = require("src.core.game3.player") + local Bike = require("src.core.game3.bike") + local Warp = require("src.core.game3.warp") + local Collision = require("src.core.game3.collision") + local session = Runtime.getSession() + if not S.check(session.version == "emerald", "driver runs Emerald") then return S.finish() end + local function place(kind, x, y, facing) + if Player.biking then Bike.rse(session).getOnOff(kind, session) end + S.check(F.goTo(game, "EM_MAUVILLE_CITY", x, y, facing), "Mauville approach loaded") + local item = F.give(kind == "mach" and "ITEM_MACH_BIKE" or "ITEM_ACRO_BIKE") + F.useRegistered(game, item) + S.check(Player.biking and Player.bikeType == kind, kind .. " mounted through registered item") + end + local function rideUntilLastFrame(dir, tx, ty) + local ready = false + F.holdKeys(game, { dir }, 180, function() + ready = Player.moving and Player.targetX == tx and Player.targetY == ty + and Player.progress >= Player.stepFrames - 1 + return ready + end) + return S.check(ready, "approach last frame " .. tx .. "," .. ty) + end + local function enterAndExit(kind, x, tag) + local door = Collision.isDoorWarp(game, x, 5) + if not S.check(door ~= nil, tag .. " has real animated door warp") then return false end + F.holdKeys(game, { "up" }, 90, function() return F.map() ~= "EM_MAUVILLE_CITY" end) + F.settle(game, 500) + if not S.check(F.map() == door.destMap, tag .. " actual door enters destination") then return false end + S.check(not Player.biking, tag .. " indoors dismounts " .. kind) + S.check(S.still(game, tag .. "_inside.png"), tag .. " stable indoor screenshot") + local def = game.data.maps[F.map()] + local exit + for _, w in ipairs(def.warps or {}) do + if w.destMap == "EM_MAUVILLE_CITY" then exit = w break end + end + if not S.check(exit ~= nil, tag .. " has outdoor exit mat") then return false end + F.holdKeys(game, { "down" }, 140, function() return F.map() == "EM_MAUVILLE_CITY" end) + F.settle(game, 500) + S.check(F.map() == "EM_MAUVILLE_CITY", tag .. " actual exit returns outdoors") + return true + end + for _, kind in ipairs({ "mach", "acro" }) do + for _, x in ipairs({ 22, 35 }) do + local tag = kind .. "_" .. (x == 22 and "center" or "shop") + place(kind, x - 1, 6, "right") + if rideUntilLastFrame("right", x, 6) then + F.holdKeys(game, { "up" }, 1) + S.check(Player.cellX == x and Player.cellY == 6 and not Player.moving and not Warp.isBusy(), + tag .. " changed heading blocks unclaimed door step") + S.note(tag .. " facing=" .. Player.facing .. " running=" .. Bike.rse(session).state.running) + U.wait(20) + S.check(S.still(game, tag .. "_approach.png"), tag .. " stable approach screenshot") + enterAndExit(kind, x, tag) + end + place(kind, x, 6, "up") + enterAndExit(kind, x, tag .. "_straight") + end + end + place("mach", 22, 8, "up") + if rideUntilLastFrame("up", 22, 6) then + U.wait(50) + S.check(Player.cellY == 6 and not Player.moving and F.map() == "EM_MAUVILLE_CITY" and not Warp.isBusy(), + "Mach coast stops south of Center without opening door") + S.check(S.still(game, "mach_coast_stopped.png"), "Mach coast stable screenshot") + enterAndExit("mach", 22, "mach_coast") + end + S.finish() +end diff --git a/tests/drivers/em_catch_prompt_2637.lua b/tests/drivers/em_catch_prompt_2637.lua new file mode 100644 index 00000000..d26437c5 --- /dev/null +++ b/tests/drivers/em_catch_prompt_2637.lua @@ -0,0 +1,174 @@ +local U = require("tests.drivers.util") +local F = require("tests.drivers.em_fa_util") +local S = F.new("em_catch_prompt_2637", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_catch_prompt_2637") + +return function(game) + if not F.boot(game, S) then return S.finish() end + local Runtime = require("src.core.game3.runtime") + local Battle = require("src.core.game3.battle") + local Bridge = require("src.core.game3.battle_bridge") + local Ui = require("src.core.game3.battle.ui") + local Party = require("src.core.game3.party") + local Pokemon = require("src.core.game3.pokemon") + local Bag = require("src.core.game3.bag") + local BagMenu = require("src.ui.game3.bag_menu") + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + local Naming = require("src.ui.game3.naming") + local RseDex = require("src.ui.game3.rse.pokedex") + local Dex = require("src.core.game3.dex") + local Storage = require("src.core.game3.storage") + local session = Runtime.getSession() + local version = os.getenv("POKEPORT_TEST_VERSION") or "emerald" + if not S.check(session.version == version, "capture driver version " .. version) then return S.finish() end + local C = require("src.core.game3.constants").of(version) + local function species(name) return C:require("species", "SPECIES_" .. name) end + local starter = species(version == "emerald" and "TREECKO" or "CHARMANDER") + local target = species(version == "emerald" and "WURMPLE" or "MAGIKARP") + local pcTarget = species(version == "emerald" and "POOCHYENA" or "PIDGEY") + local master = C:require("items", "ITEM_MASTER_BALL") + session.party, session.dex = {}, Dex.new() + Party.giveMon(session, starter, 12) + if version == "emerald" then + F.setVar("VAR_LITTLEROOT_INTRO_STATE", 7) + F.setVar("VAR_LITTLEROOT_TOWN_STATE", 4) + F.setVar("VAR_ROUTE101_STATE", 3) + F.setFlag("FLAG_RESCUED_BIRCH", true) + end + S.check(F.goTo(game, version == "emerald" and "EM_ROUTE101" or "FR_ROUTE1", 10, 10, "down"), + "capture approach map loaded") + + local function throw() + Bag.add(session.bag, master, 1) + U.tap(game, "right") + U.wait(12) + U.tap(game, "a") + U.wait(60) + for _ = 1, 6 do + if BagMenu.currentPocket() == "POKE_BALLS" then break end + U.tap(game, "right") + U.wait(32) + end + if not S.check(BagMenu.currentPocket() == "POKE_BALLS", "capture bag reached Poke Balls") then return false end + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + U.wait(20) + return true + end + local function capture(sp, first, yes, pc, tag) + local partyBefore = #session.party + if not S.check(Bridge.startWild(Runtime._mod, game, { species = sp, level = 3 }, { fade = false }) == true, + tag .. " wild encounter started") then return false end + local thrown, dexSeen, promptSeen, typed, pcMessageSeen = false, false, false, false, false + local name, snapshot, image + for frame = 1, 12000 do + if not Battle.isActive() then break end + local phase = Battle._phase + if phase == "command" and Ui._mode == "menu" and not thrown then + thrown = true + if not throw() then return false end + elseif phase == "pokedex_reg" then + dexSeen = true + local ds = RseDex.active() + if version == "emerald" and ds and ds.fn == "caughtInput" then + snapshot, image = ds.caught.mon, ds.caught.mon.img + S.check(S.still(game, tag .. "_dex.png"), tag .. " stable caught dex screenshot") + U.tap(game, "a") + elseif version ~= "emerald" and frame % 18 == 0 then + U.tap(game, "a") + else U.wait(1) end + elseif phase == "catch_dex_return" then + U.wait(1) + elseif phase == "catch_nickname_prompt" and Choice.isOpen() then + if not promptSeen then + promptSeen = true + local c = Ui._caughtDexScene + if first then + if version == "emerald" then + S.check(c and c.sprite == snapshot, tag .. " prompt retains same dex sprite") + end + local cy = version == "emerald" and 80 or 64 + S.check(c and c.sprite.x == 120 and c.sprite.y == cy, tag .. " prompt source center120," .. cy) + local battleMon = Battle._st.enemy.mon + local expected = Pokemon.frontPic(Pokemon.picSpecies(sp, battleMon.personality), nil, + Pokemon.isShiny(battleMon), battleMon.personality) + S.check(c and c.sprite.img == expected.image, tag .. " prompt uses restored caught palette") + else + S.check(c == nil, tag .. " existing capture branch presentation preserved") + end + S.check(S.still(game, tag .. "_question.png"), tag .. " stable nickname question screenshot") + U.tap(game, yes and "a" or "b") + U.wait(24) + else U.wait(1) end + elseif Naming.isOpen() then + local ns = Naming._state + if ns and ns.pcPages then + if Message.isTyping() then U.wait(1) else + U.tap(game, "a") + U.wait(20) + end + elseif not typed then + typed = true + U.wait(24) + for _ = 1, 3 do U.tap(game, "a"); U.wait(12) end + name = Naming._state and Naming._state.name + S.check(name == "AAA", tag .. " three input taps type exactly AAA") + S.check(Ui._caughtDexScene == nil, tag .. " fullscreen naming releases caught scene") + S.check(S.still(game, tag .. "_naming.png"), tag .. " stable keyboard screenshot") + U.tap(game, "start") + U.wait(20) + U.tap(game, "a") + U.wait(20) + else U.wait(1) end + elseif phase == "catch_pc_msg" and not pcMessageSeen then + pcMessageSeen = true + S.check(Ui._caughtDexScene ~= nil == first, tag .. " PC No preserves first-capture scene") + if first then + local c = Ui._caughtDexScene + S.check(c and c.sprite.x == 120 and c.sprite.y == (version == "emerald" and 80 or 64), + tag .. " PC transfer retains source center") + end + for _ = 1, 180 do if not Message.isTyping() then break end; U.wait(1) end + S.check(S.still(game, tag .. "_pc_message.png"), tag .. " stable PC transfer screenshot") + elseif Message.isOpen() and not Message.isTyping() and frame % 18 == 0 then + U.tap(game, "a") + else U.wait(1) end + end + S.check(not Battle.isActive(), tag .. " capture completed") + S.check(dexSeen == first, tag .. " dex registration branch correct") + S.check(promptSeen, tag .. " nickname question reached") + S.check(typed == yes, tag .. " naming Yes No route correct") + S.check(pcMessageSeen == (pc and not yes), tag .. " PC transfer message route correct") + S.check(Ui._caughtDexScene == nil and not Naming.isOpen(), tag .. " capture presentation cleared") + local mon + if pc then + local storage = Storage.ensure(session) + for _, box in ipairs(storage.boxes or {}) do + for _, candidate in pairs(box.mons or {}) do + if tonumber(candidate.species or candidate.speciesId) == sp and (not yes or candidate.nickname == name) then + mon = candidate + end + end + end + else + mon = session.party[partyBefore + 1] + end + S.check(mon and tonumber(mon.species or mon.speciesId) == sp, tag .. " correct mon reached " .. (pc and "PC" or "party")) + if yes then S.check(mon and mon.nickname == "AAA", tag .. " destination retains AAA nickname") end + U.wait(40) + return not Battle.isActive() + end + if not capture(target, true, true, false, version .. "_new_party_yes") then return S.finish() end + if not capture(target, false, true, false, version .. "_repeat_party_yes") then return S.finish() end + session.dex = Dex.new() + if not capture(target, true, false, false, version .. "_new_party_no") then return S.finish() end + if not capture(target, false, false, false, version .. "_repeat_party_no") then return S.finish() end + while #session.party < 6 do Party.giveMon(session, starter, 12) end + if not capture(pcTarget, true, true, true, version .. "_new_pc_yes") then return S.finish() end + if not capture(pcTarget, false, true, true, version .. "_repeat_pc_yes") then return S.finish() end + session.dex = Dex.new() + if not capture(pcTarget, true, false, true, version .. "_new_pc_no") then return S.finish() end + capture(pcTarget, false, false, true, version .. "_repeat_pc_no") + S.finish() +end diff --git a/tests/drivers/em_tent_reload_2638.lua b/tests/drivers/em_tent_reload_2638.lua new file mode 100644 index 00000000..62c0c903 --- /dev/null +++ b/tests/drivers/em_tent_reload_2638.lua @@ -0,0 +1,385 @@ +local U = require("tests.drivers.util") +local S = require("tests.drivers.em_story_util") + +return function(game) + local phase = os.getenv("EM_TENT_2638_PHASE") or "rest_setup" + local route, step = phase:match("^(%a+)_(%a+)$") + local d = S.new("em_tent_reload_2638_" .. phase, "/tmp/em_tent_reload_2638/" .. phase) + local check = d.check + local deadline = love.timer.getTime() + 25 + if not check((route == "rest" or route == "direct") and (step == "setup" or step == "resume"), "valid driver phase") then + return d.finish() + end + local identity = os.getenv("POKEPORT_IDENTITY") or "" + if not check(identity ~= "" and identity ~= "pokemon-love2d", "dedicated identity supplied") then return d.finish() end + local SaveData = require("src.core.SaveData") + local Schema = require("src.core.game3.save_schema_firered") + local Runtime = require("src.core.game3.runtime") + local D = require("src.core.game3.rse.frontier.trainers") + local Util = require("src.core.game3.rse.frontier.util") + local Select = require("src.ui.game3.rse.factory_select") + local SaveMenu = require("src.ui.game3.save_menu") + local Choice = require("src.ui.game3.choice") + local Message = require("src.ui.game3.message") + local LOBBY = "EM_SLATEPORT_CITY_BATTLE_TENT_LOBBY" + local CORRIDOR = "EM_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR" + local witnessFile = "2638_" .. route .. "_expected.lua" + local saveFile = SaveData.saveFilename("emerald") + local function readSave() + local raw = love.filesystem.read(saveFile) + return type(raw) == "string" and SaveData.decode(raw) or nil + end + local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out + end + local function traits(party) + local out = {} + local keys = { "species", "speciesId", "speciesNumbering", "personality", "otId", "otSecretId", "otName", "ot", + "nickname", "ivs", "evs", "moves", "pp", "maxPp", "ppBonusesPacked", "nature", "heldItem", "abilityNum", + "exp", "level", "friendship", "pokeball", "metLocation", "markings" } + for i, mon in ipairs(party or {}) do + out[i] = {} + for _, key in ipairs(keys) do out[i][key] = copy(mon[key]) end + end + return out + end + local function fingerprint(party) + local bytes = SaveData.encode(traits(party)) + return love.data.encode("string", "hex", love.data.hash("sha256", bytes)) + end + local function sameParty(got, expected, label) + local a, b = fingerprint(got), fingerprint(expected) + return check(a == b and #(got or {}) == #(expected or {}), label .. " slots=" .. #(got or {}) .. " sha256=" .. a .. " expected=" .. b) + end + local function waitFor(pred, limit) + for _ = 1, limit or 900 do + if pred() then return true end + U.wait(1) + end + return pred() + end + local capture = d.shot + function d.shot(game, file) + local Fade = require("src.ui.game3.fade") + local Transition = require("src.core.game3.battle_transition") + local function visible() + return game.phase == "field" and not Fade.isActive() and (Fade.t or 0) <= 0 + and not Transition.isActive() + end + if not check(waitFor(visible, 900), "capture ready " .. file) then + d.note("capture phase=" .. tostring(game.phase) .. " fadeActive=" .. tostring(Fade.isActive()) + .. " fade=" .. tostring(Fade.t) .. " transition=" .. tostring(Transition.isActive())) + return false + end + U.wait(2) + if not check(waitFor(visible, 900), "capture remains visible " .. file) then return false end + return check(capture(game, file), "capture written " .. file) + end + if not check(waitFor(function() return game.phase == "boot" and game.boot ~= nil end), "boot reached") then return d.finish() end + local witness + if step == "setup" then + local session = Schema.newGame({ version = "emerald", name = "RELOAD", rngSeed = 0x2638 }) + session.map, session.x, session.y, session.facing = LOBBY, 6, 6, "up" + require("src.core.game3.options").bind(session, game.options) + game:adoptSave(session, true) + game.sessionStartedAt = os.time() + game:_enterField(session, "continue") + U.wait(30) + S.settle(game) + session = Runtime.getSession() + if not check(session and session.version == "emerald", "Emerald setup session") then return d.finish() end + session.party = {} + local names = { "SPECIES_SWAMPERT", "SPECIES_BLAZIKEN", "SPECIES_SCEPTILE", "SPECIES_METAGROSS", "SPECIES_SALAMENCE", "SPECIES_ZIGZAGOON" } + for i, name in ipairs(names) do + local m = D.createMon(S.species(name), 35 + i, 10 + i, 200000 + i * 37, session.trainerId, + { otName = session.name, moves = {} }) + D.setMoves(m, { S.move("MOVE_TACKLE"), S.move("MOVE_PROTECT") }) + D.setEvs(m, { i * 7, i * 9, i * 11, i * 13, i * 15, i * 17 }) + m.nickname, m.otSecretId, m.heldItem = "KEEP" .. i, session.secretId, S.item("ITEM_ORAN_BERRY") + m.ivs.hp, m.pp[1], m.friendship, m.markings = i + 4, i + 2, 100 + i, i + require("src.core.game3.save_mon").normalize(m) + Schema.ensureMonBall(m) + session.party[i] = m + end + witness = { original = copy(session.party), route = route, seed = tonumber(os.getenv("EM_TENT_2638_SEED")) or 185 } + require("src.core.game3.encounters").onStep = function() return nil end + local Rng = require("src.core.game3.rng") + local Tents = require("src.core.game3.rse.frontier.tents") + local generateRentals = Tents.generateRentalMons + Tents.generateRentalMons = function(sess) + Rng.SeedRng(witness.seed) + d.note("real rental generator seed=" .. witness.seed) + return generateRentals(sess) + end + Rng.SeedRng(witness.seed) + local ok, err = pcall(require("src.core.game3.map").load, nil, game, LOBBY, { x = 6, y = 6, facing = "up" }) + if not check(ok, "Slateport lobby loads " .. tostring(err or "")) then return d.finish() end + U.wait(30) + S.settle(game) + d.shot(game, "01_original_party_lobby") + sameParty(Runtime.getSession().party, witness.original, "2638 original hidden-stat fingerprint") + else + local raw = love.filesystem.read(witnessFile) + witness = type(raw) == "string" and SaveData.decode(raw) or nil + if not check(witness and witness.route == route and witness.original and witness.rentals, "previous process supplied exact witness") then + return d.finish() + end + local disk = readSave() + if not check(disk and disk.frontier and disk.frontier.curChallengeBattleNum == 1, "fresh process reads one-battle challenge save") then + return d.finish() + end + sameParty(disk.savedPlayerParty, witness.original, "2638 persisted original hidden-stat fingerprint") + if route == "rest" then + sameParty(disk.party, witness.original, "2638 REST disk original fingerprint") + check(disk.frontier.challengePaused == 1, "REST challenge paused on disk") + local w = disk.continueGameWarp or {} + check(w.map == LOBBY and w.x == witness.lobby.x and w.y == witness.lobby.y, + "REST disk has exact captured lobby coordinates " .. tostring(w.x) .. "," .. tostring(w.y)) + check((tonumber(disk.specialSaveWarpFlags) or 0) % 2 == 1, "REST disk continue flag set") + else + sameParty(disk.party, witness.rentals, "2638 direct disk rental hidden-stat fingerprint") + check(disk.map == CORRIDOR, "direct save retains actual corridor location") + end + game:_handleBootAction({ action = "continue" }) + if not check(waitFor(function() return game.phase == "field" and Runtime.getSession() ~= nil end, 1800), + "fresh process CONTINUE reaches live field") then return d.finish() end + if route == "rest" then + check(S.mapNow() == LOBBY, "REST CONTINUE starts in original lobby") + d.shot(game, "04_resume_lobby") + sameParty(Runtime.getSession().party, witness.original, "2638 REST continued original fingerprint") + else + check(S.mapNow() == CORRIDOR, "direct CONTINUE starts in actual corridor") + d.shot(game, "04_direct_continue_corridor") + end + end + local session = Runtime.getSession() + local f = Util.frontier(session) + local battles, wins, selected = 0, 0, false + local stopped, savedRest, captured, resumeRentals = false, false, false, false + local aborted, lastProgress, lastStage = false, U.frame(), nil + local function timeout(label) + if aborted then return end + aborted = true + check(false, label .. " at " .. S.vmWhere()) + end + local function driveSelect() + local st = Select._st + local function ready(phase) + return st.phase == phase and (not st.picAnim or st.picAnim.finished) + end + local function rentalWait(pred, label, limit) + if waitFor(pred, limit or 600) then return true end + d.note("rental UI phase=" .. tostring(st.phase) .. " cursor=" .. tostring(st.cursor) + .. " selected=" .. tostring(st.selectingState) .. " menu=" .. tostring(st.menuCursor) + .. " animation=" .. tostring(st.picAnim and st.picAnim.step)) + timeout(label) + return false + end + if not rentalWait(function() return ready("choose") end, "rental UI did not reach actionable selection") then return end + check(true, "rental UI reaches selection") + d.shot(game, "02_rental_selection") + local rank = {} + local Moves = require("src.core.game3.battle.moves") + local Types = require("src.core.game3.battle.types") + local Pokemon = require("src.core.game3.pokemon") + for i, row in ipairs(st.mons) do + local m = row.monData + local types, best, unsafe, names = Pokemon.types(m.species), 0, false, {} + for _, id in ipairs(m.moves or {}) do + local move = Moves.get(id) + names[#names + 1] = tostring(id) .. "/" .. tostring(Pokemon.romMoveName(id)) + if id == 120 or id == 153 then unsafe = true end + local stab = (move.type == types[1] or move.type == types[2]) and 1.5 or 1 + local stat = Types.isPhysical(move.type) and m.attack or m.spAtk + best = math.max(best, (move.power or 0) * (stat or 0) * stab / 100) + end + local score = unsafe and -1 or best + ((m.maxHp or 0) + (m.defense or 0) + (m.spDef or 0) + (m.speed or 0)) / 5 + rank[i] = { i = i, score = score } + d.note(string.format("rental %d %s species=%s hp=%s atk=%s spa=%s spe=%s score=%.2f moves=%s", i, + tostring(m.name), tostring(m.species), tostring(m.maxHp), tostring(m.attack), tostring(m.spAtk), tostring(m.speed), + score, table.concat(names, ","))) + end + table.sort(rank, function(a, b) if a.score == b.score then return a.i < b.i end return a.score > b.score end) + for _, candidate in ipairs(rank) do + if st.selectingState > 3 then break end + if not rentalWait(function() return ready("choose") end, "rental UI did not finish closing previous picture") then return end + for _ = 1, 6 do if st.cursor == candidate.i then break end U.tap(game, "right") U.wait(3) end + if not rentalWait(function() return st.cursor == candidate.i end, "rental UI cursor did not reach candidate", 30) then return end + U.tap(game, "a") + if not rentalWait(function() return ready("menu") end, "rental UI did not open candidate menu") then return end + U.tap(game, "down") U.wait(3) + if not rentalWait(function() return st.menuCursor == 1 end, "rental UI did not highlight RENT", 30) then return end + d.note("RENT candidate=" .. candidate.i .. " species=" .. tostring(st.mons[candidate.i].monData.species)) + U.tap(game, "a") + if not rentalWait(function() return ready("choose") or ready("yesno") or st.phase == "invalid" end, + "rental UI did not complete RENT selection") then return end + if st.phase == "invalid" then + U.tap(game, "a") + if not rentalWait(function() return ready("choose") end, "rental UI did not dismiss invalid species") then return end + end + end + if not rentalWait(function() return ready("yesno") end, "rental UI did not confirm three rentals") then return end + check(st.selectingState == 4, "three rentals chosen with RENT") + U.tap(game, "a") + selected = rentalWait(function() return not Select.isOpen() end, "rental UI did not accept chosen party") + check(selected, "rental UI confirms chosen party") + end + local function readyChoice() + if not Choice.isOpen() then return false end + for _, row in ipairs(Choice.options or {}) do + local text = type(row) == "table" and (row.text or row.label or row[1]) or row + if tostring(text):upper():find("REST", 1, true) then return true end + end + return false + end + local function watch() + session = Runtime.getSession() or session + f = Util.frontier(session) + local stage = table.concat({ tostring(S.mapNow()), battles, wins, tostring(selected), tostring(captured), tostring(savedRest), tostring(resumeRentals) }, ":") + if stage ~= lastStage then lastStage, lastProgress = stage, U.frame() end + if love.timer.getTime() >= deadline then timeout("driver exceeded 25-second budget") + elseif U.frame() - lastProgress > 12000 then timeout("driver made no challenge progress for 12000 frames") end + if aborted then return end + if step == "setup" and wins == 1 and f.curChallengeBattleNum == 1 and readyChoice() and not captured then + captured = true + witness.rentals, witness.rentalMons = copy(session.party), copy(f.rentalMons) + witness.lobby = copy(session.dynamicWarp) + check(love.filesystem.write(witnessFile, SaveData.encode(witness)), "write exact restart witness in dedicated identity") + sameParty(session.savedPlayerParty, witness.original, "2638 before-pause original backup fingerprint") + sameParty(session.party, witness.rentals, "2638 before-pause rental fingerprint") + d.shot(game, "03_before_pause") + if route == "direct" then + check(game:saveGame() == true, "actual Game3 direct save succeeds during rentals") + local disk = readSave() + sameParty(disk and disk.savedPlayerParty, witness.original, "2638 direct save original backup fingerprint") + sameParty(disk and disk.party, witness.rentals, "2638 direct save rental fingerprint") + stopped = true + end + end + if step == "setup" and route == "rest" and captured and not savedRest then + local disk = readSave() + savedRest = disk and disk.frontier and disk.frontier.challengePaused == 1 and disk.frontier.challengeStatus == 2 + and disk.frontier.curChallengeBattleNum == 1 and (tonumber(disk.specialSaveWarpFlags) or 0) % 2 == 1 + if savedRest then + sameParty(disk.party, witness.original, "2638 REST written original fingerprint") + sameParty(disk.savedPlayerParty, witness.original, "2638 REST written backup fingerprint") + end + end + if step == "resume" and route == "rest" and not resumeRentals and S.mapNow() == CORRIDOR and readyChoice() then + resumeRentals = true + sameParty(session.party, witness.rentals, "2638 resumed same-rental hidden-stat fingerprint") + sameParty(session.savedPlayerParty, witness.original, "2638 resumed original-backup hidden-stat fingerprint") + check(f.curChallengeBattleNum == 1, "REST resume keeps completed battle count") + end + end + local function choice() + if stopped then return "b" end + if readyChoice() then return step == "setup" and route == "rest" and "REST" or "GO ON" end + if Choice.kind == "multi" then + for _, row in ipairs(Choice.options or {}) do + local text = type(row) == "table" and (row.text or row.label or row[1]) or row + local label = tostring(text):upper() + if label == "CHALLENGE" or label == "ENTER" then + d.note("select attendant entry " .. label) + return label + end + end + end + local page = tostring(Message.currentPage and Message.currentPage() or ""):upper() + if page:find("SWAP", 1, true) or page:find("RECORD", 1, true) then return "no" end + return "yes" + end + if step == "setup" then + local attendant = S.objectByScript("SlateportCity_BattleTentLobby_EventScript_Attendant") + if not check(attendant ~= nil, "real Slateport attendant found") then return d.finish() end + S.talkTo(game, attendant) + end + local done = S.settle(game, { + limit = 120000, + until_ = function() + if aborted then return true end + if step == "setup" then + return stopped or (savedRest and game.phase == "boot") + or (battles > 0 and wins == 0 and S.mapNow() == LOBBY and not S.busy()) + end + return battles >= 1 and S.mapNow() == LOBBY and not S.busy() and tonumber(f.challengeStatus) == 0 + end, + choice = choice, + watch = watch, + onIdleUi = function() + if aborted then return true end + if Select.isOpen() then driveSelect() return true end + if SaveMenu.isOpen() then U.tap(game, "a") U.wait(8) return true end + return false + end, + onBattleFrame = function(st, phase) + local Ui = require("src.core.game3.battle.ui") + if phase == "command" and Ui._mode == "moves" then + local who = Ui.activeBattler and Ui.activeBattler() or 0 + local p, e = st.battlers and st.battlers[who], st.battlers and st.battlers[1] + local tag = tostring(st.turn) .. ":" .. tostring(p and p.mon and p.mon.species) + if tag ~= d.lastBattleTrace then + d.lastBattleTrace = tag + d.note("battle turn=" .. tostring(st.turn) .. " player=" .. tostring(p and p.mon and p.mon.species) + .. " hp=" .. tostring(p and p.mon and p.mon.hp) .. " foe=" .. tostring(e and e.mon and e.mon.species) + .. " foeHp=" .. tostring(e and e.mon and e.mon.hp)) + end + end + if love.timer.getTime() >= deadline then + timeout("driver exceeded 25-second budget during battle") + d.finish() + end + end, + onBattleStart = function(st) + battles = battles + 1 + check(st.kinds and st.kinds.frontier and #session.party == 3, "actual rental Frontier battle " .. battles) + sameParty(session.savedPlayerParty, witness.original, "2638 battle original-backup fingerprint") + S.pendingBattleShot = step == "setup" and "02_first_rental_battle" or "05_rental_battle_after_continue" + end, + onBattleEnd = function(result) + if result == "win" then wins = wins + 1 end + check(result == "win", "actual rental battle won " .. battles .. " result=" .. tostring(result)) + if result ~= "win" then + local Ui = require("src.core.game3.battle.ui") + for i, text in ipairs(Ui.log and Ui.log() or {}) do + if type(text) == "table" then + local ok, plain = pcall(require("src.core.game3.scripting.text_ir").toAscii, text, {}) + text = ok and plain or tostring(text) + end + d.note("battle failure log " .. i .. " " .. tostring(text):gsub("\n", " ")) + end + end + end, + }) + check(done and not aborted, "driver reaches requested phase endpoint") + if aborted then return d.finish() end + if step == "setup" then + check(selected and wins == 1 and captured, "one real rental win precedes save/restart") + if route == "rest" then check(savedRest and game.phase == "boot", "real REST script saves and resets to boot") end + else + session = Runtime.getSession() + check(battles == 2 and wins == 2, "two remaining real rental battles completed after restart") + if route == "rest" then check(resumeRentals, "real corridor ResumeChallenge rebuilt saved rentals") end + check(S.mapNow() == LOBBY and tonumber(Util.frontier(session).challengeStatus) == 0, "challenge completed through real lobby script") + sameParty(session.party, witness.original, "2638 final exact original-party hidden-stat fingerprint") + local PartyMenu = require("src.ui.game3.party_menu") + if not check(waitFor(function() + local Fade = require("src.ui.game3.fade") + return not Fade.isActive() and (Fade.t or 0) <= 0 + end, 900), "final lobby fade fully clears before party menu") then return d.finish() end + PartyMenu.show(session.party, session.move_overlay, { session = session }) + if not check(waitFor(function() + local top = require("src.ui.game3.stack").top() + return PartyMenu.isOpen() and top and top.id == "party" and PartyMenu.mode == "list" + and #PartyMenu._party == 6 + end, 300), "final six-slot party menu is ready") then return d.finish() end + d.shot(game, "06_final_original_party") + PartyMenu.close() + check(game:saveGame() == true, "final original party saves through Game3") + sameParty(readSave().party, witness.original, "2638 final disk original-party fingerprint") + end + return d.finish() +end diff --git a/tests/drivers/game3_oak_first_battle.lua b/tests/drivers/game3_oak_first_battle.lua index fae3ebda..483e59a1 100644 --- a/tests/drivers/game3_oak_first_battle.lua +++ b/tests/drivers/game3_oak_first_battle.lua @@ -134,8 +134,7 @@ return function(game) and inVoiceover == (sh.voiceover == true) then sh.held = sh.held + 1 if sh.held >= 6 then - sh.taken = true - U.shot(game, DIR .. "/" .. sh.file) + sh.taken = U.still(game, DIR .. "/" .. sh.file) print("shot " .. sh.file) end else @@ -162,10 +161,13 @@ return function(game) if dim < lastDim and dim > 0 and not (Message.isOpen() and Message.frameKind() == "voiceover") then closedEarly = true end - if not undimShot and Message.isHeld and Message.isHeld() and dim > 0 and dim < 0.5 then - undimShot = true - U.shot(game, DIR .. "/2310_09_oak_voiceover_undim.png") + if not undimShot and Message.isHeld and Message.isHeld() + and Message.isOpen() and Message.frameKind() == "voiceover" + and dim < lastDim and dim > 0 and dim < 0.5 then + undimShot = U.still(game, DIR .. "/2310_09_oak_voiceover_undim.png") print("shot 2310_09_oak_voiceover_undim.png") + print("undim capture dim=" .. tostring(dim) .. " held=" .. tostring(Message.isHeld()) + .. " frame=" .. tostring(Message.frameKind()) .. " text=" .. tostring(page_text())) end lastDim = dim end @@ -217,8 +219,8 @@ return function(game) end result(oak_fx().phase == "text" and oak_fx().y == 6, "msg 1 prints once the dim reaches 6") U.wait(4) - U.shot(game, DIR .. "/2310_03_oak_party_menu.png") - result(true, "shot 2310_03_oak_party_menu.png (party-menu advice)") + local partyShot = U.still(game, DIR .. "/2310_03_oak_party_menu.png") + result(partyShot, "shot 2310_03_oak_party_menu.png (party-menu advice)") -- pokefirered/src/party_menu.c:1970 U.tap(game, "a") for _ = 1, 120 do @@ -228,8 +230,8 @@ return function(game) result(PartyMenu._oakPage == 2 and oak_fx().slot == 0 and oak_fx().y == 6, "the first slot lightens before msg 2") U.wait(4) - U.shot(game, DIR .. "/2310_08_oak_party_menu_slot_lit.png") - result(true, "shot 2310_08_oak_party_menu_slot_lit.png (first slot lit, msg 2)") + local slotShot = U.still(game, DIR .. "/2310_08_oak_party_menu_slot_lit.png") + result(slotShot, "shot 2310_08_oak_party_menu_slot_lit.png (first slot lit, msg 2)") for _ = 1, 40 do if not (PartyMenu.isOpen() and PartyMenu.mode == "oak") then break end U.tap(game, "a") diff --git a/tests/drivers/save_editor_custom_maps.lua b/tests/drivers/save_editor_custom_maps.lua new file mode 100644 index 00000000..79526a70 --- /dev/null +++ b/tests/drivers/save_editor_custom_maps.lua @@ -0,0 +1,151 @@ +local U = require("tests.drivers.util") + +return function(game) + U.wait(10) + package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" + local Fixture = require("tests.engine.save_editor_custom_maps") + local GameVersion = require("src.core.GameVersion") + local version = os.getenv("POKEPORT_VERSION") or GameVersion.get() + local generation = GameVersion.generation(version) + local Browser, Gen, Kit = require("MapBrowser"), require("Gen"), require("Kit") + local State, Theme = require("State"), require("Theme") + local opts, restoreNativeRoot = {}, nil + local root = "save-editor-custom-map-fixture" + assert(love.filesystem.createDirectory(root)) + if generation < 3 then + opts.atlas = root .. "/atlas.png" + local pixels = love.image.newImageData(8, 8) + for y = 0, 7 do + for x = 0, 7 do + pixels:setPixel(x, y, x < 4 and 0.1 or 0.8, y < 4 and 0.9 or 0.2, 0.65, 1) + end + end + assert(love.filesystem.write(opts.atlas, pixels:encode("png"):getString())) + pixels:release() + else + local Dataset = require("src.core.game3.dataset") + local maps = Dataset.buildMaps() + Dataset.attachMidLayouts(maps) + local source = maps[generation == 3 and GameVersion.layout(version) == "rse" and "EM_LITTLEROOT_TOWN" or "FR_PALLET_TOWN"] + if not (source and source.midLayout) then + for _, def in pairs(maps) do if def.midLayout then source = def; break end end + end + assert(source and source.midLayout, "no imported native map for custom-map visual probe") + opts.pair = source.pair or source.midLayout.pair + opts.mid = assert(source.midLayout:midAt(2, 2), "source metatile missing") + local Native = require("src.core.game3.tileset_native") + if not Native._cache then Native.install(Dataset.cache()) end + assert(Native.get(opts.pair), "native source tileset did not load") + end + local data, loader, ids = Fixture.load(version, opts) + if generation == 3 then + local Extract = require("src.import.gba.extract_island1") + restoreNativeRoot = Extract.NATIVE_ROOT + Extract.NATIVE_ROOT = root .. "/native" + require("src.core.game3.dataset").invalidateManifestCache() + assert(love.filesystem.createDirectory(Extract.NATIVE_ROOT .. "/layouts")) + local cells = {}; for i = 1, 24 do cells[i] = { mid = ids.mid, coll = 0, elev = 0 } end + assert(love.filesystem.write(Extract.NATIVE_ROOT .. "/layouts/" .. ids.lazy .. ".mid", + require("src.import.gba.native_pack").encodeMidLayout({ width = 6, height = 4, cells = cells }))) + end + local S = State.new() + S.data, S.version, S.tab = data, version, "map" + S.save = { generation = generation, version = version, player = { map = ids.new, x = 1, y = 1 } } + if generation == 3 then S.save.engine = "game3"; S.save.map = ids.new; S.save.x = 1; S.save.y = 1 end + local originalDraw, originalUpdate = game.draw, game.update + game.update = function() end + game.draw = function() + require("src.render.GameViewport").reset() + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setShader() + love.graphics.clear(0.035, 0.045, 0.075, 1) + local w, h = love.graphics.getDimensions() + Kit.layout(w, h) + Kit.beginFrame(-1, -1, false, 0) + Kit.text("small", "Installed mod map preview · " .. version, 20, 12, Theme.PAL.heading) + Browser.draw(S, Kit, 20, 45, w - 40, h - 65) + Kit.endFrame() + end + local errors, captures = {}, 0 + local function check(ok, message) + if not ok then errors[#errors + 1] = message; print("FAIL " .. message) + else print("PASS " .. message) end + end + local function select(id, label) + Browser.select(S, id) + S.mapSection, S.mapAutoFit = "view", true + local dir = assert(os.getenv("POKEPORT_SHOT_DIR"), "POKEPORT_SHOT_DIR is required") + assert(U.still(game, dir .. "/" .. version .. "-custom-map-" .. label .. ".png")) + captures = captures + 1 + check(S._mapViewRect and S._mapViewRect.w > 0, label .. " actual editor viewport is visible") + end + local function terrainPixels(id) + if not Browser.preview then return nil end + S.mapId = id + local ok, map, reason = Browser.preview(S) + if not (ok and map.renderer and not reason) then return nil end + local canvas = love.graphics.newCanvas(96, 64, { msaa = 0, dpiscale = 1 }) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setShader() + love.graphics.clear(0, 0, 0, 0) + love.graphics.setColor(1, 1, 1, 1) + map.renderer:draw(0, 0, 96, 64) + love.graphics.setCanvas() + love.graphics.pop() + local pixels = canvas:newImageData() + canvas:release() + local r, g, b, a = pixels:getPixel(1, 1) + if generation < 3 then + check(math.abs(r - 0.1) < 0.02 and math.abs(g - 0.9) < 0.02 and math.abs(b - 0.65) < 0.02 and a > 0.98, + id .. " actual custom atlas pixels reach terrain renderer") + else + local opaque = 0 + for y = 0, 15 do for x = 0, 15 do + local _, _, _, alpha = pixels:getPixel(x, y) + if alpha > 0.9 then opaque = opaque + 1 end + end end + check(opaque > 200, id .. " imported native metatile pixels render after mod load") + end + pixels:release() + return map + end + select(ids.base, "patched") + local expectedWidth = generation == 3 and 6 or 4 + check(Gen.maps(data)[ids.base].width == expectedWidth, "existing map uses installed mod dimensions") + if generation == 3 then + check(Gen.maps(data)[ids.base].midLayout:midAt(0, 0) == ids.mid, "existing map uses installed mod layout") + end + check(terrainPixels(ids.base) ~= nil, "patched existing map has artwork") + select(ids.new, "new") + check(terrainPixels(ids.new) ~= nil, "new registered map has artwork") + if generation == 3 then + select(ids.lazy, "native-file") + check(data.maps[ids.lazy].midLayout ~= nil, "new mod map lazily loads its native file") + check(terrainPixels(ids.lazy) ~= nil, "lazy native map has artwork") + end + select(ids.missing, "missing-art") + check(S.mapPreviewReason and S.mapPreviewReason:find("unavailable", 1, true), "missing map artwork has a visible explanation") + if Browser.preview then + local ok, grid = Browser.preview(S) + check(ok and grid:inBounds(1, 1), "missing art preserves coordinate placement bounds") + local rect = S._mapViewRect + local x = rect.x + (16 * 1.5 - S.mapCamX) * S.mapZoom + local y = rect.y + (16 * 1.5 - S.mapCamY) * S.mapZoom + local w, h = love.graphics.getDimensions() + Kit.beginFrame(x, y, true, 0) + Browser.draw(S, Kit, 20, 45, w - 40, h - 65) + Kit.endFrame() + check(S.mapClickCell and S.mapClickCell.cx == 1 and S.mapClickCell.cy == 1, "actual missing-art grid still selects a spawn cell") + end + game.draw, game.update = originalDraw, originalUpdate + if restoreNativeRoot then + require("src.import.gba.extract_island1").NATIVE_ROOT = restoreNativeRoot + require("src.core.game3.dataset").invalidateManifestCache() + end + print(("custom maps %s: loaded=%d captures=%d failures=%d"):format(version, #loader:status().loaded, captures, #errors)) + love.event.quit(#errors == 0 and 0 or 1) +end diff --git a/tests/drivers/silph10f_cont_bug2634.lua b/tests/drivers/silph10f_cont_bug2634.lua new file mode 100644 index 00000000..1af01dc6 --- /dev/null +++ b/tests/drivers/silph10f_cont_bug2634.lua @@ -0,0 +1,130 @@ +-- pokered/scripts/SilphCo10F.asm:72 +-- pokered/text/SilphCo10F.asm:1 +-- pokeyellow/text/SilphCo10F.asm:1 +return function(game) + local U = require("tests.drivers.util") + local TextBox = require("src.render.TextBox") + local Font = require("src.render.Font") + local Version = require("src.core.GameVersion") + local Timing = require("src.core.Timing") + local dir = assert(os.getenv("POKEPORT_SHOT_DIR"), "POKEPORT_SHOT_DIR is required") + local edition = Version.get() + local fails = 0 + + local function check(ok, label) + U.log((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + return ok + end + local function same(a, b) + if #a ~= #b then return false end + for i, value in ipairs(a) do if value ~= b[i] then return false end end + return true + end + local function rows(box, expected, label) + local good = #box.shown == #expected and same(box:visibleText() or {}, expected) + for i, text in ipairs(expected) do + good = good and same(box.shown[i] or {}, Font.encode(text)) + end + return check(good, label) + end + local function prompt(box) + for _ = 1, 400 do + if game.stack:top() ~= box then return false end + if box.waiting or box.done then return true end + U.wait(1) + end + return false + end + local function capture(box, file) + for _ = 1, 60 do + if box.blink % 60 < 20 then break end + U.wait(1) + end + check(U.still(game, dir .. "/" .. file), "screenshot " .. file) + end + local function close(box) + U.tap(game, "a") + for _ = 1, 120 do + if game.stack:top() == game.overworld and not game.overworld.runner:isRunning() then + return check(true, "NPC callback returns to overworld") + end + U.wait(1) + end + return check(false, "NPC callback returns to overworld") + end + local function talk() + U.teleport(game, "SILPH_CO_10F", 8, 15, "right") + local ow = game.overworld + local worker + for _, npc in ipairs(ow.npcs) do + if npc.def.name == "SILPHCO10F_SILPH_WORKER_F" then worker = npc end + end + if not check(worker ~= nil, "real Silph 10F worker is loaded") then return nil end + -- pokered/data/maps/objects/SilphCo10F.asm:25 + worker.wanders = false + worker:resetToSpawn() + worker.cellX, worker.cellY = 9, 15 + worker.px, worker.py = 9 * 16, 15 * 16 + U.tap(game, "a") + for _ = 1, 120 do + local box = game.stack:top() + if getmetatable(box) == TextBox then return box end + U.wait(1) + end + check(false, "NPC A dispatch opens textbox") + end + local function cachedRows(key, marker) + local raw = game.data.text[key] + if not check(type(raw) == "string", "cache contains " .. key) then return nil end + local text = TextBox.strip(TextBox.substitute(game, raw)) + local first, actual, last = text:match("^(.-)([\n\v])(.-)$") + if not check(first and last and actual == marker and not last:find("[\n\v\f]"), + edition .. " cached control for " .. key) then return nil end + return { first, last } + end + + if not check(edition == "red" or edition == "blue" or edition == "yellow", + "supported Gen 1 edition") then love.event.quit(1) return end + game.save.flags.EVENT_BEAT_SILPH_CO_10F_TRAINER_0 = true + game.save.flags.EVENT_BEAT_SILPH_CO_10F_TRAINER_1 = true + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil + local scared = cachedRows("_SilphCo10FSilphWorkerFImScaredText", edition == "yellow" and "\n" or "\v") + local quiet = cachedRows("_SilphCo10FSilphWorkerFQuietAboutMyCryingText", "\n") + if not scared or not quiet then love.event.quit(1) return end + local box = talk() + if not box or not check(prompt(box), "scared text reaches prompt") then + love.event.quit(1) return + end + if edition == "yellow" then + local good = check(box.done and not box.waiting, "Yellow ordinary line needs no cont press") + good = rows(box, scared, "Yellow scared text keeps both rows") and good + if good then capture(box, "2634_yellow_scared_two_rows.png") end + else + local good = check(box.waiting and box.contAdvance, "Red Blue scared text waits on cont") + good = rows(box, { scared[1] }, "Red Blue initial scared text upper row") and good + if good then capture(box, "2634_scared_cont_arrow.png") end + U.wait(Timing.TEXT_PRE_ADVANCE) + U.tap(game, "a") + check(prompt(box) and box.done and not box.waiting, "one cont press completes scared text") + local expected = { "", scared[2] } + if rows(box, expected, "Red Blue scared text blank upper bottom sentence") then + capture(box, "2634_scared_blank_upper_bottom_sentence.png") + end + local instant = TextBox.new(game, game.data.text._SilphCo10FSilphWorkerFImScaredText, + nil, { instant = true }) + rows(instant, expected, "instant scared text matches physical rows") + end + if not close(box) then love.event.quit(1) return end + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = true + box = talk() + if not box or not check(prompt(box), "quiet text reaches prompt") then + love.event.quit(1) return + end + local good = check(box.done and not box.waiting, "quiet text needs no cont press") + good = rows(box, quiet, "post Giovanni quiet text keeps both rows") and good + if good then capture(box, "2634_post_giovanni_quiet_two_rows.png") end + close(box) + U.log(fails == 0 and "PASS silph10f_cont_bug2634 " .. edition or "FAIL silph10f_cont_bug2634 " .. edition) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/viridian_gambler_bug2632_test.lua b/tests/drivers/viridian_gambler_bug2632_test.lua new file mode 100644 index 00000000..ee83bd17 --- /dev/null +++ b/tests/drivers/viridian_gambler_bug2632_test.lua @@ -0,0 +1,99 @@ +-- pokered/scripts/ViridianCity.asm:150 +-- pokeyellow/scripts/ViridianCity_2.asm:10 +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local GameVersion = require("src.core.GameVersion") + local version = GameVersion.get() + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local failed = false + local badges = { + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", + } + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", "2632_" .. label) + if not ok then failed = true end + return ok + end + local function topBox() + local state = game.stack:top() + return getmetatable(state) == TextBox and state or nil + end + local function flatten(pages) + local result = {} + for _, page in ipairs(pages) do result[#result + 1] = table.concat(page, "\n") end + return table.concat(result, "\f") + end + + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.flags.EVENT_OAK_GOT_PARCEL = true + game.save.flags.EVENT_VIRIDIAN_GYM_OPEN = true + game.save.inventory = game.save.inventory or {} + + local function scene(label, count, flagName, textKey) + for i, badge in ipairs(badges) do game.save.inventory[badge] = i <= count or nil end + game.save.flags.EVENT_BEAT_GIOVANNI = nil + game.save.flags.EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI = nil + if flagName then game.save.flags[flagName] = true end + U.teleport(game, "VIRIDIAN_CITY", 30, 9, "up") + local ow = game.overworld + if not check(label .. "_map", ow and ow.map.id == "VIRIDIAN_CITY") then return end + local gambler + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.text == "TEXT_VIRIDIANCITY_GAMBLER1" then + gambler = npc + break + end + end + if not check(label .. "_actual_npc", gambler ~= nil) then return end + gambler.wanders = false + gambler.moving = false + gambler.cellX, gambler.cellY = gambler.def.x, gambler.def.y + gambler.px, gambler.py = gambler.cellX * 16, gambler.cellY * 16 + local p = ow.player + p.cellX, p.cellY = gambler.cellX, gambler.cellY + 1 + p.px, p.py = p.cellX * 16, p.cellY * 16 + p.facing = "up" + U.wait(2) + U.tap(game, "a") + for _ = 1, 60 do + if topBox() then break end + U.wait(1) + end + local box = topBox() + if not check(label .. "_normal_talk", box ~= nil and gambler.frozen) then return end + local source = game.data.text[textKey] + if not check(label .. "_rom_text", type(source) == "string") then return end + local expected = TextBox.paginate(TextBox.substitute(game, source), box.maxCols) + check(label .. "_dialogue", flatten(box.pages) == flatten(expected)) + U.log("2632_" .. label .. "_pages", flatten(box.pages)) + for _ = 1, 600 do + if box.done or box.waiting then break end + U.wait(1) + end + if check(label .. "_completed_page", box.done or box.waiting) then + check(label .. "_screenshot", U.still(game, + DIR .. "/2632_" .. version .. "_" .. label .. ".png")) + end + for _ = 1, 120 do + if game.stack:top() == ow then break end + U.tap(game, "a") + U.wait(3) + end + check(label .. "_callback_unfreezes", game.stack:top() == ow and not gambler.frozen) + end + + scene("closed_before_seven", 6, nil, "_ViridianCityGambler1GymAlwaysClosedText") + scene("returned_exact_seven", 7, nil, "_ViridianCityGambler1GymLeaderReturnedText") + scene("returned_after_earth", 8, "EVENT_BEAT_GIOVANNI", + "_ViridianCityGambler1GymLeaderReturnedText") + scene("returned_canonical_win", 8, "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI", + "_ViridianCityGambler1GymLeaderReturnedText") + scene("closed_eight_without_win", 8, nil, "_ViridianCityGambler1GymAlwaysClosedText") + check("all_scenes", not failed) + love.event.quit(failed and 1 or 0) +end diff --git a/tests/emerald_tent_save_reload_2638_test.lua b/tests/emerald_tent_save_reload_2638_test.lua new file mode 100644 index 00000000..070266df --- /dev/null +++ b/tests/emerald_tent_save_reload_2638_test.lua @@ -0,0 +1,324 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local bit = require("bit") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +require("src.core.game3.profile").reset() +GameVersion.set("emerald") +local Util = require("src.core.game3.rse.frontier.util") +local Story = require("src.core.game3.scripting.natives_frontier_story") +local Sections = require("src.core.game3.save_sections") +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = copy(x) end + return out +end +local oldPersist = Util.persist +local function protectedSave(sess, persist) + Util.persist = persist + local ok, result = pcall(Util.saveGameFrontier, sess) + Util.persist = oldPersist + return ok, result +end +local function state() + return { version = "emerald", name = "RELOAD", trainerId = 12345, secretId = 4321, + map = "EM_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR", x = 3, y = 5, party = {}, flags = {}, vars = {}, + dynamicWarp = { map = "EM_SLATEPORT_CITY_BATTLE_TENT_LOBBY", x = 7, y = 8, warpId = 255 }, + continueGameWarp = { map = "EM_ROUTE110", x = 10, y = 11 }, specialSaveWarpFlags = 0xA1 } +end +local original = { { personality = 123, nickname = "ORIGINAL", ivs = { hp = 17 }, evs = { atk = 23 } } } +local rentals = { { personality = 987, nickname = "RENTAL", ivs = { hp = 3 }, evs = { atk = 252 } } } +local s = state() +s.party = copy(original) +Story.savePlayerParty(s) +s.party = copy(rentals) +local exported = {} +Sections.fields(Util.SAVE_FIELDS).export(s, exported) +local decoded = assert(SaveData.decode(SaveData.encode(exported))) +local restored = {} +Sections.fields(Util.SAVE_FIELDS).restore(decoded, restored) +T.same(restored.savedPlayerParty, original, "registered section preserves complete original backup through SaveData serializer") +for _, fail in ipairs({ false, "throw" }) do + s = state() + s.party, s.savedPlayerParty = copy(rentals), copy(original) + local ok, result = protectedSave(s, function() + T.same(s.party, original, "persistence sees original party") + T.same(s.continueGameWarp, s.dynamicWarp, "persistence sees captured lobby destination") + T.eq(s.specialSaveWarpFlags, 0xA1, "persistence sets only continue bit") + if fail == "throw" then error("2638 injected write failure") end + return false + end) + T.check(not ok or result == false, "failed persistence cannot report success") + T.same(s.party, rentals, "failed persistence restores live rentals") + T.same(s.savedPlayerParty, original, "failed persistence preserves original backup") + T.eq(s.specialSaveWarpFlags, 0xA0, "failed persistence clears continue bit and preserves other bits") + T.same(s.continueGameWarp, s.dynamicWarp, "failed persistence leaves inactive source destination bytes") +end +for _, missing in ipairs({ "dynamicWarp", "x", "savedPlayerParty" }) do + s = state() + s.party, s.savedPlayerParty = copy(rentals), copy(original) + if missing == "x" then s.dynamicWarp.x = nil else s[missing] = nil end + local before, calls = copy(s), 0 + local ok, result = protectedSave(s, function() calls = calls + 1 return true end) + T.check(ok and result == false and calls == 0, "incomplete " .. missing .. " is rejected before writing") + T.same(s, before, "incomplete " .. missing .. " leaves concrete session state intact") +end +local cache = require("src.core.game3.dataset").cache() +local hasCache = type(cache:read("data/generated/gba/rse/frontier/manifest.lua")) == "string" +if hasCache then + require("src.import.gba.versions").select("emerald") + local C = require("src.core.game3.constants").of("emerald") + require("src.core.game3.pokemon").install(nil) + local D = Util.D + local Rse = require("src.core.game3.rse.init") + local Runtime = require("src.core.game3.runtime") + local Schema = require("src.core.game3.save_schema_firered") + local Tents = require("src.core.game3.rse.frontier.tents") + local Factory = require("src.core.game3.rse.frontier.factory") + local ctx = { specialVars = { [0x8005] = 2 } } + function ctx:getVar(id) return self.specialVars[id] or 0 end + function ctx:setVar(id, value) self.specialVars[id] = value end + local oldGet = Runtime.getSession + Runtime.getSession = function() return s end + original = {} + local names = { "SPECIES_SWAMPERT", "SPECIES_BLAZIKEN", "SPECIES_SCEPTILE", "SPECIES_METAGROSS", "SPECIES_SALAMENCE", "SPECIES_ZIGZAGOON" } + for i, name in ipairs(names) do + local m = D.createMon(C:require("species", name), 35 + i, 10 + i, 200000 + i * 37, 12345, + { otName = "RELOAD", moves = {} }) + D.setMoves(m, { C:require("moves", "MOVE_TACKLE"), C:require("moves", "MOVE_PROTECT") }) + D.setEvs(m, { i * 7, i * 9, i * 11, i * 13, i * 15, i * 17 }) + m.nickname, m.otSecretId, m.heldItem = "KEEP" .. i, 4321, C:require("items", "ITEM_ORAN_BERRY") + m.ivs.hp, m.pp[1], m.friendship, m.markings = i + 4, i + 2, 100 + i, i + require("src.core.game3.save_mon").normalize(m) + Schema.ensureMonBall(m) + original[i] = m + end + love = love or require("tests.love_stub") + local oldFs, files, writeFault = SaveData.portableFs, {}, nil + SaveData.portableFs = function() + return { + getInfo = function(n) return files[n] and { type = "file" } or nil end, + read = function(n) return files[n] end, + write = function(n, body) + if writeFault and n == SaveData.saveFilename("emerald") .. ".tmp" then + if writeFault == "throw" then error("2638 injected filesystem failure") end + return false, "2638 injected filesystem failure" + end + files[n] = body + return true + end, + remove = function(n) files[n] = nil return true end, + createDirectory = function() return true end, + } + end + local function roundtrip(sess) + assert(SaveData.save(Schema.toSaveTable(sess))) + return assert(SaveData.load("emerald")) + end + local function fresh() + s = state() + s.bag = require("src.core.game3.bag").new() + s.party = copy(original) + s.dex = { [1] = { seen = true, owned = true } } + s.storage = require("src.core.game3.storage").new() + Util.newGame(s) + require("src.core.game3.scripting.space").store = { flags = s.flags, vars = s.vars } + Rse.setVar("VAR_FRONTIER_FACILITY", D.FACILITY.FACTORY, s) + Rse.setVar("VAR_FRONTIER_BATTLE_MODE", D.MODE.SINGLES, s) + return s + end + fresh() + s.specialSaveWarpFlags = 0xA0 + local ordinary = Schema.fromSaveTable(roundtrip(s)) + T.same(ordinary.party, original, "ordinary Emerald save preserves all original mon values") + T.eq(ordinary.map, s.map, "ordinary save stays at current location") + T.eq(ordinary.savedPlayerParty, nil, "ordinary and legacy saves do not invent a backup") + s.map, s.x, s.y = s.dynamicWarp.map, 7, 8 + Tents.init(s) + Story.savePlayerParty(s) + s.frontier.lvlMode = D.LVL.TENT + require("src.core.game3.rng").SeedRng(0x2638) + Tents.generateRentalMons(s) + Tents.generateOpponentMons(s) + ctx.specialVars[0x8005] = 0 + Factory.setPlayerAndOpponentParties(ctx, s) + rentals = copy(s.party) + for _, m in ipairs(rentals) do + require("src.core.game3.save_mon").normalize(m) + Schema.ensureMonBall(m) + end + s.party = copy(rentals) + s.map, s.x, s.y = "EM_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR", 3, 5 + s.frontier.curChallengeBattleNum = 1 + local dex, storage = copy(s.dex), copy(s.storage) + local direct = Schema.fromSaveTable(roundtrip(s)) + T.same(direct.party, rentals, "direct engine save retains active rentals") + T.same(direct.savedPlayerParty, original, "direct engine save retains six full original identities and traits") + Story.loadPlayerParty(direct) + T.same(direct.party, original, "direct continue completion restores exact original six mons in order") + T.same(direct.dex, dex, "rental reload leaves dex unchanged") + T.same(direct.storage, storage, "rental reload leaves storage unchanged") + if direct.savedPlayerParty then direct.savedPlayerParty[1].ivs.hp = 99 end + T.eq(direct.party[1].ivs.hp, original[1].ivs.hp, "restored party and backup are not aliases") + local disk + Util.persist = function() disk = roundtrip(s) return true end + ctx.specialVars[0x8005] = Util.CHALLENGE_STATUS.PAUSED + T.check(Tents.save(ctx, s), "real Tent REST save succeeds") + Util.persist = oldPersist + T.same(disk.party, original, "REST disk party contains originals") + T.same(disk.continueGameWarp, s.dynamicWarp, "REST disk continue destination is captured lobby") + T.eq(bit.band(disk.specialSaveWarpFlags, 1), 1, "REST disk has continue bit") + T.same(disk.frontier.rentalMons, s.frontier.rentalMons, "REST keeps saved rental data") + T.eq(disk.frontier.curChallengeBattleNum, 1, "REST keeps challenge battle counter") + T.same(s.party, rentals, "successful REST restores live rental party") + T.eq(s.specialSaveWarpFlags, 0xA0, "successful REST clears live bit only") + s = Schema.fromSaveTable(disk) + T.eq(s.map, "EM_SLATEPORT_CITY_BATTLE_TENT_LOBBY", "REST continue resumes lobby") + T.eq(s.x, 7, "REST continue keeps lobby x") + T.eq(s.y, 8, "REST continue keeps lobby y") + Story.savePlayerParty(s) + ctx.specialVars[0x8005] = 0 + Factory.setPlayerAndOpponentParties(ctx, s) + for _, m in ipairs(s.party) do + require("src.core.game3.save_mon").normalize(m) + Schema.ensureMonBall(m) + end + T.same(s.party, rentals, "real corridor resume rebuilds the same rentals") + Story.loadPlayerParty(s) + T.same(s.party, original, "REST resume completion restores exact original six mons") + for i, name in ipairs({ "tents", "factory", "tower", "palace", "arena", "dome" }) do + fresh() + s.map, s.x, s.y = "EM_SLATEPORT_CITY_BATTLE_TENT_LOBBY", i + 1, i + 5 + require("src.core.game3.rse.frontier." .. name).init(s) + local expected = { map = s.map, x = s.x, y = s.y, warpId = 255 } + T.same(s.dynamicWarp, expected, name .. " initializer captures exact entry coordinates") + s.map, s.x, s.y = "EM_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR", 1, 2 + s.savedPlayerParty = copy(original) + local ok, result = protectedSave(s, function() disk = roundtrip(s) return true end) + T.check(ok and result, name .. " shared save succeeds") + T.same(disk.continueGameWarp, expected, name .. " save uses initial location rather than current room") + T.eq(s.specialSaveWarpFlags, 0xA0, name .. " save preserves other warp bits") + end + fresh() + s.party, s.savedPlayerParty = copy(rentals), copy(original) + local staleWarp, currentWarp = copy(s.dynamicWarp), copy(s.continueGameWarp) + local common, calls = Util.saveGameFrontier, 0 + Util.saveGameFrontier = function() error("Pike must save in place") end + Util.persist = function() calls = calls + 1 disk = roundtrip(s) return true end + ctx.specialVars[0x8005] = Util.CHALLENGE_STATUS.PAUSED + local ok, result = pcall(require("src.core.game3.rse.frontier.pike").save, ctx, s) + Util.persist, Util.saveGameFrontier = oldPersist, common + T.check(ok and result and calls == 1, "Pike invokes in-place persistence only") + if calls == 1 then + T.same(disk.party, rentals, "Pike writes active challenge party without original swap") + T.same(disk.continueGameWarp, currentWarp, "Pike does not manufacture continue destination") + T.same(disk.dynamicWarp, staleWarp, "Pike ignores stale unrelated dynamic warp") + T.eq(disk.map, s.map, "Pike saves current map") + T.eq(disk.x, s.x, "Pike saves current x") + T.eq(disk.y, s.y, "Pike saves current y") + T.eq(disk.frontier.challengePaused, 1, "Pike writes paused challenge state") + T.eq(disk.specialSaveWarpFlags, 0xA1, "Pike preserves preexisting warp flags") + end + local Vm = require("src.core.game3.scripting.vm") + local Adapters = require("src.core.game3.scripting.adapters") + local Field = require("src.core.game3.field") + local Natives = require("src.core.game3.scripting.natives") + local Game3 = require("src.core.Game3") + local oldGame, oldMod = Runtime._game, Runtime._mod + Natives.bind("emerald") + local nativeSaves = { + { "CallSlateportTentFunction", Tents.SLATEPORT.SAVE, "VAR_TEMP_0" }, + { "CallFallarborTentFunction", Tents.FALLARBOR.SAVE, "VAR_TEMP_0" }, + { "CallVerdanturfTentFunction", Tents.VERDANTURF.SAVE, "VAR_TEMP_0" }, + { "CallBattleFactoryFunction", Factory.FUNC.SAVE, "VAR_TEMP_CHALLENGE_STATUS" }, + { "CallBattleTowerFunc", require("src.core.game3.rse.frontier.tower").FUNC.SAVE, "VAR_TEMP_0" }, + { "CallBattleArenaFunction", require("src.core.game3.rse.frontier.arena").FUNC.SAVE, "VAR_TEMP_CHALLENGE_STATUS" }, + { "CallBattlePalaceFunction", require("src.core.game3.rse.frontier.palace").FUNC.SAVE, "VAR_TEMP_CHALLENGE_STATUS" }, + { "CallBattleDomeFunction", require("src.core.game3.rse.frontier.dome").FUNC.SAVE, "VAR_TEMP_CHALLENGE_STATUS" }, + } + for _, native in ipairs(nativeSaves) do + for _, failure in ipairs({ "false", "throw", "missingWarp", "missingParty" }) do + fresh() + s.savedPlayerParty, s.party = copy(original), copy(rentals) + s.specialSaveWarpFlags = 0xA0 + s.frontier.challengeStatus, s.frontier.challengePaused = 1, 0 + local tempId = assert(Rse.varId(native[3], s)) + Rse.setVar(tempId, 73, s) + roundtrip(s) + local diskBefore = files[SaveData.saveFilename("emerald")] + if failure == "missingWarp" then s.dynamicWarp.x = nil + elseif failure == "missingParty" then s.savedPlayerParty = nil + else writeFault = failure end + local frontierBefore, partyBefore, backupBefore = copy(s.frontier), copy(s.party), copy(s.savedPlayerParty) + Runtime._game, Runtime._mod = { session = s, phase = "field", saveGame = Game3.saveGame }, nil + local closed, finishMessage, sounds, unfreezes, logs = 0, nil, 0, 0, {} + local adapters = Adapters.stub({}) + adapters.listActiveLocalIds = function() return { 1 } end + adapters.snapshotLocal = function() return { frozen = false } end + adapters.freezeLocal = function() Field.locked = true end + adapters.unfreezeLocal = function() unfreezes = unfreezes + 1 end + adapters.log = function(msg) logs[#logs + 1] = msg end + adapters.playSe = function() sounds = sounds + 1 end + adapters.closeMessage = function() closed = closed + 1 end + adapters.openMessageAsync = function(text, done) + T.check(type(text) == "string" and text:find("saved", 1, true) ~= nil, native[1] .. " presents concrete save error") + finishMessage = done + end + local vm = Vm.new({ version = "emerald", store = Rse.store(), adapters = adapters, scripts = { save = { + { op = "lockall" }, + { op = "setvar", 0x8004, native[2] }, + { op = "setvar", 0x8005, 2 }, + { op = "special", id = C:require("specials", native[1]) }, + { op = "playse", C:require("songs", "SE_SAVE") }, + { op = "setvar", 0x8004, Util.FUNC.SOFT_RESET }, + { op = "special", id = C:require("specials", "CallFrontierUtilFunc") }, + { op = "end" }, + } } }) + T.check(vm:start("save"), native[1] .. " real VM starts") + T.check(vm:isRunning() and finishMessage ~= nil, native[1] .. " failure awaits message dismissal") + T.eq(sounds, 0, native[1] .. " failure skips save-success sound") + T.eq(Runtime._game.softResetRequested, nil, native[1] .. " failure skips frontier reset") + T.same(s.frontier, frontierBefore, native[1] .. " failure rolls back all challenge save fields") + T.eq(Rse.var(tempId, s), 73, native[1] .. " failure restores original temporary var") + T.same(s.party, partyBefore, native[1] .. " failure restores current live rentals") + T.same(s.savedPlayerParty, backupBefore, native[1] .. " failure preserves concrete original backup") + T.eq(files[SaveData.saveFilename("emerald")], diskBefore, native[1] .. " failure leaves last successful save intact") + if finishMessage then finishMessage() end + vm:tick() + T.check(not vm:isRunning() and vm.ctx.status == "shutdown", native[1] .. " failure ends through normal VM halt") + T.check(not Field.locked and unfreezes == 1 and vm.ctx.lockKind == nil, native[1] .. " failure unfreezes objects and unlocks field") + T.check(closed >= 2 and vm.ctx.messageOpen == false, native[1] .. " failure closes error message") + T.eq(sounds, 0, native[1] .. " dismissed error still skips save-success sound") + T.eq(Runtime._game.softResetRequested, nil, native[1] .. " dismissed error still skips reset") + T.eq(#logs, 0, native[1] .. " known SAVE failure never reports an unported native") + writeFault = nil + end + fresh() + s.savedPlayerParty, s.party = copy(original), copy(rentals) + s.specialSaveWarpFlags = 0xA0 + Runtime._game, Runtime._mod = { session = s, phase = "field", saveGame = Game3.saveGame }, nil + local sounds = 0 + local adapters = Adapters.stub({}) + adapters.playSe = function() sounds = sounds + 1 end + local vm = Vm.new({ version = "emerald", store = Rse.store(), adapters = adapters, scripts = { save = { + { op = "setvar", 0x8004, native[2] }, + { op = "setvar", 0x8005, 2 }, + { op = "special", id = C:require("specials", native[1]) }, + { op = "playse", C:require("songs", "SE_SAVE") }, + { op = "setvar", 0x8004, Util.FUNC.SOFT_RESET }, + { op = "special", id = C:require("specials", "CallFrontierUtilFunc") }, + { op = "end" }, + } } }) + T.check(vm:start("save") and not vm:isRunning(), native[1] .. " successful save runs synchronous script tail") + T.eq(sounds, 1, native[1] .. " success reaches sentinel save sound") + T.eq(Runtime._game.softResetRequested, true, native[1] .. " success reaches real reset special") + T.same(s.party, rentals, native[1] .. " success keeps live rentals") + T.same(SaveData.load("emerald").party, original, native[1] .. " success writes exact original party") + end + Runtime._game, Runtime._mod = oldGame, oldMod + Runtime.getSession, SaveData.portableFs = oldGet, oldFs +else + print("SKIP ROM assertions: Emerald cache unavailable; section serialization and failure cleanup still exercised") +end +T.finish("emerald_tent_save_reload_2638_test") diff --git a/tests/engine/cache_contract_emerald_test.lua b/tests/engine/cache_contract_emerald_test.lua index cc44a897..a93145df 100644 --- a/tests/engine/cache_contract_emerald_test.lua +++ b/tests/engine/cache_contract_emerald_test.lua @@ -20,12 +20,12 @@ eq(CacheContract.markerFor("emerald", SHA), "rom-cache-v3-emerald:" .. SHA, "eme check(CacheContract.markerMatches("emerald", "rom-cache-v3-emerald:" .. SHA), "emerald marker matches") check(not CacheContract.markerMatches("emerald", "rom-cache-v17-firered:" .. SHA), "a FireRed marker does not") -eq(#CacheContract.requiredFiles("firered"), 433, "FireRed required list size") -eq(digest(CacheContract.requiredFiles("firered")), 3052316112, "FireRed required list") -eq(#CacheContract.requiredFiles("leafgreen"), 434, "LeafGreen required list size") -eq(digest(CacheContract.requiredFiles("leafgreen")), 3097867067, "LeafGreen required list") -eq(digest(CacheContract.requiredFiles("firered", true)), 2123642333, "FireRed semantic list") -eq(digest(CacheContract.requiredFiles("leafgreen", true)), 1337846595, "LeafGreen semantic list") +eq(#CacheContract.requiredFiles("firered"), 453, "FireRed required list size") +eq(digest(CacheContract.requiredFiles("firered")), 1498110830, "FireRed required list") +eq(#CacheContract.requiredFiles("leafgreen"), 454, "LeafGreen required list size") +eq(digest(CacheContract.requiredFiles("leafgreen")), 3355015054, "LeafGreen required list") +eq(digest(CacheContract.requiredFiles("firered", true)), 455568495, "FireRed semantic list") +eq(digest(CacheContract.requiredFiles("leafgreen", true)), 4104686896, "LeafGreen semantic list") local em, isOverride = CacheContract.requiredFilesFor("emerald") check(isOverride == true, "emerald is a composed list, not the gen 1 list") diff --git a/tests/engine/game3_em_bike_doors_2637.lua b/tests/engine/game3_em_bike_doors_2637.lua new file mode 100644 index 00000000..daf1d76d --- /dev/null +++ b/tests/engine/game3_em_bike_doors_2637.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +require("src.core.GameVersion").set("emerald") +local session = { version = "emerald", map = "EM_TEST", party = {}, flags = {}, vars = {} } +package.loaded["src.core.game3.runtime"] = { + getSession = function() return session end, uiBusy = function() return false end, +} +package.loaded["src.core.game3.audio"] = setmetatable({}, { __index = function() return function() end end }) +package.loaded["src.core.game3.step_events"] = { onStepTaken = function() end } +package.loaded["src.core.game3.encounters"] = { onStep = function() end } +local entered, exit +package.loaded["src.core.game3.warp"] = { + isBusy = function() return entered ~= nil or exit ~= nil end, + startDoorEntrance = function(_, _, dest) entered = dest end, + startDoorExit = function(_, _, dest) exit = dest end, +} +package.loaded["src.core.game3.doors"] = { + getDoorEntryAt = function(_, x, y) if x == 3 and y == 2 then return {} end end, +} +local Collision = require("src.core.game3.collision") +local Player = require("src.core.game3.player") +local Field = require("src.core.game3.field") +local Bike = require("src.core.game3.bike") +local MB = require("src.core.game3.mb") +local Layout = require("src.core.game3.layout_native") +local cells = {} +for i = 1, 49 do cells[i] = { mid = 0, coll = 0, elev = 3 } end +-- pokeemerald/src/event_object_movement.c:4663 +cells[2 * 7 + 3 + 1] = { mid = 1, coll = 7, elev = 0 } +local map = { kind = "town", pair = "test", midLayout = Layout.fromDecoded({ width = 7, height = 7, cells = cells }), + warps = { { x = 3, y = 2, destMap = "EM_CENTER", destX = 3, destY = 7 } } } +local game = { data = { maps = { EM_TEST = map, EM_CENTER = { kind = "indoor" } } } } +require("src.core.game3.scripting.interaction_scripts").behaviors.test = { [0] = MB.require("NORMAL"), [1] = MB.require("WARP_DOOR") } +Field._game, Field._session, Field.locked = game, session, false +local function input(dir, b) + return { isDown = function(_, k) return k == dir or (k == "b" and b == true) end, + wasPressed = function() return false end } +end +local function reset(kind, x, y, facing) + entered, exit = nil, nil + Collision.bindMap(game, "EM_TEST", map) + Player.reset(x, y, facing) + Player.biking, Player.bikeType = false, nil + Bike.rse(session).getOnOff(kind, session) +end +local function step(dir) + Player.update(game, input(dir)) + while Player.moving do Player.tick(game) end +end +T.check(Bike.rse({ version = "firered" }) == nil, "FRLG bicycle controller preserved") +for _, kind in ipairs({ "mach", "acro" }) do + reset(kind, 2, 3, "right") + step("right") + T.eq(Player.cellX, 3, kind .. " moving approach reached south of door") + Player.update(game, input("up")) + T.check(not Player.moving, kind .. " changed heading cannot enter unclaimed solid door") + T.eq(Player.cellY, 3, kind .. " stays south of door") + T.eq(entered, nil, kind .. " changed heading does not bypass facing gate") + while Player.action do Player.tick(game) end + Player.update(game, input("up")) + T.eq(entered, "EM_CENTER", kind .. " next north input dispatches actual Player door trigger") + reset(kind, 3, 3, "up") + Player.update(game, input("up")) + T.eq(entered, "EM_CENTER", kind .. " straight approach dispatches door") + reset(kind, 2, 2, "right") + Player.update(game, input("right")) + T.check(not Player.moving, kind .. " side approach cannot occupy animated door") +end +reset("acro", 2, 3, "right") +step("right") +Player.update(game, input("up", true)) +T.check(not Player.moving and Player.cellY == 3, "Acro wheelie transition cannot bypass animated door collision") +reset("acro", 3, 3, "up") +Player.update(game, input("up", true)) +T.eq(entered, "EM_CENTER", "Acro B-held straight approach preserves door trigger") +reset("mach", 3, 5, "up") +step("up") +step("up") +Player.update(game, input(nil)) +T.check(not Player.moving, "Mach coast cannot occupy solid animated door") +T.eq(entered, nil, "Mach release does not open door without held input") +while Player.action do Player.tick(game) end +Player.update(game, input("up")) +T.eq(entered, "EM_CENTER", "Mach held north enters after coast collision") +local exitCells = {} +for i = 1, 49 do exitCells[i] = { mid = 0, coll = 0, elev = 3 } end +exitCells[2 * 7 + 3 + 1] = { mid = 2, coll = 0, elev = 3 } +require("src.core.game3.scripting.interaction_scripts").behaviors.test[2] = MB.require("SOUTH_ARROW_WARP") +local exitMap = { kind = "indoor", pair = "test", + midLayout = Layout.fromDecoded({ width = 7, height = 7, cells = exitCells }), + warps = { { x = 3, y = 2, destMap = "EM_TEST", destX = 3, destY = 2 } } } +game.data.maps.EM_CENTER = exitMap +for _, kind in ipairs({ "mach", "acro" }) do + reset(kind, 3, 2, "down") + Collision.bindMap(game, "EM_CENTER", exitMap) + Player.update(game, input("down")) + T.eq(exit, "EM_TEST", kind .. " exit mat dispatch preserved") +end +T.finish("game3_em_bike_doors_2637") diff --git a/tests/engine/game3_em_catch_prompt_2637.lua b/tests/engine/game3_em_catch_prompt_2637.lua new file mode 100644 index 00000000..2d850c95 --- /dev/null +++ b/tests/engine/game3_em_catch_prompt_2637.lua @@ -0,0 +1,217 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +_G.love = require("tests.love_stub") +local T = require("tests.harness") +local Battle = require("src.core.game3.battle.init") +local Ui = require("src.core.game3.battle.ui") +local Dex = require("src.ui.game3.rse.pokedex") +local Pokemon = require("src.core.game3.pokemon") +local Text = require("src.core.game3.battle.battle_text") +local Pal = require("src.core.game3.pal_fade") +local Chrome = require("src.ui.game3.battle_chrome") +local Bg = require("src.core.game3.battle.bg") +Text.get = function(_, args) return args and args.opponentMon1 or "caught" end +Pokemon.name = function() return "WURMPLE" end +Pokemon.speciesFromNational = function() return 290 end +Pokemon.picSpecies = function(sp) return sp end +local shinyImage, normalImage = {}, {} +Pokemon.frontPic = function(_, _, shiny) return { image = shiny and shinyImage or normalImage } end +local dexOpts, answer, namingOpts, gave +Dex.showCaughtMon = function(_, opts) dexOpts = opts end +Ui.askYesNo = function(_, cb) answer = cb end +package.loaded["src.ui.game3.naming"] = { + open = function(opts) namingOpts = opts end, + isOpen = function() return namingOpts ~= nil end, + monTitle = function(name) return name end, +} +package.loaded["src.core.game3.battle.catching"] = { + givePending = function(_, res) gave = res.mon; res.pending = nil end, +} +local Storage = require("src.core.game3.storage") +Storage.pcTransferMessage = function() return "transfer" end +local function begin(version, first, location) + dexOpts, answer, namingOpts, gave = nil, nil, nil, nil + Ui.reset({ headless = false }) + local mon = { species = 290, name = "WURMPLE", personality = 0x10001, otId = 123, otSecretId = 0 } + local enemyMon = { species = 290, name = "WURMPLE", personality = 0x10001, otId = 0, otSecretId = 0 } + local session = { version = version, party = {}, flags = {}, vars = {} } + package.loaded["src.core.game3.runtime"] = { getSession = function() return session end } + Battle._active, Battle._headless = true, false + Battle._st = { session = session, enemy = { mon = enemyMon } } + local res = { mon = mon, firstTimeCaught = first, location = location or "party", pending = location == "pc" } + Battle.startPostCatchFlow(res) + return mon, res +end +local function leaveDex() + local sprite = { img = normalImage, x = 92, y = 80, x2 = 0, y2 = 0 } + local s = { pal = Pal.new(), caught = { dexNum = 265, personality = dexOpts.personality, + otId = dexOpts.otId, otSecretId = dexOpts.otSecretId, shiny = dexOpts.shiny, + mon = sprite, onDone = dexOpts.onDone } } + Dex.Host._s = s + Dex.tasks.caughtExit(s) + T.eq(Dex.Host._s, nil, "dex fullscreen state released") + T.check(Ui._caughtDexScene and Ui._caughtDexScene.sprite == sprite, "same caught sprite retained across callback") + T.eq(sprite.img, shinyImage, "caught exit reloads shiny sprite palette from mon identity") + T.eq(Battle._phase, "catch_dex_return", "background return fade owns phase before question") + T.eq(answer, nil, "nickname input waits for BG return fade") + T.eq(Ui._caughtDexScene and Ui._caughtDexScene.pal.fade.mask, 0xFFFF, "Emerald fades BG palettes only") + T.eq(Ui._caughtDexScene and Ui._caughtDexScene.pal.slots[16].y, 0, "Emerald retained OBJ palette remains visible") + Battle._headless = true + for _ = 1, 60 do Battle.update(1 / 60, {}) end + Battle._headless = false + T.eq(Battle._phase, "catch_nickname_prompt", "battle dispatch reaches nickname question after fade") + T.eq(sprite.x, 120, "retained sprite reaches Emerald x120") + T.eq(sprite.y, 80, "retained sprite reaches Emerald y80") + T.eq(Ui._caughtDexScene and Ui._caughtDexScene.personality, 0x10001, "handoff preserves personality") + T.eq(Ui._caughtDexScene and Ui._caughtDexScene.otId, 0, "dex retains target OT instead of newly assigned caught OT") + return sprite +end +local mon = begin("emerald", true) +local sprite = leaveDex() +local drewBg, drewPanel, draws, normalBattle = 0, 0, {}, 0 +Chrome.drawPostDexBg = function() drewBg = drewBg + 1 end +Chrome.drawPanel = function(mode) if mode == "none" then drewPanel = drewPanel + 1 end end +Bg.sheetKey = function() return "grass" end +Bg.draw = function() normalBattle = normalBattle + 1 end +love.graphics.draw = function(img, x, y) draws[#draws + 1] = { img, x, y } end +Ui.draw() +T.eq(drewBg, 1, "question draws ROM post-dex BG3 screen") +T.eq(drewPanel, 1, "question draws battle textbox") +T.eq(normalBattle, 0, "question does not draw normal battlefield") +T.eq(draws[1] and draws[1][1], shinyImage, "question draws retained mon rather than captured battler") +T.eq(draws[1] and draws[1][2], 120, "question composite uses Emerald center") +answer(true) +T.eq(Ui._caughtDexScene, nil, "Yes releases retained presentation before fullscreen naming") +T.eq(namingOpts.species, 290, "Yes names caught species") +T.eq(namingOpts.personality, 0x10001, "Yes passes caught personality") +namingOpts.onDone("BUG") +T.eq(mon.nickname, "BUG", "naming completion writes caught mon nickname") +T.eq(Battle._phase, "ending", "party nickname completion ends capture") +begin("emerald", true) +leaveDex() +answer(false) +T.eq(Ui._caughtDexScene, nil, "No clears retained presentation") +T.eq(Battle._phase, "ending", "No completes party capture") +local pcMon = begin("emerald", true, "pc") +leaveDex() +answer(true) +T.eq(namingOpts.sentToPc, true, "PC capture preserves naming destination") +namingOpts.onDone("PCBUG") +T.eq(gave, pcMon, "PC completion gives the same caught mon") +T.eq(pcMon.nickname, "PCBUG", "PC destination retains nickname") +local noPcMon = begin("emerald", true, "pc") +leaveDex() +answer(false) +T.eq(gave, noPcMon, "PC No gives the same caught mon") +T.eq(Battle._phase, "catch_pc_msg", "PC No preserves transfer message phase") +local canceledMon = begin("emerald", true) +leaveDex() +answer(true) +local staleName = namingOpts.onDone +Battle.reset() +staleName("STALE") +T.eq(canceledMon.nickname, nil, "reset ignores stale naming completion") +begin("emerald", true) +local stale = dexOpts.onDone +Battle.reset() +stale({ sprite = sprite }) +T.eq(Ui._caughtDexScene, nil, "reset rejects a stale dex callback") +T.eq(Battle._rseDex, nil, "reset clears dex ownership") +begin("emerald", true) +leaveDex() +Battle._st = nil +Battle.abort("run") +T.eq(Ui._caughtDexScene, nil, "abort clears retained presentation") +answer(true) +T.eq(namingOpts, nil, "aborted question cannot open naming") +begin("emerald", false) +T.eq(dexOpts, nil, "repeat capture skips dex") +T.eq(Ui._caughtDexScene, nil, "repeat capture keeps ordinary battle scene") +T.eq(Battle._phase, "catch_nickname_prompt", "repeat capture directly asks nickname") +answer(false) +for _, version in ipairs({ "emerald", "firered", "leafgreen" }) do + local registration + package.loaded["src.ui.game3.pokedex"] = { showRegistration = function(_, opts) registration = opts end } + for _, first in ipairs({ true, false }) do + for _, yes in ipairs({ true, false }) do + for _, location in ipairs({ "party", "pc" }) do + registration = nil + local label = version .. " " .. tostring(first) .. " " .. tostring(yes) .. " " .. location + local caught = begin(version, first, location) + if first then + if version == "emerald" then leaveDex() else + T.check(registration ~= nil, label .. " registers new capture") + registration.onDone() + local c = Ui._caughtDexScene + T.check(c and c.sprite.img == shinyImage, label .. " creates target shiny front sprite") + T.eq(c and c.otId, 0, label .. " keeps enemy OT") + T.eq(c and c.personality, 0x10001, label .. " keeps enemy personality") + T.eq(c and c.pal.fade.mask, 0x1FFFF, label .. " BG and OBJ0 fade mask") + if c then c.pal:updateFade() end + T.eq(c and c.pal.slots[16].y, 16, label .. " caught OBJ0 fades from black") + T.eq(answer, nil, label .. " input waits for return fade") + Battle._headless = true + for _ = 1, 60 do Battle.update(1 / 60, {}) end + Battle._headless = false + end + local c = Ui._caughtDexScene + T.eq(c and c.sprite.x, 120, label .. " caught center X") + T.eq(c and c.sprite.y, version == "emerald" and 80 or 64, label .. " family center Y") + drewBg, normalBattle, draws = 0, 0, {} + Ui.draw() + T.eq(drewBg, 1, label .. " draws ROM second screen") + T.eq(normalBattle, 0, label .. " excludes ordinary battlefield") + T.eq(draws[1] and draws[1][1], shinyImage, label .. " draws caught sprite") + else + T.eq(registration, nil, label .. " repeat skips FRLG registration") + T.eq(dexOpts, nil, label .. " repeat skips RSE registration") + T.eq(Ui._caughtDexScene, nil, label .. " repeat retains ordinary scene") + end + T.eq(Battle._phase, "catch_nickname_prompt", label .. " reaches question") + answer(yes) + if yes then + T.eq(Ui._caughtDexScene, nil, label .. " naming resets sprites") + T.eq(namingOpts.sentToPc, location == "pc", label .. " naming destination") + namingOpts.onDone("NAMED") + T.eq(caught.nickname, "NAMED", label .. " stores nickname") + T.eq(Ui._caughtDexScene, nil, label .. " naming return does not resurrect scene") + elseif location == "pc" then + T.eq(Ui._caughtDexScene ~= nil, first, label .. " PC No keeps first-capture scene") + T.eq(Battle._phase, "catch_pc_msg", label .. " PC message phase") + if first then + drewBg, normalBattle, draws = 0, 0, {} + Ui.draw() + T.eq(drewBg, 1, label .. " PC message draws right screen") + T.eq(normalBattle, 0, label .. " PC message excludes ordinary battlefield") + T.eq(draws[1] and draws[1][3], version == "emerald" and 80 or 64, label .. " PC message center Y") + end + else T.eq(Ui._caughtDexScene, nil, label .. " party terminal clears scene") end + if location == "pc" then T.eq(gave, caught, label .. " gives same pending mon") end + local staleAnswer = answer + Battle.reset() + T.eq(Ui._caughtDexScene, nil, label .. " reset clears scene") + namingOpts = nil + staleAnswer(true) + T.eq(namingOpts, nil, label .. " stale question cannot reopen naming") + end + end + end + begin(version, true) + local staleRegistration = version == "emerald" and dexOpts.onDone or registration.onDone + Battle.reset() + staleRegistration({ sprite = sprite }) + T.eq(Ui._caughtDexScene, nil, version .. " stale registration cannot restore scene") + begin(version, true) + if version == "emerald" then leaveDex() else + registration.onDone() + Battle._headless = true + for _ = 1, 60 do Battle.update(1 / 60, {}) end + Battle._headless = false + end + local abortedAnswer = answer + Battle._st = nil + Battle.abort("run") + T.eq(Ui._caughtDexScene, nil, version .. " abort clears scene") + abortedAnswer(true) + T.eq(namingOpts, nil, version .. " aborted question stays closed") +end +T.finish("game3_em_catch_prompt_2637") diff --git a/tests/engine/game3_frontier_obedience_2638.lua b/tests/engine/game3_frontier_obedience_2638.lua new file mode 100644 index 00000000..6a3fe7c5 --- /dev/null +++ b/tests/engine/game3_frontier_obedience_2638.lua @@ -0,0 +1,65 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local GameVersion = require("src.core.GameVersion") +GameVersion.set("emerald") +require("src.import.gba.versions").select("emerald") +require("tests.game3_cache").mountOrSkip("game3_frontier_obedience_2638", "pokemon/battle_moves.lua") +require("tests.fixture_data.game3_items").install() + +local Battle = require("src.core.game3.battle.init") +local Engine = require("src.core.game3.battle.engine") +local Adapter = require("src.core.game3.battle.adapter") +local BattleProfile = require("src.core.game3.battle.profile") +local Space = require("src.core.game3.scripting.space") +local Flags = require("src.core.game3.scripting.flags") +local T = require("tests.harness") + +local function mon(species, otId, fateful) + return { species = species, level = 30, hp = 200, maxHp = 200, + attack = 80, defense = 80, spAtk = 80, spDef = 80, speed = 80, ability = 0, + nickname = "RENTAL", otId = otId, otName = otId == 1 and "PLAYER" or "RENTAL", + fatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } } +end + +local function run(o) + GameVersion.set(o.version or "emerald") + local user, foe = mon(o.species or 1, o.own and 1 or 2, o.fateful), mon(1, 3) + local session = { version = o.version or "emerald", name = "PLAYER", trainerId = 1, party = { user } } + Battle.start({ headless = false, autoFight = false, session = session, playerParty = session.party, + foe = { species = 1, level = 30, mon = foe }, wild = o.wild == true, frontier = o.frontier, + rng = function(lo, hi) if lo == 1 and hi == 100 then return 1 end return hi end }) + local st = assert(Battle.getState()) + st.badges, st.playerTrainerId, st.playerOtName = {}, 1, "PLAYER" + st.link = o.link == true + if o.badge then + st.badges = nil + Space.store = Flags.newStore() + Flags.setFlag(Space.store, nil, BattleProfile.of(st).badgeFlags[o.badge], true) + T.check(Engine.hasBadge(st, o.badge), o.label .. " canonical Emerald badge flag is read") + end + T.eq(st.kinds.frontier == true, o.frontier == true, o.label .. " Battle.start propagates Frontier kind") + T.check(o.own or Engine.isTradedMon(st, st.player.mon), o.label .. " foreign rental OT is preserved") + local hp, pp = st.enemy.mon.hp, st.player.mon.pp[1] + local out = {} + Engine.resolveMove(st.player, st.enemy, 33, 1, Adapter.new(st), st, out) + T.eq(out._anim.cancelled ~= true, o.obey, o.label .. " real move caller follows obedience gate") + T.eq(st.enemy.mon.hp < hp, o.obey, o.label .. " selected move damages foe only when obedient") + T.eq(st.player.mon.pp[1] < pp, o.obey, o.label .. " selected move consumes PP only when obedient") + Battle.reset() +end + +-- pokeemerald/src/battle_util.c:3909 +run({ frontier = true, obey = true, label = "Emerald Frontier rental" }) +run({ obey = false, label = "Emerald ordinary traded trainer" }) +run({ wild = true, obey = false, label = "Emerald ordinary traded wild" }) +run({ frontier = true, species = 151, fateful = false, own = true, obey = false, label = "Illegal Mew Frontier" }) +run({ frontier = true, species = 410, fateful = false, own = true, obey = false, label = "Illegal Deoxys Frontier" }) +run({ frontier = true, species = 151, fateful = true, obey = true, label = "Legal Mew Frontier" }) +run({ frontier = true, species = 410, fateful = true, obey = true, label = "Legal Deoxys Frontier" }) +run({ version = "firered", frontier = true, obey = false, label = "FireRed Frontier flag control" }) +run({ version = "leafgreen", frontier = true, obey = false, label = "LeafGreen Frontier flag control" }) +run({ own = true, obey = true, label = "Emerald own OT" }) +run({ link = true, obey = true, label = "Emerald link" }) +run({ badge = 2, obey = true, label = "Emerald badge two level 30" }) +run({ badge = 8, obey = true, label = "Emerald badge eight" }) +T.finish("game3_frontier_obedience_2638") diff --git a/tests/engine/game3_import_worker_policy_2636.lua b/tests/engine/game3_import_worker_policy_2636.lua new file mode 100644 index 00000000..fd21054a --- /dev/null +++ b/tests/engine/game3_import_worker_policy_2636.lua @@ -0,0 +1,94 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Extractor = require("src.import.RomExtractorGen3") +local CacheFs = require("src.import.CacheFs") +local plans = { + frlg = require("src.import.gba.plans.frlg"), + rse = require("src.import.gba.plans.rse"), +} +local getenv = os.getenv +local flags = { "HANDHELD", "POKEPORT_HANDHELD", "PORTMASTER" } +local cases = { + { "Linux desktop", "Linux", 8, {}, 6 }, + { "Linux four cores", "Linux", 4, {}, 3 }, + { "single core", "Linux", 1, {}, 1 }, + { "two cores", "Linux", 2, {}, 1 }, + { "unknown OS", "", 8, {}, 6 }, + { "Android", "Android", 8, {}, 2 }, + { "iOS", "iOS", 8, {}, 2 }, + { "NX", "NX", 8, {}, 2 }, + { "disabled flags", "Linux", 8, { HANDHELD = "0", POKEPORT_HANDHELD = "0", PORTMASTER = "0" }, 6 }, + { "override two", "Linux", 8, { HANDHELD = "1", POKEPORT_EXTRACT_WORKERS = "2" }, 2 }, + { "override one", "Linux", 8, { PORTMASTER = "1", POKEPORT_EXTRACT_WORKERS = "1" }, 1 }, + { "override floor", "Linux", 8, { POKEPORT_HANDHELD = "1", POKEPORT_EXTRACT_WORKERS = "2.9" }, 2 }, + { "override cap", "Linux", 8, { PORTMASTER = "1", POKEPORT_EXTRACT_WORKERS = "100" }, 100 }, + { "invalid override", "Linux", 8, { PORTMASTER = "1", POKEPORT_EXTRACT_WORKERS = "bad" }, 1 }, + { "zero override", "Linux", 8, { PORTMASTER = "1", POKEPORT_EXTRACT_WORKERS = "0" }, 1 }, +} +for _, flag in ipairs(flags) do + for _, platform in ipairs({ "Linux", "OS X" }) do + cases[#cases + 1] = { platform .. " " .. flag, platform, 8, { [flag] = "1" }, 1 } + end +end + +local function run(plan, case, failure) + local live, maximum, starts, completions, pending, writes = 0, 0, {}, {}, {}, {} + local env = case[4] + os.getenv = function(key) + if key == "POKEPORT_NO_THREAD" or key == "POKEPORT_EXTRACT_WORKERS" then return env[key] end + for _, flag in ipairs(flags) do if key == flag then return env[key] end end + return getenv(key) + end + CacheFs.exists = function() return false end + CacheFs.write = function(path, bytes) writes[path] = bytes; return true end + _G.love = { + system = { getOS = function() return case[2] end, getProcessorCount = function() return case[3] end }, + timer = { getTime = function() return 10 end, sleep = function() end }, + thread = { + getChannel = function() + return { pop = function() + local task = table.remove(pending, 1) + if not task then return nil end + live = live - 1 + completions[task] = (completions[task] or 0) + 1 + return { type = "done", task = task, ok = task ~= failure, error = task == failure and "fixture failure" or nil } + end } + end, + newThread = function() + return { + start = function(_, task, _, rom, sha1) + T.eq(rom, "ROM fixture", "scheduler transfers unchanged ROM") + T.eq(sha1, "fixture sha1", "scheduler transfers SHA") + live = live + 1 + maximum = math.max(maximum, live) + starts[task] = (starts[task] or 0) + 1 + pending[#pending + 1] = task + end, + getError = function() return nil end, + } + end, + }, + } + local ex = setmetatable({ romData = "ROM fixture", plan = plan }, Extractor) + local ok, err = ex:runParallel("fixture sha1") + local label = case[1] .. " " .. plan.id + T.eq(ok, failure == nil, label .. " scheduler result") + T.eq(maximum, math.min(#plan.tasks, case[5]), label .. " maximum concurrent nested workers") + T.eq(live, 0, label .. " completes all workers") + for _, task in ipairs(plan.tasks) do + T.eq(starts[task.id], 1, label .. " starts " .. task.id .. " exactly once") + T.eq(completions[task.id], 1, label .. " completes " .. task.id .. " exactly once") + end + if failure then + T.check(tostring(err):find("fixture failure", 1, true) ~= nil, label .. " propagates worker failure") + T.eq(next(writes), nil, label .. " failed tasks cannot publish success output") + else + T.check(next(writes) ~= nil, label .. " successful pool publishes maps inventory") + end +end +for _, plan in pairs(plans) do + for _, case in ipairs(cases) do run(plan, case) end + run(plan, { "failed handheld task", "Linux", 8, { PORTMASTER = "1" }, 1 }, plan.tasks[1].id) +end +os.getenv = getenv +T.finish("game3_import_worker_policy_2636") diff --git a/tests/engine/game3_post_dex_background_2637.lua b/tests/engine/game3_post_dex_background_2637.lua new file mode 100644 index 00000000..6e85b3bc --- /dev/null +++ b/tests/engine/game3_post_dex_background_2637.lua @@ -0,0 +1,207 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Extract = require("src.import.gba.battle_chrome_extract") +local Lz77 = require("src.import.gba.lz77") +local V = require("src.import.gba.games.emerald") +local cfg = V.BATTLE_UI +local originals = { decompress = Lz77.decompress, terrainTable = Extract.requireTerrainTable } +local data, written = {}, {} +local terrainRows = {} +local gfx = string.rep("\x11", 32) .. string.rep("\x22", 32) +local palette = "\0\0\x1f\0\xe0\3" .. string.rep("\0", 26) +local map = string.rep("\0\x20", 1024) .. string.rep("\1\x20", 1024) +local function terrain(tr) + data[tr.tiles], data[tr.pal], data[tr.tilemap] = gfx, palette, map +end +for id = 0, cfg.terrain_count - 1 do + local tr = { tiles = 100 + id * 3, pal = 101 + id * 3, tilemap = 102 + id * 3 } + terrain(tr) + terrainRows[#terrainRows + 1] = { key = Extract.TERRAIN_KEYS[id], id = id, cfg = tr } +end +for _, scene in ipairs(cfg.scenes) do terrain(scene.cfg) end +Extract.requireTerrainTable = function() return terrainRows end +Lz77.decompress = function(_, offset) return data[offset] or string.rep("\0", 32) end +local rom = { get = function() return 0 end, u16 = function() return 0 end } +local cache = { write = function(_, path, bytes) written[path] = bytes; return true end } +local root = "fixture/pokemon/battle/" +local function bake() + Extract.runRse(rom, cache, { cacheRoot = "fixture" }, cfg) + return assert(loadstring(written[root .. "manifest.lua"]))() +end +local manifest = bake() +local green = string.char(0, 255, 0, 255) +local red = string.char(255, 0, 0, 255) +for key, info in pairs(manifest.terrains) do + T.eq(info.postDexFile, "terrain_" .. key .. "_post_dex.rgba", key .. " manifest retains right screen") + local pixels = written[root .. (info.postDexFile or "missing")] + T.eq(pixels and #pixels, 256 * 256 * 4, key .. " right screen dimensions") + T.eq(pixels and pixels:sub(1, 4), green, key .. " right screen uses second map block and palette bank2") + T.eq(written[root .. info.file]:sub(1, 4), red, key .. " ordinary left screen unchanged") +end +local tr = terrainRows[1].cfg +local savedMap = data[tr.tilemap] +data[tr.tilemap] = savedMap:sub(1, 2048) +T.raises(bake, "post-dex terrain map", "truncated ROM map cannot invent right screen") +data[tr.tilemap] = savedMap +local Versions = require("src.import.gba.versions") +local frlgManifests = {} +Extract.requireTerrainTable = originals.terrainTable +local profiles = { + { "fr10", "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" }, + { "fr11", "dd5945db9b930750cb39d00c84da8571feebf417" }, + { "lg10", "574fa542ffebb14be69902d1d36f1ec0a4afd71e" }, + { "lg11", "7862c67bdecbe21d1d69ce082ce34327e1c6ed5e" }, +} +for _, edition in ipairs(profiles) do + Versions.select(edition[2]) + local frCfg, bytes, rows = Versions.BATTLE_UI, {}, {} + local tableBase = frCfg.terrain_table or Versions.address(Extract.TERRAIN_TABLE) + local function pointer(off, value) + for i = 0, 3 do bytes[off + i] = math.floor((value + 0x08000000) / 256 ^ i) % 256 end + end + for id = 0, 19 do + local row = id == 0 and frCfg.terrain_grass + or { tiles = 1000 + id * 3, pal = 1001 + id * 3, tilemap = 1002 + id * 3 } + terrain(row) + pointer(tableBase + id * 20, row.tiles) + pointer(tableBase + id * 20 + 4, row.tilemap) + pointer(tableBase + id * 20 + 16, row.pal) + rows[#rows + 1] = row + end + local frRom = { get = function(_, off) return bytes[off] or 0 end } + local function bakeFr() + Extract.run(frRom, cache, { cacheRoot = "fixture" }) + return assert(loadstring(written[root .. "manifest.lua"]))() + end + local decoded = Extract.requireTerrainTable(function(off) return frRom:get(off) end, frCfg) + T.eq(#decoded, 20, edition[1] .. " actual ROM terrain table decodes20 rows") + local frManifest, count = bakeFr(), 0 + frlgManifests[edition[1]] = frManifest + for key, info in pairs(frManifest.terrains) do + count = count + 1 + T.eq(info.postDexFile, "terrain_" .. key .. "_post_dex.rgba", edition[1] .. " " .. key .. " manifest retains right screen") + local pixels = written[root .. (info.postDexFile or "missing")] + T.eq(pixels and #pixels, 256 * 256 * 4, edition[1] .. " " .. key .. " right screen dimensions") + T.eq(pixels and pixels:sub(1, 4), green, edition[1] .. " " .. key .. " right screen uses second map block and palette bank2") + T.eq(written[root .. info.file]:sub(1, 4), red, edition[1] .. " " .. key .. " ordinary left screen unchanged") + end + T.eq(count, 20, edition[1] .. " all20 terrains preserved") + data[rows[1].tilemap] = map:sub(1, 2048) + T.raises(bakeFr, "post-dex terrain map", edition[1] .. " truncated ROM map cannot invent right screen") + data[rows[1].tilemap] = map +end +Versions.select("firered") +Lz77.decompress, Extract.requireTerrainTable = originals.decompress, originals.terrainTable + +local draws, imageReads = {}, {} +_G.love = require("tests.love_stub") +love.image.newImageData = function(w, h, _, bytes) return { w = w, h = h, bytes = bytes } end +love.graphics.newImage = function(id) + return { data = id, setFilter = function() end, getDimensions = function() return id.w, id.h end } +end +love.graphics.newQuad = function(...) return { ... } end +love.graphics.draw = function(...) draws[#draws + 1] = { ... } end +local Chrome = require("src.ui.game3.battle_chrome") +local function install(m, bytes) + draws = {} + Chrome.install({ read = function(_, path) + if path:match("/manifest.lua$") then return m end + local file = path:match("/([^/]+)$") + imageReads[file] = (imageReads[file] or 0) + 1 + return bytes[file] + end }) +end +local rgba = string.rep(green, 256 * 256) +local assets = { ["terrain_grass.rgba"] = string.rep(red, 256 * 256), ["terrain_grass_post_dex.rgba"] = rgba } +local metadata = 'return { layout="rse", terrains={ grass={ file="terrain_grass.rgba", postDexFile="terrain_grass_post_dex.rgba", w=256, h=256 } } }' +install(metadata, assets) +T.check(type(Chrome.drawPostDexBg) == "function", "renderer exposes post-dex ROM scene") +if Chrome.drawPostDexBg then + T.eq(Chrome.drawPostDexBg("grass"), true, "draw succeeds with required ROM data") + T.eq(#draws, 1, "post-dex draw has one background") + T.check(draws[1][1].data.bytes == rgba, "draw selects right screen image") + T.same(draws[1][2], { 0, 0, 240, 112, 256, 256 }, "post-dex crop protects lower textbox") + T.eq(draws[1][3], 0, "draw starts at screen x0") + T.eq(draws[1][4], 0, "draw starts at screen y0") + T.raises(function() Chrome.drawPostDexBg("missing") end, "post-dex", "unknown RSE terrain cannot substitute grass") + assets["terrain_grass_post_dex.rgba"] = nil + install(metadata, assets) + T.raises(function() Chrome.drawPostDexBg("grass") end, "post-dex", "missing asset cannot substitute ordinary background") + install('return { layout="rse", terrains={ grass={file="terrain_grass.rgba",w=256,h=256} } }', assets) + T.raises(function() Chrome.drawPostDexBg("grass") end, "post-dex", "old manifest cannot degrade post-dex scene") + assets["terrain_grass_post_dex.rgba"] = rgba + for _, family in ipairs({ "firered", "leafgreen" }) do + install('return { terrains={grass={file="terrain_grass.rgba",postDexFile="terrain_grass_post_dex.rgba",w=256,h=256}} }', assets) + T.eq(Chrome.drawPostDexBg("grass"), true, family .. " draws its required post-dex ROM image") + T.eq(#draws, 1, family .. " post-dex API draws one background") + local draw = draws[1] + T.check(draw and draw[1].data.bytes == rgba, family .. " draw selects second screen") + T.same(draw and draw[2], { 0, 0, 240, 112, 256, 256 }, family .. " crop protects textbox") + T.raises(function() Chrome.drawPostDexBg("missing") end, "post-dex", family .. " cannot substitute grass for unknown terrain") + assets["terrain_grass_post_dex.rgba"] = nil + install('return { terrains={grass={file="terrain_grass.rgba",postDexFile="terrain_grass_post_dex.rgba",w=256,h=256}} }', assets) + T.raises(function() Chrome.drawPostDexBg("grass") end, "post-dex", family .. " missing asset cannot substitute ordinary background") + assets["terrain_grass_post_dex.rgba"] = rgba + install('return { terrains={grass={file="terrain_grass.rgba",w=256,h=256}} }', assets) + T.raises(function() Chrome.drawPostDexBg("grass") end, "post-dex", family .. " old manifest cannot degrade post-dex scene") + end +end +local Contract = require("src.import.CacheContract") +local required = Contract.requiredFiles("emerald") +local complete, requiredSet = {}, {} +for _, path in ipairs(required) do complete["emerald/" .. path] = "fixture"; requiredSet[path] = true end +local fs = { prefix = "" } +fs.exists = function(path) return complete[fs.prefix .. path] ~= nil end +T.eq(Contract.allRequiredFilesExist("emerald", fs), true, "complete Emerald contract is valid") +for key, info in pairs(manifest.terrains) do + local path = "data/generated/gba/pokemon/battle/" .. (info.postDexFile or "missing") + T.check(requiredSet[path], key .. " post-dex output is required") + local saved = complete["emerald/" .. path] + complete["emerald/" .. path] = nil + local ok, missing = Contract.allRequiredFilesExist("emerald", fs) + T.eq(ok, false, key .. " missing post-dex data blocks readiness") + T.eq(missing, path, key .. " contract identifies missing post-dex output") + complete["emerald/" .. path] = saved +end +local GameVersion = require("src.core.GameVersion") +for _, profile in ipairs(profiles) do + local version = GameVersion.forSha1(profile[2]) + Versions.select(profile[2]) + T.eq(Versions.CACHE_VERSION, 128, profile[1] .. " active edition retains shared128 cache stamp") + local files, set = {}, {} + for _, path in ipairs(Contract.requiredFiles(version)) do + files[version .. "/" .. path] = "fixture" + set[path] = true + end + local meta = version .. "/data/generated/gba/meta.json" + files[version .. "/" .. Contract.MARKER_PATH] = Contract.markerFor(version, profile[2]) + local profileFs = { prefix = "" } + profileFs.exists = function(path) return files[profileFs.prefix .. path] ~= nil end + profileFs.read = function(path) return files[profileFs.prefix .. path] end + files[meta] = '{"cache_version":127}' + T.eq(Contract.isReady(version, profileFs), false, profile[1] .. " old127 stamp requires reimport") + files[meta] = '{"cache_version":128}' + T.eq(Contract.isReady(version, profileFs), true, profile[1] .. " complete128 cache is ready") + local count = 0 + for key, info in pairs(frlgManifests[profile[1]].terrains) do + count = count + 1 + local path = "data/generated/gba/pokemon/battle/" .. info.postDexFile + T.check(set[path], profile[1] .. " " .. key .. " post-dex output is required") + local saved = files[version .. "/" .. path] + files[version .. "/" .. path] = nil + T.eq(Contract.isReady(version, profileFs), false, profile[1] .. " " .. key .. " missing post-dex data blocks readiness") + local ok, missing = Contract.allRequiredFilesExist(version, profileFs) + T.eq(ok, false, profile[1] .. " " .. key .. " contract rejects missing post-dex output") + T.eq(missing, path, profile[1] .. " " .. key .. " contract identifies missing file") + files[version .. "/" .. path] = saved + end + T.eq(count, 20, profile[1] .. " all20 required outputs tested") + for _, key in ipairs({ "frontier", "groudon", "kyogre", "rayquaza", "magma", "aqua", "sidney", "phoebe", "glacia", "drake" }) do + T.eq(set["data/generated/gba/pokemon/battle/terrain_" .. key .. "_post_dex.rgba"], nil, + profile[1] .. " does not require Emerald-only " .. key) + end +end +Versions.select("firered") +T.eq(V.CACHE_VERSION, 14, "Emerald cache version advanced once to14") +T.eq(require("src.import.gba.versions_frlg").CACHE_VERSION, 128, "FRLG cache version advanced once to128") +T.finish("game3_post_dex_background_2637") diff --git a/tests/engine/game3_tent_save_reload_2638.lua b/tests/engine/game3_tent_save_reload_2638.lua new file mode 100644 index 00000000..9e90b7fc --- /dev/null +++ b/tests/engine/game3_tent_save_reload_2638.lua @@ -0,0 +1 @@ +dofile("tests/emerald_tent_save_reload_2638_test.lua") diff --git a/tests/engine/save_editor_custom_maps.lua b/tests/engine/save_editor_custom_maps.lua new file mode 100644 index 00000000..395432ac --- /dev/null +++ b/tests/engine/save_editor_custom_maps.lua @@ -0,0 +1,153 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" + +local Fixture = {} + +function Fixture.load(version, opts) + opts = opts or {} + local GameVersion = require("src.core.GameVersion") + GameVersion.set(version) + local generation = GameVersion.generation(version) + local Merge = require("src.mods.Merge") + local Gen = require("Gen") + local baseId = generation == 3 and "FR_EDITOR_BASE" or "EDITOR_BASE" + local newId = generation == 3 and "FR_EDITOR_NEW" or "EDITOR_NEW" + local lazyId = "FR_EDITOR_LAZY" + local missingId = generation == 3 and "FR_EDITOR_MISSING" or "EDITOR_MISSING" + local pair, mid = opts.pair or "editor_fixture", opts.mid or 7 + local atlas = opts.atlas or "mods/editor_map_fixture/atlas.png" + local base = opts.base or { + id = baseId, width = 3, height = 2, tileset = "EDITOR_TILES", blocks = { 1, 1, 1, 1, 1, 1 }, + warps = {}, midLayout = generation == 3 and { + width = 3, height = 2, pair = pair, midAt = function() return 0 end, + } or nil, + } + local data = { maps = { [baseId] = Merge.deepCopy(base) }, tilesets = {} } + if generation == 3 then data.game3Maps = { [baseId] = base } + elseif generation == 2 then Gen.bindGoldData(data) end + local source + if generation == 3 then + source = ([[return function(mod) + local function layout(pair) + return { width = 6, height = 4, pair = pair, midAt = function() return %d end } + end + mod.content.maps:patch(%q, { width = 6, height = 4, midLayout = layout(%q), pair = %q, weather = 2 }) + mod.content.maps:register(%q, { id = %q, width = 6, height = 4, midLayout = layout(%q), pair = %q, warps = {} }) + mod.content.maps:register(%q, { id = %q, width = 6, height = 4, pair = %q, warps = {} }) + mod.content.maps:register(%q, { id = %q, width = 6, height = 4, warps = {} }) + end]]):format(mid, baseId, pair, pair, newId, newId, pair, pair, lazyId, lazyId, pair, missingId, missingId) + else + source = ([[return function(mod) + local row = {}; for i = 1, 16 do row[i] = 0 end + mod.content.tilesets:register("EDITOR_TILES", { + id = "EDITOR_TILES", image = %q, tilesPerRow = 1, blocks = { row, row }, + walkable = { 0 }, collision = { { 0, 0, 0, 0 }, { 0, 0, 0, 0 } }, trueColor = true, + }) + mod.content.maps:patch(%q, { width = 4, height = 3, blocks = { 1,1,1,1,1,1,1,1,1,1,1,1 } }) + mod.content.maps:register(%q, { + id = %q, tileset = "EDITOR_TILES", width = 4, height = 3, + blocks = { 1,1,1,1,1,1,1,1,1,1,1,1 }, warps = {}, objects = {}, signs = {}, + }) + mod.content.tilesets:register("EDITOR_MISSING_TILES", { image = "mods/editor_map_fixture/missing.png", blocks = { row }, walkable = {} }) + mod.content.maps:register(%q, { id = %q, tileset = "EDITOR_MISSING_TILES", width = 2, height = 2, blocks = { 0,0,0,0 }, warps = {} }) + end]]):format(atlas, baseId, newId, newId, missingId, missingId) + end + local files = { + ["mods/editor_map_fixture/manifest.json"] = ([[{"id":"editor_map_fixture","name":"Editor map fixture","version":"1.0.0","api":2,"entry":"main.lua","games":[%q]}]]):format(version), + ["mods/editor_map_fixture/main.lua"] = source, + } + local fs = {} + function fs.read(path) return files[path] end + function fs.write(path, bytes) files[path] = bytes; return true end + function fs.getInfo(path) + if files[path] then return { type = "file" } end + for key in pairs(files) do if key:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end end + end + function fs.load(path) return loadstring(assert(files[path]), "@" .. path) end + function fs.getDirectoryItems(path) + local found, items = {}, {} + for key in pairs(files) do + if key:sub(1, #path + 1) == path .. "/" then + local name = key:sub(#path + 2):match("^[^/]+") + if not found[name] then found[name], items[#items + 1] = true, name end + end + end + table.sort(items) + return items + end + local loader = require("src.mods.Loader").new({ fs = fs, generation = generation, version = version }) + assert(loader:load(data), table.concat(loader.errors, "; ")) + assert(#loader:status().loaded == 1, "fixture mod did not load") + return data, loader, { base = baseId, new = newId, lazy = lazyId, missing = missingId, pair = pair, mid = mid } +end + +if ... == "tests.engine.save_editor_custom_maps" then return Fixture end + +love = require("tests.love_stub") +local T = require("tests.harness") +local Gen = require("Gen") +local Browser = require("MapBrowser") +local State = require("State") +local Kit = require("Kit") +local realImage = love.graphics.newImage +love.graphics.newImage = function(path) + if type(path) == "string" and path:find("missing.png", 1, true) then error("missing fixture image") end + return realImage(path) +end +local function state(data, version, id) + local s = State.new() + s.data, s.version, s.tab, s.mapId = data, version, "map", id + s.save = { version = version, generation = require("src.core.GameVersion").generation(version), player = { map = id, x = 1, y = 1 } } + return s +end +local function draw(s) + Kit.beginFrame(-1, -1, false, 0) + Browser.draw(s, Kit, 0, 0, 1200, 760) + Kit.endFrame() +end + +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local data, loader, ids = Fixture.load(version) + local maps = Gen.maps(data) + local generation = require("src.core.GameVersion").generation(version) + T.eq(maps[ids.base].width, generation == 3 and 6 or 4, version .. " mod patch reaches preview map lookup") + T.check(maps[ids.new] ~= nil, version .. " loaded mod's new map is discoverable") + if generation == 3 then + T.eq(maps[ids.base].weather, 2, version .. " existing mod patch is authoritative") + T.eq(maps[ids.base].midLayout:midAt(0, 0), 7, version .. " mod-supplied layout survives base binding") + end + local missing = state(data, version, ids.missing) + draw(missing) + T.check(type(missing.mapPreviewReason) == "string" and missing.mapPreviewReason:find("unavailable", 1, true), version .. " missing artwork is explained in the actual map panel") + if Browser.preview then + local s = state(data, version, ids.new) + if generation < 3 then + local ok, map, reason = Browser.preview(s) + T.check(ok and map.renderer and not reason, version .. " loaded custom atlas builds a terrain renderer") + T.eq(map.widthCells, 8, version .. " block dimensions use walking cells") + else + local Dataset = require("src.core.game3.dataset") + local Extract = require("src.import.gba.extract_island1") + local root = "editor-custom-map-fixture" + Extract.NATIVE_ROOT = root + Dataset.invalidateManifestCache() + local cells = {}; for i = 1, 24 do cells[i] = { mid = 7, coll = 0, elev = 0 } end + love.filesystem.write(root .. "/layouts/" .. ids.lazy .. ".mid", require("src.import.gba.native_pack").encodeMidLayout({ width = 6, height = 4, cells = cells })) + local original = data.maps[ids.new].midLayout + Browser.preview(s) + T.eq(data.maps[ids.new].midLayout, original, version .. " preview preserves supplied layout identity") + s.mapId = ids.lazy + local ok, map = Browser.preview(s) + T.check(ok and data.maps[ids.lazy].midLayout and data.maps[ids.lazy].midLayout:midAt(2, 2) == 7, version .. " gameplay API lazily attaches a mod-added native MID file") + T.check(map:inBounds(5, 3) and not map:inBounds(6, 3), version .. " native grid preserves placement bounds") + s.mapId = ids.missing + local _, grid, reason = Browser.preview(s) + T.check(grid:inBounds(5, 3) and reason:find("missing native map layout", 1, true), version .. " missing layout retains the coordinate grid") + data.maps[ids.missing].midLayout = { midAt = function() return 0 end } + local _, _, pairReason = Browser.preview(s) + T.check(pairReason:find("missing native tileset pair", 1, true), version .. " missing pair is distinguished from missing layout") + end + end + require("src.mods.Runtime").reset() +end + +T.finish() diff --git a/tests/engine/text_cont_single_row_bug2634.lua b/tests/engine/text_cont_single_row_bug2634.lua new file mode 100644 index 00000000..1ee48ee2 --- /dev/null +++ b/tests/engine/text_cont_single_row_bug2634.lua @@ -0,0 +1,142 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local Font = require("src.render.Font") +Font.load({ font = require("tests.fixture_data.font") }) +local TextBox = require("src.render.TextBox") +local Version = require("src.core.GameVersion") +local Timing = require("src.core.Timing") +package.loaded["src.core.Sound"] = { play = function() end, playPress = function() end } + +local function make(text, opts, save) + local pressed, pops, calls = false, 0, 0 + local game = { + data = {}, save = save or { options = { textSpeed = 1 } }, + input = { wasPressed = function(_, key) return pressed == key end, + isDown = function() return false end }, + stack = { pop = function() pops = pops + 1 end }, + } + local box = TextBox.new(game, text, function() calls = calls + 1 end, opts) + return box, function(key) + pressed = key or false + box:update(1 / 60) + pressed = false + end, function() return pops, calls end +end + +local function prompt(box, tick) + for _ = 1, 200 do + if box.waiting or box.done then return true end + tick() + end + return false +end + +local function advance(box, tick, key) + for _ = 1, Timing.TEXT_PRE_ADVANCE do tick() end + tick(key or "a") +end + +local function rows(box, expected, label) + T.eq(#box.shown, #expected, label .. " physical row count") + for i, text in ipairs(expected) do + T.same(box.shown[i], Font.encode(text), label .. " glyph row " .. i) + end + T.same(box:visibleText(), expected, label .. " visibleText") + local drawCode, drawBox = Font.drawCode, Font.drawBox + local drawn = {} + Font.drawCode = function(code, x, y) + drawn[#drawn + 1] = { code = code, x = x, y = y } + end + Font.drawBox = function() end + local blink = box.blink + box.blink = 30 + box:draw() + box.blink = blink + Font.drawCode, Font.drawBox = drawCode, drawBox + local want = {} + for i, text in ipairs(expected) do + local x = box.textX + for _, code in ipairs(Font.encode(text)) do + want[#want + 1] = { code = code, x = x, y = i == 1 and box.line1Y or box.line2Y } + x = x + Font.advanceOf(code) + end + end + T.same(drawn, want, label .. " drawn glyph positions") +end + +-- pokered/home/text.asm:262 +for _, edition in ipairs({ "red", "blue", "yellow" }) do + Version.set(edition) + local box, tick, counts = make("A\vB{DONE}") + T.check(prompt(box, tick), edition .. " reaches cont") + T.check(box.waiting and box.contAdvance, edition .. " waits before scrolling") + rows(box, { "A" }, edition .. " before cont") + tick("a") + T.check(box.waiting, edition .. " protected wait rejects early A") + advance(box, tick, "b") + T.eq(box.holdFrames, Timing.TEXT_SCROLL_PAIR, edition .. " scroll timing") + T.same(box.shown, { {}, {} }, edition .. " old upper row cleared before typing") + for _ = 1, Timing.TEXT_SCROLL_PAIR do tick() end + T.eq(box.charIndex, 0, edition .. " scroll hold suppresses typing") + T.check(prompt(box, tick), edition .. " completes after one cont press") + T.check(box.done and not box.waiting, edition .. " has no extra cont prompt") + rows(box, { "", "B" }, edition .. " single-row cont") + tick("a") + local pops, calls = counts() + T.eq(pops, 1, edition .. " closes once") + T.eq(calls, 1, edition .. " resumes callback once") + rows(make("A\vB{DONE}", { instant = true }), { "", "B" }, edition .. " instant cont") + local plain, plainTick = make("A\nB{DONE}") + T.check(prompt(plain, plainTick) and plain.done, edition .. " ordinary line has no cont wait") + rows(plain, { "A", "B" }, edition .. " ordinary line") +end + +Version.set("red") +for _, case in ipairs({ + { "A\nB\vC", { "B", "C" }, "two-row cont" }, + { "\vB", { "", "B" }, "leading cont" }, + { "A\v\vB", { "", "B" }, "empty cont" }, + { "A\vB\vC", { "B", "C" }, "successive cont" }, +}) do + local box, tick = make(case[1]) + for _ = 1, 4 do + T.check(prompt(box, tick), case[3] .. " reaches next prompt") + if box.done then break end + advance(box, tick) + end + T.check(box.done, case[3] .. " completes") + rows(box, case[2], case[3]) + rows(make(case[1], { instant = true }), case[2], case[3] .. " instant") +end + +local para, tick = make("X\fA\vB") +T.check(prompt(para, tick) and not para.contAdvance, "para waits before clearing") +advance(para, tick) +T.eq(para.holdFrames, Timing.TEXT_PAGE_CLEAR, "para clear timing") +for _ = 1, Timing.TEXT_PAGE_CLEAR do tick() end +T.check(prompt(para, tick) and para.contAdvance, "para restarts at a one-row cont") +rows(para, { "A" }, "para restarts at top") +advance(para, tick) +T.check(prompt(para, tick) and para.done, "para then cont completes") +rows(para, { "", "B" }, "para then cont") + +for _, edition in ipairs({ "gold", "firered" }) do + Version.set(edition) + local box, tick = make("A\vB") + T.check(prompt(box, tick) and box.contAdvance, edition .. " reaches cont") + advance(box, tick) + T.check(prompt(box, tick) and box.done, edition .. " completes cont") + rows(box, { "A", "B" }, edition .. " existing row semantics") + rows(make("A\vB", { instant = true }), { "A", "B" }, edition .. " instant unchanged") +end + +Version.set("red") +local save2, tick2 = make("A\vB", nil, { generation = 2, options = { textSpeed = 1 } }) +T.check(prompt(save2, tick2), "Gen 2 save reaches cont under default version") +advance(save2, tick2) +T.check(prompt(save2, tick2), "Gen 2 save completes cont") +rows(save2, { "A", "B" }, "Gen 2 save unchanged") + +T.finish("text_cont_single_row_bug2634") diff --git a/tests/game3_stitchuif_naming_pc_test.lua b/tests/game3_stitchuif_naming_pc_test.lua index c886374c..3e0f02ff 100644 --- a/tests/game3_stitchuif_naming_pc_test.lua +++ b/tests/game3_stitchuif_naming_pc_test.lua @@ -23,7 +23,7 @@ require("src.core.GameVersion").set("firered") local gfx = setmetatable({}, { __index = function() return function() end end }) gfx.newQuad = function() return {} end gfx.newImage = function() return { setFilter = function() end, getDimensions = function() return 8, 8 end } end -_G.love = { graphics = gfx } +_G.love = { graphics = gfx, image = require("tests.love_stub").image } local Storage = require("src.core.game3.storage") local Flags = require("src.core.game3.scripting.flags") diff --git a/tests/parity_viridian_gambler_bug2632.lua b/tests/parity_viridian_gambler_bug2632.lua new file mode 100644 index 00000000..dfaf38cc --- /dev/null +++ b/tests/parity_viridian_gambler_bug2632.lua @@ -0,0 +1,95 @@ +-- pokered/scripts/ViridianCity.asm:150 +-- pokeyellow/scripts/ViridianCity_2.asm:10 +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 Viridian gambler #2632") +local GameVersion = require("src.core.GameVersion") +local TextBox = require("src.render.TextBox") +local StateStack = require("src.core.StateStack") +local bit = require("bit") +local oldVersion = GameVersion.get() +local savedModules = {} +for name, value in pairs(package.loaded) do + if name:match("^data%.scripts%.") or name == "src.script.MapScripts" then + savedModules[name] = value + end +end + +local badges = { + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", +} + +local function clearRegistry() + local names = {} + for name in pairs(package.loaded) do + if name:match("^data%.scripts%.") or name == "src.script.MapScripts" then + names[#names + 1] = name + end + end + for _, name in ipairs(names) do package.loaded[name] = nil end +end + +local function exercise(handler, version, mask, flagName) + local inventory = {} + for i, badge in ipairs(badges) do + if bit.band(mask, bit.lshift(1, i - 1)) ~= 0 then inventory[badge] = true end + end + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local pressed = false + local game = { + data = { text = { + _ViridianCityGambler1GymLeaderReturnedText = "RETURNED{DONE}", + _ViridianCityGambler1GymAlwaysClosedText = "CLOSED{DONE}", + } }, + save = { inventory = inventory, flags = {}, options = { textSpeed = 1 } }, + stack = stack, + input = { + wasPressed = function(_, key) return pressed and key == "a" end, + isDown = function() return false end, + }, + } + if flagName then game.save.flags[flagName] = true end + local completed = 0 + local label = version .. " mask=" .. mask .. " event=" .. (flagName or "none") + handler(game, nil, nil, function() completed = completed + 1 end) + local box = stack:top() + S.check(getmetatable(box) == TextBox, label .. " opens the actual TextBox") + if getmetatable(box) ~= TextBox then return end + local expected = (mask == 127 or flagName ~= nil) and "RETURNED" or "CLOSED" + S.eq(table.concat(box.pages[1], "\n"), expected, label .. " cartridge branch") + S.eq(completed, 0, label .. " callback waits for dismissal") + for _ = 1, 100 do + if box.done then break end + stack:update(1 / 60) + end + S.check(box.done, label .. " finishes revealing") + pressed = true + stack:update(1 / 60) + S.eq(stack:top(), nil, label .. " dismisses the TextBox") + S.eq(completed, 1, label .. " completes callback exactly once") + stack:update(1 / 60) + S.eq(completed, 1, label .. " callback stays completed") +end + +for _, version in ipairs({ "red", "blue", "yellow" }) do + GameVersion.set(version) + clearRegistry() + local scripts = require("data.scripts.init") + local handler = scripts.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1") + S.check(type(handler) == "function", version .. " resolves the actual registry handler") + if type(handler) == "function" then + for mask = 0, 255 do + exercise(handler, version, mask, nil) + exercise(handler, version, mask, "EVENT_BEAT_GIOVANNI") + exercise(handler, version, mask, "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI") + end + end +end + +clearRegistry() +for name, value in pairs(savedModules) do package.loaded[name] = value end +GameVersion.set(oldVersion) +S.finish() diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index a31ca782..2a1b552a 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -97,8 +97,9 @@ end function Gen.maps(data) if type(data) ~= "table" then return {} end + if data.game3Maps then return data.maps or data.game3Maps end local m = overlayRecords(data.maps, data.gen2Maps) - return overlayRecords(m, data.game3Maps) + return m end function Gen.tilesets(data) diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index efd94751..aee66d79 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -307,6 +307,111 @@ local function drawOverlays(S, map) love.graphics.setColor(1, 1, 1, 1) end +local function gridMap(def, id, generation) + local factor = generation == 3 and 1 or 2 + local map = { + id = id, def = def, width = def.width, height = def.height, + widthCells = def.width * factor, heightCells = def.height * factor, + warps = def.warps or {}, + } + function map:inBounds(cx, cy) + return cx >= 0 and cy >= 0 and cx < self.widthCells and cy < self.heightCells + end + function map:warpAtCell(cx, cy) + for i, warp in ipairs(self.warps) do + if warp.x == cx and warp.y == cy then return { index = i, def = warp } end + end + end + return map +end + +function MapBrowser.preview(S) + local generation = Gen.of(S.save, S.version) + local def = Gen.maps(S.data)[S.mapId] + if not def then return false, "unknown map" end + if generation == 3 and not def.midLayout then + S._mapNativeTried = S._mapNativeTried or {} + if not S._mapNativeTried[def] then + S._mapNativeTried[def] = true + pcall(function() + require("src.core.game3.map").ensureMidLayout({ data = S.data }, S.mapId, def) + end) + end + end + if type(def.width) ~= "number" or type(def.height) ~= "number" then + return false, "incomplete map record (missing width/height)" + end + local map = gridMap(def, S.mapId, generation) + local reason + if generation == 3 then + local layout = def.midLayout + local pair = def.pair or (layout and layout.pair) + if not layout or type(layout.midAt) ~= "function" then + reason = "missing native map layout" + elseif not pair then + reason = "missing native tileset pair" + else + local NativeTileset = require("src.core.game3.tileset_native") + local loaded, ts = pcall(NativeTileset.get, pair) + if not loaded or not ts or not ts.image then + reason = "native tileset image unavailable: " .. tostring(pair) + else + function map:tileAtCell(cx, cy) return layout:midAt(cx, cy) or 0 end + map.renderer = { + draw = function(_, camX, camY) + local zoom = S.mapZoom or 1 + local startCx = math.max(0, math.floor(camX / CELL) - 1) + local endCx = math.min(def.width - 1, math.ceil((camX + (S._mapViewW or 480) / zoom) / CELL) + 1) + local startCy = math.max(0, math.floor(camY / CELL) - 1) + local endCy = math.min(def.height - 1, math.ceil((camY + (S._mapViewH or 432) / zoom) / CELL) + 1) + love.graphics.setColor(1, 1, 1, 1) + for cy = startCy, endCy do + for cx = startCx, endCx do + local mid = layout:midAt(cx, cy) + if mid and mid >= 0 then + local slot = NativeTileset.slotFor(ts, mid) + local quad = NativeTileset.quad(ts, slot) + if quad then love.graphics.draw(ts.image, quad, cx * CELL - camX, cy * CELL - camY) end + if ts.layered and ts.overImage then + local over = NativeTileset.overQuad(ts, slot) + if over then love.graphics.draw(ts.overImage, over, cx * CELL - camX, cy * CELL - camY) end + end + end + end + end + end, + } + end + end + else + local tileset = Gen.tilesets(S.data)[def.tileset] + if not tileset then + reason = "missing tileset: " .. tostring(def.tileset) + elseif not tileset.image then + reason = "missing tileset image: " .. tostring(def.tileset) + elseif not tileset.blocks then + reason = "missing tileset blocks: " .. tostring(def.tileset) + elseif generation == 2 then + local loadedMap, built = pcall(require("src.world.gen2.Map").new, def, tileset) + if not loadedMap then return false, built end + map = built + local MapPreview = require("src.world.gen2.MapPreview") + S._g2MapBaker = S._g2MapBaker or MapPreview.baker({ + tilesets = Gen.tilesets(S.data), gen2Roofs = S.data.gen2Roofs, + roofs = S.data.roofs, gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes, + }) + local loaded, renderer = pcall(MapPreview.renderer, S._g2MapBaker, map) + if loaded and renderer then map.renderer = renderer + else reason = "tileset image unavailable: " .. tostring(tileset.image) end + else + local loaded, rendered = pcall(MapLoader.load, S.data, S.mapId) + if loaded then map = rendered + else reason = "tileset image unavailable: " .. tostring(tileset.image) end + end + end + return true, map, reason and ("Preview unavailable: " .. reason .. ". Coordinate grid remains available.") +end + local function drawSection(S, Kit, x, y, w, h) local s = Kit.scale local gap = 20 * s @@ -496,110 +601,8 @@ local function drawSection(S, Kit, x, y, w, h) ) end - local ok, map - if Gen.of(S.save) == 3 then - local def = Gen.maps(S.data)[S.mapId] - if def then - local mw = def.width or 20 - local mh = def.height or 18 - local midLayout = def.midLayout - local pair = def.pair or (midLayout and midLayout.pair) - map = { - id = S.mapId, - def = def, - width = mw, - height = mh, - widthCells = mw, - heightCells = mh, - warps = def.warps or {}, - inBounds = function(self, cx, cy) - return cx >= 0 and cx < self.width and cy >= 0 and cy < self.height - end, - warpAtCell = function(self, cx, cy) - for _, w in ipairs(self.warps or {}) do - if w.x == cx and w.y == cy then - return { def = w } - end - end - return nil - end, - tileAtCell = function(self, cx, cy) - if midLayout and midLayout.midAt then - return midLayout:midAt(cx, cy) or 0 - end - return 0 - end, - } - - local okN, NativeTileset = pcall(require, "src.core.game3.tileset_native") - if okN and NativeTileset and pair and midLayout and midLayout.midAt then - local ts = NativeTileset.get(pair) - if ts and ts.image then - map.renderer = { - draw = function(self, camX, camY) - local viewW = S._mapViewW or 480 - local viewH = S._mapViewH or 432 - local zoom = S.mapZoom or 1 - local startCx = math.max(0, math.floor(camX / CELL) - 1) - local endCx = math.min(mw - 1, math.ceil((camX + viewW / zoom) / CELL) + 1) - local startCy = math.max(0, math.floor(camY / CELL) - 1) - local endCy = math.min(mh - 1, math.ceil((camY + viewH / zoom) / CELL) + 1) - - love.graphics.setColor(1, 1, 1, 1) - for cy = startCy, endCy do - for cx = startCx, endCx do - local mid = midLayout:midAt(cx, cy) - if mid and mid >= 0 then - local slot = NativeTileset.slotFor(ts, mid) - local q = NativeTileset.quad(ts, slot) - if q then - love.graphics.draw(ts.image, q, cx * CELL - camX, cy * CELL - camY) - end - if ts.layered and ts.overImage then - local oq = NativeTileset.overQuad(ts, slot) - if oq then - love.graphics.draw(ts.overImage, oq, cx * CELL - camX, cy * CELL - camY) - end - end - end - end - end - end, - } - end - end - ok = true - else - ok, map = false, "unknown map" - end - elseif Gen.of(S.save) == 2 then - local def = Gen.maps(S.data)[S.mapId] - if def then - local Map2 = require("src.world.gen2.Map") - if type(def.width) ~= "number" or type(def.height) ~= "number" then - ok, map = false, "incomplete map record (missing width/height)" - else - local tileset = Gen.tilesets(S.data)[def.tileset] - ok, map = pcall(Map2.new, def, tileset or {}) - if ok and map and not map.renderer then - local MapPreview = require("src.world.gen2.MapPreview") - S._g2MapBaker = S._g2MapBaker - or MapPreview.baker({ - tilesets = Gen.tilesets(S.data), - gen2Roofs = S.data.gen2Roofs, - roofs = S.data.roofs, - gen2Palettes = S.data.gen2Palettes, - palettes = S.data.palettes, - }) - map.renderer = MapPreview.renderer(S._g2MapBaker, map) - end - end - else - ok, map = false, "unknown map" - end - else - ok, map = pcall(MapLoader.load, S.data, S.mapId) - end + local ok, map, previewReason = MapBrowser.preview(S) + S.mapPreviewReason = previewReason if not ok then Kit.text( "mono", @@ -777,6 +780,9 @@ local function drawSection(S, Kit, x, y, w, h) end drawOverlays(S, map) love.graphics.pop() + if previewReason then + Kit.textWrapped("tiny", previewReason, vx0 + 8 * s, vy0 + 8 * s, math.max(0, vinner - 16 * s), PAL.yellow) + end Kit.popClip() end