Files
gen1recomp/tests/engine/gen2_save_import_message.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

128 lines
5.3 KiB
Lua

-- A real Gen 2 cart save is 32786 bytes, and that must not be held against it
-- (#1832).
-- luajit tests/gen2_save_import_message_test.lua
-- Also dofile'd by tests/run_tests.lua.
--
-- Gen 2 carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save
-- carries an RTC footer past the 32768 bytes of SRAM. SaveFileIO.importToSlot
-- judges anything that is not exactly SAVE_SIZE, and it used to judge it with
-- pokered's main-data checksum whatever game it was for, so every real Gen 2
-- save came back "save data checksum invalid" -- a perfectly good save
-- reported as corrupt.
--
-- This is the size gate specifically. tests/gen2_save_import_test.lua covers
-- the codec.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
-- SaveData is stubbed so this stays about the gate: the real one wants a
-- filesystem and a registry, and neither is the subject here.
local written = {}
package.loaded["src.core.SaveData"] = {
load = function() return { meta = {} } end,
activeSlot = function() return "slot1" end,
buildMeta = function(_, m) return m or {} end,
createSlot = function() return "slot1" end,
writeSlot = function(_, _, save) written[#written + 1] = save return true end,
setActiveSlot = function() return true end,
}
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Gen2Save = require("src.save_convert.Gen2Save")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveFileIO = require("src.import.SaveFileIO")
-- The size a real Gen 2 cart save actually is: 32768 of SRAM plus an 18-byte
-- RTC footer. Verified against a real Gold cartridge in an emulator core,
-- which reports the cart's backup size as exactly this.
local GEN2_CART_SAVE_SIZE = 32786
local function validSave(version, trailing)
local L = Gen2Save.layoutFor(version)
local b = {}
for i = 0, Gen2Save.SAVE_SIZE - 1 do b[i] = 0 end
b[L.wPlayerName] = 0x80 -- "A", so the save is not entirely blank
b[L.wPlayerName + 1] = 0x50
b[L.sCheckValue1] = 0x63
b[L.sCheckValue2] = 0x7F
local sum = 0
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
b[L.sChecksum] = sum % 256
b[L.sChecksum + 1] = math.floor(sum / 256) % 256
local out = {}
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(b[i]) end
return table.concat(out) .. string.rep("\0", trailing or 0)
end
local function savFile(bytes)
local path = os.tmpname()
local f = assert(io.open(path, "wb"))
f:write(bytes)
f:close()
return path
end
-- ------------------------------------------------------------------
-- The report: a real-sized Gen 2 save imports
-- ------------------------------------------------------------------
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local trailing = GEN2_CART_SAVE_SIZE - Gen2Save.SAVE_SIZE
local path = savFile(validSave(version, trailing))
written = {}
-- force is deliberately NOT passed: the footer is the normal shape of a Gen
-- 2 cart save, so it must not raise the oversize confirmation either.
local ok, err = SaveFileIO.importToSlot(path, version)
check(ok == true,
version .. ": a 32786-byte cart save imports -- " .. tostring(err))
check(type(err) ~= "string" or err:find("checksum", 1, true) == nil,
version .. ": and is never blamed on a checksum -- " .. tostring(err))
eq(#written, 1, version .. ": the slot is written")
end
-- ------------------------------------------------------------------
-- The checksum question is asked of the right generation
-- ------------------------------------------------------------------
do
local gen2 = validSave("gold")
eq(SaveConvert.mainChecksumValid(gen2, "gold"), true,
"a Gen 2 save is valid under Gen 2's rule")
eq(SaveConvert.mainChecksumValid(gen2), false,
"and would read as invalid under Gen 1's, which is the whole bug")
check(SaveConvert.isGen2Cart("crystal"), "Crystal is a Gen 2 cart")
check(not SaveConvert.isGen2Cart("red"), "Red is not")
end
-- ------------------------------------------------------------------
-- Gen 1 keeps its own diagnosis
-- ------------------------------------------------------------------
do
local path = savFile(string.rep("\0", GEN2_CART_SAVE_SIZE))
local ok, err = SaveFileIO.importToSlot(path, "red", true)
check(ok == false, "red: a corrupt oversize save is still refused")
check(type(err) == "string" and err:find("checksum", 1, true) ~= nil,
"red: still diagnosed by pokered's checksum -- got: " .. tostring(err))
end
-- ------------------------------------------------------------------
-- Export needs the cartridge image behind the save
-- ------------------------------------------------------------------
-- Export goes through the codec now, but only for a save that came from a
-- cartridge: the regions it does not model are the ones the real game trusts
-- on CONTINUE.
for _, version in ipairs({ "gold", "silver", "crystal" }) do
eq(SaveConvert.exportSupported(version), true, version .. ": export is supported")
local out, why = SaveConvert.exportSav({ meta = {}, player = { name = "A" } }, version)
eq(out, nil, version .. ": a save with no cartridge behind it is refused")
check(type(why) == "string" and why:find("no cartridge image", 1, true) ~= nil,
version .. ": and the reason is the missing image -- " .. tostring(why))
end
T.finish()