mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-04 00:20:01 -04:00
291 lines
16 KiB
Lua
291 lines
16 KiB
Lua
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
local T = require("tests.harness")
|
|
local GV = require("src.core.GameVersion")
|
|
require("src.core.game3.profile").reset()
|
|
GV.set("emerald")
|
|
local Dataset = require("src.core.game3.dataset")
|
|
if not Dataset.cache():read("data/generated/gba/rse/contest_gfx/manifest.lua") then
|
|
print("SKIP Emerald cache required: emerald_frontier_contest_save_test")
|
|
os.exit(0)
|
|
end
|
|
love = require("tests.love_stub")
|
|
require("src.import.gba.versions").select("emerald")
|
|
local C = require("src.core.game3.constants").of("emerald")
|
|
require("src.core.game3.pokemon").install(nil)
|
|
local Schema = require("src.core.game3.save_schema_firered")
|
|
local SaveData = require("src.core.SaveData")
|
|
local Space = require("src.core.game3.scripting.space")
|
|
local Runtime = require("src.core.game3.runtime")
|
|
local Game3 = require("src.core.Game3")
|
|
local Rse = require("src.core.game3.rse.init")
|
|
local Util = require("src.core.game3.rse.frontier.util")
|
|
local Story = require("src.core.game3.scripting.natives_frontier_story")
|
|
local D = require("src.core.game3.rse.frontier.trainers")
|
|
local Pike = require("src.core.game3.rse.frontier.pike")
|
|
local Pyramid = require("src.core.game3.rse.frontier.pyramid")
|
|
local Natives = require("src.core.game3.scripting.natives")
|
|
local NC = require("src.core.game3.scripting.natives_contest")
|
|
local Contest = require("src.core.game3.rse.contest")
|
|
local CU = require("src.core.game3.rse.contest_util")
|
|
local Results = require("src.ui.game3.rse.contest_results")
|
|
local Field = require("src.core.game3.field")
|
|
local Vm = require("src.core.game3.scripting.vm")
|
|
local Adapters = require("src.core.game3.scripting.adapters")
|
|
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 files, failure, s, saveCalls, observed = {}, nil, nil, 0, nil
|
|
local oldFs, oldGet, oldGame, oldMod, oldPersist = SaveData.portableFs, Runtime.getSession, Runtime._game, Runtime._mod, Util.persist
|
|
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 failure == "false" and n == SaveData.saveFilename("emerald") .. ".tmp" then return false, "injected write failure" end
|
|
if failure == "throw" and n == SaveData.saveFilename("emerald") .. ".tmp" then error("injected write exception") end
|
|
files[n] = body return true
|
|
end,
|
|
remove = function(n) files[n] = nil return true end,
|
|
createDirectory = function() return true end,
|
|
}
|
|
end
|
|
Runtime.getSession = function() return s end
|
|
local function fresh(map)
|
|
failure, saveCalls, observed = nil, 0, nil
|
|
Util.persist = oldPersist
|
|
s = Schema.newGame({ version = "emerald", name = "SAVE", trainerId = 12345 })
|
|
s.map, s.x, s.y = map or "EM_CONTEST_HALL", 7, 5
|
|
s.party = {}
|
|
for i, name in ipairs({ "SPECIES_PIKACHU", "SPECIES_SWAMPERT", "SPECIES_METAGROSS", "SPECIES_ZIGZAGOON" }) do
|
|
s.party[i] = D.createMon(C:require("species", name), 30 + i, 10 + i, 123450 + i, 12345, { otName = "SAVE", moves = {} })
|
|
s.party[i].nickname = "KEEP" .. i
|
|
end
|
|
Story.savePlayerParty(s)
|
|
s.party = { copy(s.party[3]), copy(s.party[2]), copy(s.party[1]) }
|
|
s.dynamicWarp = { map = "EM_LILYCOVE_CITY_CONTEST_LOBBY", warpId = 255, x = 17, y = 4 }
|
|
s.continueGameWarp = { map = "EM_ROUTE110", x = 10, y = 11, warpId = 255 }
|
|
s.specialSaveWarpFlags = 0xA0
|
|
Space.store = { flags = s.flags, vars = s.vars }
|
|
Runtime._mod = nil
|
|
Runtime._game = { session = s, phase = "field", saveGame = function(game)
|
|
saveCalls = saveCalls + 1
|
|
local result = Game3.saveGame(game)
|
|
observed = copy(game.save)
|
|
return result
|
|
end }
|
|
return s
|
|
end
|
|
local function roundtrip(save)
|
|
return Schema.fromSaveTable(assert(SaveData.decode(SaveData.encode(save))))
|
|
end
|
|
local function context()
|
|
local ctx = { specialVars = {} }
|
|
function ctx:getVar(id) return self.specialVars[id] or 0 end
|
|
function ctx:setVar(id, value) self.specialVars[id] = value end
|
|
return ctx
|
|
end
|
|
local function frame(screen, a)
|
|
screen:frame({ new = a and { a = true } or {}, held = {}, rep = {} })
|
|
end
|
|
local function untilFrame(screen, predicate)
|
|
for _ = 1, 250 do
|
|
if predicate() then return true end
|
|
frame(screen)
|
|
end
|
|
return predicate()
|
|
end
|
|
local function makeContest(link)
|
|
local c = Contest.new({ category = 1, rank = 0, playerIndex = 0, linkFlags = link and 1 or 0, rng = function() return 1 end })
|
|
for i = 0, 3 do
|
|
c.mons[i] = Contest.copyMon(c.data.opponents[i])
|
|
c.round1[i], c.round2[i], c.totals[i], c.standings[i] = 80 - i * 10, 40 - i * 5, 120 - i * 15, i
|
|
end
|
|
c.mons[0] = Contest.buildContestMon(CU.contestantFromMon(s.party[1], s))
|
|
if link then c.link = { exchange = function() return nil end } end
|
|
return c
|
|
end
|
|
|
|
fresh("EM_LILYCOVE_CITY_CONTEST_LOBBY")
|
|
s.x, s.y = 17, 4
|
|
NC.partyIndex = 0
|
|
local oldYield, oldCL, oldLink = Natives.yieldHost, package.loaded["src.core.game3.link.contest_link"], package.loaded["src.core.game3.link.init"]
|
|
Natives.yieldHost = function() return true end
|
|
local code, aborted = 0, 0
|
|
local transport = { RESULT = { OK = 0, ERROR = 1 }, FLAG = { IS_LINK = 1, IS_WIRELESS = 2 },
|
|
newSession = function(_, opts) return { flags = opts.flags, abort = function() aborted = aborted + 1 end } end }
|
|
transport.beginTransfer = function(opts)
|
|
return { session = opts.session, contest = { category = 1, rank = 0 }, step = function() return code end }
|
|
end
|
|
package.loaded["src.core.game3.link.contest_link"] = transport
|
|
package.loaded["src.core.game3.link.init"] = { link = { isOpen = function() return true end, isReady = function() return true end } }
|
|
for _, result in ipairs({ 0, 1 }) do
|
|
code = result
|
|
local ctx = context()
|
|
local before = copy(s.dynamicWarp)
|
|
T.eq(NC.contestLinkTransfer({ ctx = ctx, adapters = {} }), true, "actual contest transfer native schedules polling")
|
|
T.eq(ctx.nativePoll(), true, "actual contest transfer poll finishes")
|
|
if result == 0 then
|
|
T.same(s.dynamicWarp, { map = s.map, warpId = 255, x = 17, y = 4 }, "link success captures exact producer location")
|
|
T.same(roundtrip(Schema.toSaveTable(s)).dynamicWarp, s.dynamicWarp, "producer destination survives SaveData and schema restore")
|
|
else
|
|
T.same(s.dynamicWarp, before, "link failure retains previous destination")
|
|
T.eq(aborted, 1, "failed link transfer aborts transport")
|
|
end
|
|
end
|
|
Natives.yieldHost = oldYield
|
|
package.loaded["src.core.game3.link.contest_link"], package.loaded["src.core.game3.link.init"] = oldCL, oldLink
|
|
|
|
local hallId = assert(Rse.varId("VAR_CONTEST_HALL_STATE", s))
|
|
for _, fault in ipairs({ "none", "false", "throw", "persistThrow", "missingEntrance" }) do
|
|
fresh()
|
|
local c = makeContest(true)
|
|
Rse.setVar(hallId, 3, s)
|
|
s.specialSaveWarpFlags = 0xA1
|
|
local party, backup = copy(s.party), copy(s.savedPlayerParty)
|
|
assert(SaveData.save(Schema.toSaveTable(s)))
|
|
local diskBefore = files[SaveData.saveFilename("emerald")]
|
|
failure = fault ~= "none" and fault or nil
|
|
if fault == "persistThrow" then Util.persist = function() error("injected persist exception") end end
|
|
if fault == "missingEntrance" then s.dynamicWarp.x = nil end
|
|
local screen = Results.new({ contest = c, session = s, headless = true })
|
|
T.check(untilFrame(screen, function() return saveCalls > 0 or screen.linkSaveError ~= nil end), fault .. " reaches real results save action")
|
|
T.eq(s.contestLinkResults[2][1], 1, fault .. " counts result once")
|
|
T.eq(s.gameStats[35], 1, fault .. " counts link win once")
|
|
T.eq(s.gameStats[36], nil, fault .. " does not count ordinary contest")
|
|
local winners, stats = copy(s.contestWinners), copy(s.gameStats)
|
|
local counts = copy(s.contestLinkResults)
|
|
T.eq(Rse.var(hallId, s), 3, fault .. " restores live hall state")
|
|
T.eq(s.specialSaveWarpFlags, fault == "missingEntrance" and 0xA1 or 0xA0, fault .. " clears only active transaction continue bit")
|
|
T.same(s.party, party, fault .. " keeps active party without swapping")
|
|
T.same(s.savedPlayerParty, backup, fault .. " preserves original backup")
|
|
if fault ~= "none" then
|
|
T.check(screen.linkSaveError ~= nil and screen.linkBoxShown == true, fault .. " exposes save error in actual results text box")
|
|
T.eq(screen.standbyBeforeResults, nil, fault .. " blocks link standby after failure")
|
|
T.eq(files[SaveData.saveFilename("emerald")], diskBefore, fault .. " preserves last successful file")
|
|
local calls = saveCalls
|
|
for _ = 1, 30 do frame(screen) end
|
|
T.eq(saveCalls, calls, fault .. " waits for explicit retry input")
|
|
frame(screen, true)
|
|
T.check(screen.linkSaveError ~= nil, fault .. " failed explicit retry retains error")
|
|
T.same(s.contestWinners, winners, fault .. " failed retry never shifts winner twice")
|
|
T.same(s.gameStats, stats, fault .. " failed retry never increments stats twice")
|
|
T.same(s.contestLinkResults, counts, fault .. " failed retry never increments place twice")
|
|
failure, Util.persist = nil, oldPersist
|
|
s.dynamicWarp.x = 17
|
|
frame(screen, true)
|
|
end
|
|
T.eq(screen.linkResultsPersisted, true, fault .. " writes link results successfully")
|
|
T.eq(screen.linkSaveError, nil, fault .. " clears error after successful retry")
|
|
T.check(observed ~= nil, fault .. " actual game save captures source transaction")
|
|
if observed then
|
|
T.eq(observed.vars[hallId] or 0, 0, fault .. " disk save sees source hall state zero")
|
|
T.eq(observed.specialSaveWarpFlags, 0xA1, fault .. " disk save sees temporary continue bit")
|
|
T.same(observed.continueGameWarp, s.dynamicWarp, fault .. " disk save captures original lobby entry")
|
|
T.same(observed.party, party, fault .. " disk writes active party unchanged")
|
|
else
|
|
T.check(false, fault .. " disk save sees source hall state zero")
|
|
T.check(false, fault .. " disk save sees temporary continue bit")
|
|
T.check(false, fault .. " disk save captures original lobby entry")
|
|
T.check(false, fault .. " disk writes active party unchanged")
|
|
end
|
|
local restored = roundtrip(assert(SaveData.load("emerald")))
|
|
T.eq(restored.map, "EM_LILYCOVE_CITY_CONTEST_LOBBY", fault .. " actual continue restores lobby")
|
|
T.eq(restored.x, 17, fault .. " actual continue restores producer x")
|
|
T.eq(restored.y, 4, fault .. " actual continue restores producer y")
|
|
T.eq(restored.vars[hallId] or 0, 0, fault .. " continued hall state remains source zero")
|
|
T.same(restored.contestWinners, winners, fault .. " persisted winner remains exact once")
|
|
T.same(restored.contestLinkResults, counts, fault .. " persisted counts remain exact once")
|
|
local calls = saveCalls
|
|
for _ = 1, 30 do frame(screen, true) end
|
|
T.eq(saveCalls, calls, fault .. " subsequent UI frames never save again")
|
|
T.eq(Rse.var(hallId, s), 3, fault .. " successful retry retains actual runtime hall var")
|
|
T.eq(s.vars[hallId], 3, fault .. " successful retry retains session hall var snapshot")
|
|
end
|
|
for _, row in ipairs({ { false, false }, { true, true } }) do
|
|
fresh()
|
|
local screen = Results.new({ contest = makeContest(row[1]), session = s, headless = true, noFieldHooks = row[2] })
|
|
T.check(untilFrame(screen, function() return screen.linkResultsSaved or (s.gameStats and s.gameStats[36] == 1) end), "ordinary/presentation control reaches results")
|
|
for _ = 1, 60 do frame(screen) end
|
|
T.eq(saveCalls, 0, "ordinary contest and explicit presentation fixture do not auto-save")
|
|
end
|
|
|
|
Natives.bind("emerald")
|
|
local tempId = assert(Rse.varId("VAR_TEMP_CHALLENGE_STATUS", s))
|
|
for _, native in ipairs({ { "CallBattlePikeFunction", Pike.FUNC.SAVE, Pike, "EM_BATTLE_FRONTIER_BATTLE_PIKE_THREE_PATH_ROOM" },
|
|
{ "CallBattlePyramidFunction", Pyramid.FUNC.SAVE, Pyramid, Pyramid.FLOOR_MAP } }) do
|
|
for _, fault in ipairs({ "none", "false", "throw", "persistThrow" }) do
|
|
fresh(native[4])
|
|
s.frontier.challengeStatus, s.frontier.challengePaused = 99, 0
|
|
s.frontier.nestedWitness = { 11, 22 }
|
|
Rse.setVar(tempId, 73, s)
|
|
assert(SaveData.save(Schema.toSaveTable(s)))
|
|
local diskBefore = files[SaveData.saveFilename("emerald")]
|
|
local before, party, backup, dynamic, continuing = copy(s.frontier), copy(s.party), copy(s.savedPlayerParty), copy(s.dynamicWarp), copy(s.continueGameWarp)
|
|
failure = fault ~= "none" and fault or nil
|
|
if fault == "persistThrow" then Util.persist = function() error("injected persist exception") end end
|
|
local finishMessage, messages, sounds, unfreezes = nil, 0, 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.playSe = function() sounds = sounds + 1 end
|
|
adapters.closeMessage = function() end
|
|
adapters.openMessageAsync = function(text, done)
|
|
T.check(text:find("saved", 1, true) ~= nil, native[1] .. " reports concrete persistence failure")
|
|
finishMessage, messages = done, messages + 1
|
|
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" },
|
|
} } })
|
|
local called, started = pcall(vm.start, vm, "save")
|
|
T.check(called and started, native[1] .. " actual VM invokes save special without propagating exceptions")
|
|
if fault == "none" then
|
|
T.eq(messages, 0, native[1] .. " successful native save displays no error")
|
|
T.eq(vm:isRunning(), false, native[1] .. " successful native save continues synchronous script")
|
|
T.eq(sounds, 1, native[1] .. " successful save reaches success sound")
|
|
T.eq(Runtime._game.softResetRequested, true, native[1] .. " successful save reaches actual reset native")
|
|
local disk = assert(SaveData.load("emerald"))
|
|
T.same(disk.party, party, native[1] .. " in-place native save writes exact current party")
|
|
T.eq(disk.map, native[4], native[1] .. " keeps in-place map")
|
|
T.eq(disk.x, 7, native[1] .. " keeps in-place x")
|
|
T.eq(disk.y, 5, native[1] .. " keeps in-place y")
|
|
T.eq(disk.frontier.challengeStatus, 2, native[1] .. " writes source requested status")
|
|
T.eq(disk.frontier.challengePaused, 1, native[1] .. " writes paused source flag")
|
|
T.eq(disk.vars[tempId] or 0, 0, native[1] .. " writes source temporary var zero")
|
|
T.same(disk.continueGameWarp, continuing, native[1] .. " does not manufacture continue destination")
|
|
T.eq(disk.specialSaveWarpFlags, 0xA0, native[1] .. " does not manufacture continue bit")
|
|
T.eq(native[3].save(context(), s), true, native[1] .. " save function returns true after actual successful write")
|
|
else
|
|
T.eq(messages, 1, native[1] .. " failed write shows one error")
|
|
T.eq(vm:isRunning(), true, native[1] .. " failed native waits for acknowledgment")
|
|
T.eq(sounds, 0, native[1] .. " failure blocks success sound")
|
|
T.eq(Runtime._game.softResetRequested, nil, native[1] .. " failure blocks reset")
|
|
T.same(s.frontier, before, native[1] .. " failure restores complete pre-special Frontier state")
|
|
T.eq(Rse.var(tempId, s), 73, native[1] .. " failure restores actual temporary var")
|
|
T.eq(s.vars[tempId], 73, native[1] .. " failure restores session temporary var snapshot")
|
|
T.eq(files[SaveData.saveFilename("emerald")], diskBefore, native[1] .. " failure preserves previous disk save")
|
|
if finishMessage then finishMessage() end
|
|
pcall(vm.tick, vm)
|
|
T.eq(vm:isRunning(), false, native[1] .. " acknowledgment halts remaining script")
|
|
T.eq(sounds, 0, native[1] .. " acknowledgment never plays success sound")
|
|
T.eq(Runtime._game.softResetRequested, nil, native[1] .. " acknowledgment never resets")
|
|
T.check(not Field.locked and unfreezes == 1, native[1] .. " failure unlocks and unfreezes field")
|
|
end
|
|
T.same(s.party, party, native[1] .. " preserves caller-side preceding party state")
|
|
T.same(s.savedPlayerParty, backup, native[1] .. " preserves caller-side preceding backup")
|
|
T.same(s.dynamicWarp, dynamic, native[1] .. " never consumes unrelated dynamic warp")
|
|
T.same(s.continueGameWarp, continuing, native[1] .. " retains unrelated continue destination")
|
|
T.eq(s.specialSaveWarpFlags, 0xA0, native[1] .. " retains all unrelated warp flags")
|
|
end
|
|
end
|
|
Util.persist, SaveData.portableFs, Runtime.getSession, Runtime._game, Runtime._mod = oldPersist, oldFs, oldGet, oldGame, oldMod
|
|
T.finish("emerald_frontier_contest_save_test")
|