mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 18:06:09 +02:00
71a5602b58
Closes the round trip. Gold, Silver and Crystal progress writes back to a
cartridge save the real game boots.
Writes into the image the save came from. Gen 2 SRAM holds a great deal
this codec does not model and the real game trusts it on CONTINUE, so a
save with no cartridge behind it is refused rather than built from nothing.
That refusal is the known limitation: a game begun in this project has no
lineage to write back into yet.
The image lives BESIDE the slot as saves/<version>/<id>.cart, not in the
save table. 32 KB of binary in the serialized table is 40 KB of Lua source
reparsed on every save and load, for every imported slot, forever.
Only the primary copy is written. TryLoadSaveFile rewrites the backup from
the primary the moment VerifyChecksum passes, so a stale backup heals
itself on the first load and this does not need a second offset mapping.
Crystal arranges its backup differently from Gold and Silver, so that
matters.
What encode now reaches that it did not:
* the bag, all four pockets, bucketed by each item's own pocket, plus
wCurBox and the box names. It used to leave them at whatever the
template carried, so a potion bought in a session never arrived.
* the RTC footer. importToSlot truncates to 32768, so the image is kept
at its full length and the footer is carried through. Dropping it
resets the clock and costs the player daily events, the bug contest
and a clock-adjustment penalty.
* 0x1C-0x1E, pokerus and caught data. Left to the template they survive
POSITIONALLY, so reordering the party gives slot 1 the previous
occupant's caught level and location.
A bag that cannot be bucketed is refused rather than written short: without
the item table every item falls into ITEM, which holds 20, and a real bag
is bigger than that. Silently dropping the overflow would be worse than
the bug this fixes.
Two bugs in the text encoder that only real names caught: the cart's table
carries the ligature halves PO and KE, so a name containing "PO" became
0x70 where the cart had a plain P; and #glyph counts BYTES in Lua, so every
multi-byte glyph was dropped and came back as "?", which is NIDORAN and
every name with an accent.
Tests
The round trip CHANGES things first, in each place export has to reach, and
reads them back through a fresh decode. Exporting onto the buffer a save was
decoded from proves nothing, because every region encode does not write
matches by construction.
The fixture-gated audit exports a real cart save too, and pins that the
image keeps its size, RTC and all.
./scripts/test.sh passes end to end, and luacheck is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.6 KiB
Lua
158 lines
6.6 KiB
Lua
-- The launcher's Export on a Gold save slot.
|
|
--
|
|
-- GOLD_CACHE="..." luajit tests/gen2_save_export_test.lua
|
|
--
|
|
-- src/save_convert/GenSave.lua is a Gen 1 codec: its whole offset table is
|
|
-- pokered's SRAM window, and a Gold save lives in a different one entirely
|
|
-- (pokegold ram/sram.asm sOptions/sCheckValue1/sPlayerData/sBox1-sBox14),
|
|
-- so no cart .sav can come out of a Gold slot yet. Export used to reach
|
|
-- the codec anyway and die inside GenSave.crosswalks: the Gen 2 generated
|
|
-- tables carry top-level provenance scalars (`generation`, `source` --
|
|
-- src/import/RomExtractorGen2.lua) beside the def rows, and indexing the
|
|
-- scalar raised "attempt to index local 'def' (a number value)", which the
|
|
-- launcher then printed as a red "encode failed:" traceback.
|
|
--
|
|
-- Two guarantees pinned here:
|
|
-- * the REAL launcher path (slot registry -> gen2 Save.save ->
|
|
-- SaveFileIO.exportActiveSlot) answers a plain "not supported yet"
|
|
-- message for Gold, in both directions, and writes no export file;
|
|
-- * GenSave.crosswalks skips non-table rows, so a data set that carries
|
|
-- provenance scalars can never crash the codec again.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 save export")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = love or require("tests.love_stub")
|
|
|
|
-- An isolated in-memory love.filesystem, the same shape
|
|
-- tests/engine/save_slots.lua isolates SaveData with: full-path keys, no
|
|
-- directory support (both writers guard their createDirectory calls).
|
|
local files = {}
|
|
love.filesystem = {
|
|
files = files,
|
|
write = function(path, content) files[path] = content return true end,
|
|
read = function(path) return files[path] end,
|
|
remove = function(path) files[path] = nil return true end,
|
|
getInfo = function(path)
|
|
if files[path] then return { type = "file" } end
|
|
return nil
|
|
end,
|
|
}
|
|
|
|
local SaveData = require("src.core.SaveData")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local GoldSave = require("src.core.gen2.Save")
|
|
local SaveFileIO = require("src.import.SaveFileIO")
|
|
local SaveConvert = require("src.save_convert.SaveConvert")
|
|
local GenSave = require("src.save_convert.GenSave")
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- The real launcher path: a registered Gold slot holding a save the game's
|
|
-- own writer produced, exported through the same call the Export chip makes.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
SaveData.resetSlotState()
|
|
GameVersion.set("gold")
|
|
|
|
local slotId = SaveData.createSlot("gold")
|
|
check(slotId ~= nil, "a gold slot registers")
|
|
SaveData.setActiveSlot("gold", slotId)
|
|
local save = GoldSave.newGame({ playerName = "BLAKE", rivalName = "SILVER" })
|
|
eq(GoldSave.save(save), true, "the gen2 writer fills the slot")
|
|
check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil,
|
|
"the slot file the launcher lists is on disk")
|
|
|
|
local ok, res = SaveFileIO.exportActiveSlot("gold")
|
|
eq(ok, false, "Export on a Gold slot is refused, not crashed")
|
|
check(type(res) == "string" and res:find("no cartridge image", 1, true),
|
|
"the refusal names the missing cartridge image: " .. tostring(res))
|
|
check(not tostring(res):find("GenSave", 1, true)
|
|
and not tostring(res):find("attempt to index", 1, true),
|
|
"no codec traceback leaks into the notice line")
|
|
local exported = false
|
|
for path in pairs(files) do
|
|
if path:find("^exports/") then exported = true end
|
|
end
|
|
eq(exported, false, "no export file is written for a Gold slot")
|
|
|
|
-- The import direction no longer matches the export one. Gold imports through
|
|
-- Gen2Save now, so a 32 KB image aimed at Gold is decoded rather than turned
|
|
-- away by version. An all-zero image still fails, because Gen 2's guards are
|
|
-- two check values and a sum and a blank image has none of them: refused for
|
|
-- what it is, not for which game it is for.
|
|
local iok, ierr = SaveConvert.importSav(string.rep("\0", 32768), "gold", "gold")
|
|
eq(iok, nil, "a blank cart .sav for Gold is still refused")
|
|
check(type(ierr) == "string" and ierr:find("not supported yet", 1, true) == nil,
|
|
"and no longer refused by version: " .. tostring(ierr))
|
|
check(type(ierr) == "string" and ierr:find("checksum", 1, true) ~= nil,
|
|
"it is Gen 2's own guards that turn it away: " .. tostring(ierr))
|
|
|
|
-- Gen 1 versions still pass the gate: red reaches the codec proper and
|
|
-- fails on its own terms (an all-zero image is not a table), never on the
|
|
-- version.
|
|
local rok, rerr = SaveConvert.exportSav({}, "red")
|
|
check(rok ~= nil or not tostring(rerr):find("not supported yet", 1, true),
|
|
"a red export is never refused by the version gate")
|
|
|
|
GameVersion.set("red")
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- GenSave.crosswalks over def tables that carry provenance scalars.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Synthetic set, ROM-free: real rows resolve, scalar rows are skipped in
|
|
-- all three builders (index, dex, machine).
|
|
local defs = {
|
|
pokemon = {
|
|
generation = 2,
|
|
source = "ROM:BaseStats",
|
|
MEW = { index = 21, source = "ROM:BaseStats[151]", name = "MEW" },
|
|
},
|
|
moves = {
|
|
generation = 2,
|
|
POUND = { index = 1 },
|
|
},
|
|
items = {
|
|
generation = 2,
|
|
source = "ROM:ItemNames",
|
|
POKE_BALL = { index = 4 },
|
|
TM01 = { machine = { kind = "TM", number = 1 } },
|
|
},
|
|
maps = {
|
|
generation = 2,
|
|
PALLET_TOWN = { index = 0 },
|
|
},
|
|
}
|
|
local cok, cw = pcall(GenSave.crosswalks, defs)
|
|
eq(cok, true, "crosswalks survive scalar rows: " .. tostring(cw))
|
|
if cok then
|
|
eq(cw.pokemonIndex.MEW, 21, "a def row beside scalars still resolves")
|
|
eq(cw.pokemonByDex[151], "MEW", "the dex builder skips scalar rows")
|
|
eq(cw.itemsIndex.TM01, 201, "the machine builder skips scalar rows")
|
|
eq(cw.pokemonIndex.generation, nil, "a scalar row never becomes an id")
|
|
end
|
|
|
|
-- The real Gold tables, when a cache is around: the exact shape that
|
|
-- raised at GenSave.crosswalks before the guard.
|
|
local cache = os.getenv("GOLD_CACHE")
|
|
if cache then
|
|
local function loadGen(name)
|
|
local chunk = assert(loadfile(cache .. "/data/generated/" .. name .. ".lua"))
|
|
return chunk()
|
|
end
|
|
local gold = {
|
|
pokemon = loadGen("pokemon"), moves = loadGen("moves"),
|
|
items = loadGen("items"), maps = loadGen("maps"),
|
|
}
|
|
eq(type(gold.items.generation), "number",
|
|
"the gold items table really does carry a scalar row (the regression stays armed)")
|
|
local gok, gerr = pcall(GenSave.crosswalks, gold)
|
|
eq(gok, true, "crosswalks over the extracted Gold tables: " .. tostring(gerr))
|
|
else
|
|
check(true, "no GOLD_CACHE: the extracted Gold tables are not checked (SKIP)")
|
|
end
|
|
|
|
S.finish()
|