CLOSES #2632, CLOSES #2634, CLOSES #2637, CLOSES #2638

This commit is contained in:
bryanthaboi
2026-10-02 22:57:54 -04:00
parent cb02aebd01
commit dc0756c236
48 changed files with 2843 additions and 215 deletions
+9 -12
View File
@@ -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,
+2
View File
@@ -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
+48 -4
View File
@@ -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
+45
View File
@@ -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()
+2
View File
@@ -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,
})
+6 -5
View File
@@ -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
+6 -5
View File
@@ -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
+6 -5
View File
@@ -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
+6 -5
View File
@@ -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
+1 -1
View File
@@ -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
+6 -5
View File
@@ -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
+12 -11
View File
@@ -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
+59 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+3 -1
View File
@@ -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))
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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
+15
View File
@@ -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
+4
View File
@@ -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)
+14 -16
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+20 -8
View File
@@ -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()
+19
View File
@@ -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,
}
+9 -2
View File
@@ -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)
+75
View File
@@ -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
+174
View File
@@ -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
+385
View File
@@ -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
+11 -9
View File
@@ -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")
+151
View File
@@ -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
+130
View File
@@ -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
@@ -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
@@ -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")
+6 -6
View File
@@ -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")
+99
View File
@@ -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")
+217
View File
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -0,0 +1 @@
dofile("tests/emerald_tent_save_reload_2638_test.lua")
+153
View File
@@ -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()
@@ -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")
+1 -1
View File
@@ -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")
+95
View File
@@ -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()
+2 -1
View File
@@ -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)
+110 -104
View File
@@ -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