Files
gen1recomp/tests/gen2_save_convert_cli_test.lua
T
Colson Rice 71a5602b58 Export Gen 2 cart saves
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>
2026-08-27 10:41:30 -04:00

112 lines
4.1 KiB
Lua

-- The standalone .sav converter refuses a Gen 2 save, ROM-free.
-- luajit tests/gen2_save_convert_cli_test.lua
-- Also dofile'd by tests/run_tests.lua.
--
-- src/save_convert/GenSave.lua models Gen 1 SRAM only, and SaveConvert answers
-- a plain refusal for a Gen 2 game rather than pushing a Gen 2 save table
-- through Gen 1 offsets. That refusal keys on the game the caller names, so
-- the CLI has to name one: tools/save_convert/convert.lua runs the real
-- binary here (a subprocess, not the library) because the hole this covers was
-- exactly the two call sites in it that passed no game at all.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 save convert cli")
local check = S.check
local SaveSerializer = require("src.core.SaveSerializer")
local function run(cmd)
local pipe = io.popen(cmd .. " 2>&1")
if not pipe then return nil end
local out = pipe:read("*a")
pipe:close()
return out or ""
end
local probe = run("luajit -v")
if not probe or not probe:find("LuaJIT") then
check(true, "luajit not on PATH : SKIP")
S.finish()
return
end
local function tmp(name)
local dir = os.getenv("TMPDIR") or "/tmp/"
if dir:sub(-1) ~= "/" then dir = dir .. "/" end
return dir .. "gen2-cli-" .. name
end
local function write(path, text)
local f = assert(io.open(path, "w"))
f:write(text)
f:close()
end
local function exists(path)
local f = io.open(path, "rb")
if f then f:close() return true end
return false
end
-- A Gold slot as src/core/gen2/Save.lua writes it: `generation`/`version` at
-- the top level, and party rows carrying fields Gen 1 never had.
local goldPath = tmp("gold.lua")
local outPath = tmp("gold.sav")
os.remove(outPath)
write(goldPath, SaveSerializer.encode({
format = 7, generation = 2, version = "gold",
player = { name = "GOLD", money = 3000 },
party = { { species = "TOTODILE", level = 5, happiness = 70, pokerus = 0,
dvs = { atk = 15, def = 15, spd = 15, spc = 15 } } },
}))
local out = run(("luajit tools/save_convert/convert.lua export %q %q")
:format(goldPath, outPath))
-- Gen 2 exports through Gen2Save now, but only for a save that carries the
-- cartridge image it came from. A slot built in the launcher has none.
check(out:find("no cartridge image", 1, true) ~= nil,
"exporting a Gold slot with no cartridge behind it is refused, and says why: "
.. (out:gsub("%s+$", "")))
check(not exists(outPath),
"and no 32768-byte file that looks like a Red battery is written")
-- The way IN is no longer a gate: Gen 2 imports through
-- src/save_convert/Gen2Save.lua now. What this pins is that the bytes reach
-- that codec and are judged by ITS rules -- an all-zero image has neither of
-- Gen 2's check values, so it is refused for being blank rather than for
-- being Gold.
local savPath = tmp("in.sav")
local outPath2 = tmp("in.lua")
os.remove(outPath2)
write(savPath, string.rep("\0", 32768))
out = run(("luajit tools/save_convert/convert.lua import %q %q gold")
:format(savPath, outPath2))
check(out:find("not supported yet", 1, true) == nil,
"importing for a Gen 2 game is no longer refused by version: " .. (out:gsub("%s+$", "")))
check(out:find("checksum", 1, true) ~= nil,
"a blank image is refused on Gen 2's own check values: " .. (out:gsub("%s+$", "")))
check(not exists(outPath2), "and writes nothing")
-- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate.
-- (Whether the export then succeeds depends on data/generated/ being built,
-- which this suite deliberately does not require.)
local redPath = tmp("red.lua")
local outPath3 = tmp("red.sav")
os.remove(outPath3)
write(redPath, SaveSerializer.encode({
meta = { format = "gen1_import", version = "red" },
player = { name = "RED", money = 3000 },
party = {},
}))
out = run(("luajit tools/save_convert/convert.lua export %q %q")
:format(redPath, outPath3))
check(not out:find("Gen 2 cart save", 1, true),
"a Gen 1 save is not refused: " .. (out:gsub("%s+$", "")))
for _, path in ipairs({ goldPath, outPath, savPath, outPath2, redPath,
outPath3 }) do
os.remove(path)
end
S.finish()