mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 18:06:09 +02:00
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>
This commit is contained in:
@@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveFileIO = {}
|
||||
|
||||
-- The cartridge image an imported Gen 2 slot came from, kept BESIDE the slot
|
||||
-- rather than inside it: export needs the regions the codec does not model,
|
||||
-- and 32 KB of binary in the serialized table is 40 KB of Lua source reparsed
|
||||
-- on every save and load.
|
||||
local function cartPath(version, slotId)
|
||||
return ("saves/%s/%s.cart"):format(version, tostring(slotId))
|
||||
end
|
||||
|
||||
local function cartFs()
|
||||
local portable = SaveData.portableFs and SaveData.portableFs()
|
||||
return portable or (love and love.filesystem)
|
||||
end
|
||||
|
||||
local function writeCart(version, slotId, bytes)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.write and bytes) then return end
|
||||
if fs.createDirectory then
|
||||
fs.createDirectory("saves")
|
||||
fs.createDirectory("saves/" .. version)
|
||||
end
|
||||
fs.write(cartPath(version, slotId), bytes)
|
||||
end
|
||||
|
||||
local function readCart(version, slotId)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.read) then return nil end
|
||||
local ok, bytes = pcall(fs.read, cartPath(version, slotId))
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||
|
||||
-- Resolve raw save bytes from whatever the launcher hands us:
|
||||
@@ -125,6 +156,7 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||
end
|
||||
SaveData.setActiveSlot(version, slotId)
|
||||
if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end
|
||||
return true, slotId
|
||||
end
|
||||
|
||||
@@ -139,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version,
|
||||
readCart(version, slotId))
|
||||
if not bytes then return false, exportErr end
|
||||
-- Portable mode is the same seam SaveData's own persistFs uses: when
|
||||
-- portable.txt marks the install every persistent write leaves the OS save
|
||||
-- directory for the game folder, and an export is no exception. Writing
|
||||
|
||||
@@ -61,6 +61,14 @@ local function byIndex(defs)
|
||||
return out
|
||||
end
|
||||
|
||||
local function toIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[id] = def.index end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Gen2Save.crosswalks(data)
|
||||
data = data or {}
|
||||
local items = byIndex(data.items)
|
||||
@@ -71,11 +79,22 @@ function Gen2Save.crosswalks(data)
|
||||
maps[def.group * 256 + def.map] = id
|
||||
end
|
||||
end
|
||||
local mapIds = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
mapIds[id] = { def.group, def.map }
|
||||
end
|
||||
end
|
||||
return {
|
||||
pokemon = byIndex(data.pokemon),
|
||||
moves = byIndex(data.moves),
|
||||
items = items,
|
||||
maps = maps,
|
||||
pokemonIndex = toIndex(data.pokemon),
|
||||
moveIndex = toIndex(data.moves),
|
||||
itemIndex = toIndex(data.items),
|
||||
mapIds = mapIds,
|
||||
itemDefs = data.items or {},
|
||||
}
|
||||
end
|
||||
|
||||
@@ -90,6 +109,14 @@ end
|
||||
-- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy.
|
||||
local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } }
|
||||
|
||||
local function encodeStatus(name, turns)
|
||||
if name == "slp" then return math.min(math.max(turns or 1, 1), 7) end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if row[2] == name then return 2 ^ row[1] end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function decodeStatus(byte)
|
||||
local turns = byte % 8
|
||||
if turns > 0 then return "slp", turns end
|
||||
@@ -137,6 +164,8 @@ local function decodeSharedMon(bytes, o, x)
|
||||
statExp = decodeStatExp(bytes, o + 0x0B),
|
||||
dvs = decodeDVs(bytes, o + 0x15),
|
||||
happiness = u8(bytes, o + 0x1B),
|
||||
pokerus = u8(bytes, o + 0x1C),
|
||||
caughtData = be(bytes, o + 0x1D, 2),
|
||||
level = u8(bytes, o + 0x1F),
|
||||
}
|
||||
end
|
||||
@@ -371,4 +400,291 @@ function Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
return base
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function putU8(t, at, v) t[at] = v % 256 end
|
||||
local function putBE(t, at, v, n)
|
||||
for i = n - 1, 0, -1 do t[at + i] = v % 256; v = math.floor(v / 256) end
|
||||
end
|
||||
|
||||
-- Engine id back to the cart's number. A raw number passes through, which is
|
||||
-- how a save imported without a crosswalk round trips.
|
||||
local function indexOf(map, id)
|
||||
if id == nil then return 0 end
|
||||
if type(id) == "number" then return id end
|
||||
return map[id] or 0
|
||||
end
|
||||
|
||||
local function charBytes(str, i)
|
||||
local b = str:byte(i)
|
||||
if not b then return 0 end
|
||||
if b < 0x80 then return 1 end
|
||||
if b >= 0xF0 then return 4 end
|
||||
if b >= 0xE0 then return 3 end
|
||||
if b >= 0xC0 then return 2 end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function glyphChars(glyph)
|
||||
local n, i = 0, 1
|
||||
while i <= #glyph do i = i + charBytes(glyph, i); n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
-- One-CHARACTER glyphs plus PK and MN. The cart's table also carries the
|
||||
-- ligature halves PO and KE, and accepting those turns a name containing "PO"
|
||||
-- into 0x70 where the cart had a plain P. #glyph counts BYTES, so a byte test
|
||||
-- would also drop every multi-byte glyph and turn NIDORAN into NIDORAN?.
|
||||
local REVERSE = nil
|
||||
local function reverseCharmap()
|
||||
if REVERSE then return REVERSE end
|
||||
REVERSE = {}
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyphChars(glyph) == 1 then REVERSE[glyph] = code end
|
||||
end
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyph == "PK" or glyph == "MN" then REVERSE[glyph] = code end
|
||||
end
|
||||
return REVERSE
|
||||
end
|
||||
|
||||
local function putText(t, at, str, n)
|
||||
local codes = reverseCharmap()
|
||||
local i, written = 1, 0
|
||||
while i <= #str and written < n - 1 do
|
||||
local two = str:sub(i, i + 1)
|
||||
if (two == "PK" or two == "MN") and codes[two] then
|
||||
t[at + written] = codes[two]; i = i + 2
|
||||
else
|
||||
local w = charBytes(str, i)
|
||||
t[at + written] = codes[str:sub(i, i + w - 1)] or 0xE6
|
||||
i = i + w
|
||||
end
|
||||
written = written + 1
|
||||
end
|
||||
t[at + written] = 0x50
|
||||
end
|
||||
|
||||
local function putBadges(t, at, owned, order)
|
||||
local byte = 0
|
||||
for bit, name in ipairs(order) do
|
||||
if owned and owned[name] then byte = byte + 2 ^ (bit - 1) end
|
||||
end
|
||||
t[at] = byte
|
||||
end
|
||||
|
||||
local function putSharedMon(t, o, mon, x)
|
||||
putU8(t, o, indexOf(x.pokemonIndex, mon.species))
|
||||
putU8(t, o + 1, indexOf(x.itemIndex, mon.item))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1]))
|
||||
end
|
||||
putBE(t, o + 6, mon.otId or 0, 2)
|
||||
putBE(t, o + 8, mon.experience or 0, 3)
|
||||
local se = mon.statExp or {}
|
||||
putBE(t, o + 0x0B, se.hp or 0, 2); putBE(t, o + 0x0D, se.attack or 0, 2)
|
||||
putBE(t, o + 0x0F, se.defense or 0, 2); putBE(t, o + 0x11, se.speed or 0, 2)
|
||||
putBE(t, o + 0x13, se.special or 0, 2)
|
||||
local d = mon.dvs or {}
|
||||
putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0))
|
||||
putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
|
||||
end
|
||||
putU8(t, o + 0x1B, mon.happiness or 0)
|
||||
-- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template
|
||||
-- means reordering the party gives slot 1 the previous occupant's pokerus
|
||||
-- and caught data.
|
||||
putU8(t, o + 0x1C, mon.pokerus or 0)
|
||||
putBE(t, o + 0x1D, mon.caughtData or 0, 2)
|
||||
putU8(t, o + 0x1F, mon.level or 0)
|
||||
end
|
||||
|
||||
-- The bag back into its four pockets, by each item's own `pocket`.
|
||||
local function putBag(t, L, inventory, x)
|
||||
local buckets = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} }
|
||||
for id, count in pairs(inventory or {}) do
|
||||
local def = x.itemDefs[id]
|
||||
local pocket = (type(def) == "table" and def.pocket) or "ITEM"
|
||||
if buckets[pocket] == nil then pocket = "ITEM" end
|
||||
buckets[pocket][#buckets[pocket] + 1] = { id = id, count = count, def = def }
|
||||
end
|
||||
-- By cart index, so the order is deterministic. A flat inventory has no
|
||||
-- order of its own, so the pocket order a round trip produces is stable
|
||||
-- rather than original.
|
||||
for _, list in pairs(buckets) do
|
||||
table.sort(list, function(a, b)
|
||||
return indexOf(x.itemIndex, a.id) < indexOf(x.itemIndex, b.id)
|
||||
end)
|
||||
end
|
||||
|
||||
local overflow = nil
|
||||
local function writePairs(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do
|
||||
putU8(t, listAt + (i - 1) * 2, indexOf(x.itemIndex, list[i].id))
|
||||
putU8(t, listAt + (i - 1) * 2 + 1, math.min(list[i].count, 99))
|
||||
end
|
||||
putU8(t, listAt + n * 2, 0xFF)
|
||||
end
|
||||
local function writeIds(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do putU8(t, listAt + i - 1, indexOf(x.itemIndex, list[i].id)) end
|
||||
putU8(t, listAt + n, 0xFF)
|
||||
end
|
||||
|
||||
writePairs(L.wNumItems, L.wItems, 20, buckets.ITEM, "ITEM")
|
||||
writeIds(L.wNumKeyItems, L.wKeyItems, 25, buckets.KEY_ITEM, "KEY_ITEM")
|
||||
writePairs(L.wNumBalls, L.wBalls, 12, buckets.BALL, "BALL")
|
||||
if L.wTMsHMs then
|
||||
for _, row in ipairs(buckets.TM_HM) do
|
||||
local number = type(row.def) == "table" and row.def.tmNumber
|
||||
if number then putU8(t, L.wTMsHMs + number - 1, math.min(row.count, 99)) end
|
||||
end
|
||||
end
|
||||
return overflow
|
||||
end
|
||||
|
||||
local function putFlagSet(t, at, set, count, indexFor)
|
||||
for i = 0, count - 1 do
|
||||
local byteAt = at + math.floor(i / 8)
|
||||
local bit = 2 ^ (i % 8)
|
||||
local cur = t[byteAt] or 0
|
||||
local on = math.floor(cur / bit) % 2 == 1
|
||||
local want = set and set[indexFor(i)] == true
|
||||
if on ~= want then t[byteAt] = want and (cur + bit) or (cur - bit) end
|
||||
end
|
||||
end
|
||||
|
||||
-- encode(save, gameVersion, template, data) -> bytes, err
|
||||
--
|
||||
-- Writes into the cartridge 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 image behind it is refused rather than built from nothing.
|
||||
--
|
||||
-- Only the primary copy is written. TryLoadSaveFile rewrites the backup from
|
||||
-- the primary on every successful load.
|
||||
function Gen2Save.encode(save, gameVersion, template, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(save) ~= "table" then return nil, "expected a save table" end
|
||||
if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then
|
||||
return nil, "this save has no cartridge image to write back into, and a "
|
||||
.. "Gen 2 save built from nothing does not boot on real hardware"
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local t = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = template:byte(i + 1) end
|
||||
|
||||
local p = save.player or {}
|
||||
putText(t, L.wPlayerName, p.name or "", Gen2Save.NAME_LENGTH)
|
||||
putBE(t, L.wPlayerID, p.id or 0, 2)
|
||||
putBE(t, L.wMoney, p.money or 0, 3)
|
||||
putBE(t, L.wCoins, p.coins or 0, 2)
|
||||
putBadges(t, L.wBadges, p.badges, Gen2Save.JOHTO_BADGES)
|
||||
putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES)
|
||||
putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
|
||||
local party = save.party or {}
|
||||
if #party > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("a party of %d cannot be written to a cartridge"):format(#party)
|
||||
end
|
||||
putU8(t, L.wPartyCount, #party)
|
||||
for i, mon in ipairs(party) do
|
||||
putU8(t, L.wPartySpecies + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
local o = L.wPartyMons + (i - 1) * Gen2Save.PARTY_STRUCT
|
||||
putSharedMon(t, o, mon, x)
|
||||
putU8(t, o + 0x20, encodeStatus(mon.status, mon.statusTurns))
|
||||
putBE(t, o + 0x22, mon.hp or 0, 2)
|
||||
local st = mon.stats or {}
|
||||
putBE(t, o + 0x24, mon.maxHp or st.hp or 0, 2)
|
||||
putBE(t, o + 0x26, st.attack or 0, 2); putBE(t, o + 0x28, st.defense or 0, 2)
|
||||
putBE(t, o + 0x2A, st.speed or 0, 2); putBE(t, o + 0x2C, st.specialAttack or 0, 2)
|
||||
putBE(t, o + 0x2E, st.specialDefense or 0, 2)
|
||||
putText(t, L.wPartyMonNicknames + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wPartyMonOTs + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, L.wPartySpecies + #party, 0xFF)
|
||||
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local box = (save.boxes or {})[index] or {}
|
||||
if #box > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d holds %d, which a cartridge cannot"):format(index, #box)
|
||||
end
|
||||
putU8(t, base, #box)
|
||||
for i, mon in ipairs(box) do
|
||||
putU8(t, base + BOX_SPECIES + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
putSharedMon(t, base + BOX_MONS + (i - 1) * Gen2Save.BOX_MON_STRUCT, mon, x)
|
||||
putText(t, base + BOX_OTS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, base + BOX_NICKS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, base + BOX_SPECIES + #box, 0xFF)
|
||||
end
|
||||
|
||||
-- Refuse rather than drop. Without item defs every item buckets into ITEM,
|
||||
-- which holds 20, and a real bag is bigger than that.
|
||||
local overflow = putBag(t, L, save.inventory, x)
|
||||
if overflow then
|
||||
return nil, ("the %s pocket would need %d slots and a cartridge has %d; "
|
||||
.. "the item table for this game is needed to sort the bag")
|
||||
:format(overflow[1], overflow[2], overflow[3])
|
||||
end
|
||||
if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end
|
||||
if save.boxNames and L.wBoxNames then
|
||||
for i = 1, 14 do
|
||||
putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9)
|
||||
end
|
||||
end
|
||||
|
||||
if save.pokedex then
|
||||
local function species(i) return x.pokemon[i + 1] or (i + 1) end
|
||||
putFlagSet(t, L.wPokedexCaught, save.pokedex.caught, Gen2Save.NUM_SPECIES, species)
|
||||
putFlagSet(t, L.wPokedexSeen, save.pokedex.seen, Gen2Save.NUM_SPECIES, species)
|
||||
end
|
||||
for i = 0, Gen2Save.EVENT_BYTES - 1 do
|
||||
local byte = (save.events or {})[i]
|
||||
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
|
||||
end
|
||||
|
||||
local pos = save.position
|
||||
if pos then
|
||||
local ids = pos.map and x.mapIds[pos.map]
|
||||
putU8(t, L.wMapGroup, (ids and ids[1]) or pos.mapGroup or 0)
|
||||
putU8(t, L.wMapNumber, (ids and ids[2]) or pos.mapNumber or 0)
|
||||
putU8(t, L.wXCoord, pos.x or 0)
|
||||
putU8(t, L.wYCoord, pos.y or 0)
|
||||
end
|
||||
local pt = save.playTime
|
||||
if pt then
|
||||
putBE(t, L.wGameTimeHours, pt.hours or 0, 2)
|
||||
putU8(t, L.wGameTimeMinutes, pt.minutes or 0)
|
||||
end
|
||||
|
||||
putU8(t, L.sCheckValue1, 0x63)
|
||||
putU8(t, L.sCheckValue2, 0x7F)
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + t[i]) % 65536 end
|
||||
putU8(t, L.sChecksum, sum % 256)
|
||||
putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256)
|
||||
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end
|
||||
-- Whatever followed the 32 KiB of SRAM is the cart's RTC footer. Dropping it
|
||||
-- resets the clock, which costs the player daily events and the bug contest
|
||||
-- and earns them the clock-adjustment penalty.
|
||||
return table.concat(out) .. template:sub(Gen2Save.SAVE_SIZE + 1)
|
||||
end
|
||||
|
||||
return Gen2Save
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
@@ -258,13 +257,6 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local function gen2CartName(gameVersion)
|
||||
if not GameVersion.VERSIONS[gameVersion] then return nil end
|
||||
if GameVersion.generation(gameVersion) ~= 2 then return nil end
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
end
|
||||
|
||||
-- Can a cart save for this game cross in or out at all? Public because the
|
||||
-- launcher has to ask about the GAME before it measures the BYTES.
|
||||
--
|
||||
@@ -291,11 +283,9 @@ function SaveConvert.importSupported(gameVersion)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Gen 2 exports through Gen2Save now, but only for a save with a cartridge
|
||||
-- image behind it; encode says so itself.
|
||||
function SaveConvert.exportSupported(gameVersion)
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -344,18 +334,23 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
function SaveConvert.exportSav(saveTable, gameVersion, cartImage)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
return Gen2Save.encode(saveTable, gameVersion, cartImage,
|
||||
ensureGen2Data(gameVersion))
|
||||
end
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
|
||||
@@ -207,6 +207,66 @@ do
|
||||
eq(save.party[1].species, 155, "an unknown species keeps its cart number")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export: what goes in comes back out, including what changed
|
||||
-- ------------------------------------------------------------------
|
||||
--
|
||||
-- Exporting a save onto the buffer it was decoded from proves nothing: every
|
||||
-- region encode does not write matches because it was copied. So this CHANGES
|
||||
-- things first, in each of the places export has to reach, and reads them back
|
||||
-- through a fresh decode.
|
||||
|
||||
do
|
||||
local ITEMS = {
|
||||
POTION = { index = 20, pocket = "ITEM" },
|
||||
BICYCLE = { index = 7, pocket = "KEY_ITEM" },
|
||||
POKE_BALL = { index = 5, pocket = "BALL" },
|
||||
TM_HEADBUTT = { index = 191, pocket = "TM_HM", tmNumber = 2 },
|
||||
}
|
||||
local data = {
|
||||
pokemon = CROSSWALK.pokemon, moves = CROSSWALK.moves,
|
||||
items = ITEMS, maps = CROSSWALK.maps,
|
||||
}
|
||||
local cart = build("gold")
|
||||
local save = assert(Gen2Save.decode(cart, "gold", data))
|
||||
|
||||
save.inventory = { POTION = 7, BICYCLE = 1, POKE_BALL = 12, TM_HEADBUTT = 1 }
|
||||
save.currentBox = 5
|
||||
save.party[1].status, save.party[1].statusTurns = "slp", 3
|
||||
save.party[1].pokerus = 0x34
|
||||
save.party[1].caughtData = 0x1234
|
||||
save.events[9] = 0xA5
|
||||
|
||||
local out = assert(Gen2Save.encode(save, "gold", cart, data))
|
||||
eq(#out, #cart, "the image keeps its size")
|
||||
local back = assert(Gen2Save.decode(out, "gold", data))
|
||||
|
||||
-- The bag: encode used to leave it at whatever the template carried, so a
|
||||
-- potion bought in a session never reached the cartridge.
|
||||
eq(back.inventory.POTION, 7, "the ITEM pocket is written")
|
||||
eq(back.inventory.BICYCLE, 1, "and KEY_ITEM")
|
||||
eq(back.inventory.POKE_BALL, 12, "and BALL")
|
||||
eq(back.inventory.TM_HEADBUTT, 1, "and TM_HM, by its tmNumber")
|
||||
eq(back.currentBox, 5, "the open box is written")
|
||||
|
||||
-- 0x1C-0x1E are the mon's, not the slot's: left to the template, reordering
|
||||
-- the party gives slot 1 the previous occupant's pokerus and caught data.
|
||||
eq(back.party[1].pokerus, 0x34, "pokerus rides the mon")
|
||||
eq(back.party[1].caughtData, 0x1234, "so does caught data")
|
||||
|
||||
eq(back.party[1].status, "slp", "status survives as a class")
|
||||
eq(back.party[1].statusTurns, 3, "with its turn count")
|
||||
eq(back.events[9], 0xA5, "event bytes are written")
|
||||
end
|
||||
|
||||
-- A save with no cartridge image behind it is refused, not invented.
|
||||
do
|
||||
local out, err = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {})
|
||||
eq(out, nil, "encode refuses a save with no lineage")
|
||||
check(type(err) == "string" and err:find("no cartridge image", 1, true) ~= nil,
|
||||
"and says why -- " .. tostring(err))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- A save the real cartridge would open must not be refused
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -264,9 +324,11 @@ do
|
||||
check(type(save.hallOfFame) == "table", "so does the hall of fame")
|
||||
check(type(save.phoneContacts) == "table", "and the phone book")
|
||||
end
|
||||
-- Export needs the cartridge image the save came from. Without one it is
|
||||
-- refused rather than built from nothing.
|
||||
local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold")
|
||||
check(type(expErr) == "string" and expErr:find("exporting", 1, true) ~= nil,
|
||||
"export is still refused, and says so -- got: " .. tostring(expErr))
|
||||
check(type(expErr) == "string" and expErr:find("no cartridge image", 1, true) ~= nil,
|
||||
"a save with no cartridge behind it is refused -- got: " .. tostring(expErr))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -300,6 +362,23 @@ else
|
||||
for b, box in ipairs(save.boxes) do
|
||||
check(#box <= Gen2Save.BOX_CAPACITY, ("box %d holds at most 20"):format(b))
|
||||
end
|
||||
|
||||
-- And back out. Without the item table the bag cannot be bucketed, so
|
||||
-- this asserts the refusal rather than a lossy write.
|
||||
local out, err = Gen2Save.encode(save, fixtureVersion, bytes, {})
|
||||
if out then
|
||||
eq(#out, #bytes, "the exported image keeps the cart's size, RTC and all")
|
||||
local back = assert(Gen2Save.decode(out, fixtureVersion))
|
||||
eq(back.player.name, save.player.name, "the player survives the round trip")
|
||||
eq(#back.party, #save.party, "and the party")
|
||||
local a, b2 = 0, 0
|
||||
for _, box in ipairs(save.boxes) do a = a + #box end
|
||||
for _, box in ipairs(back.boxes) do b2 = b2 + #box end
|
||||
eq(b2, a, "and every stored Pokemon")
|
||||
else
|
||||
check(err:find("pocket", 1, true) ~= nil,
|
||||
"or it refuses because the bag cannot be sorted -- " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -110,14 +110,18 @@ do
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export is still one-way, and still says so
|
||||
-- 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
|
||||
local ok, why = SaveConvert.exportSupported(version)
|
||||
eq(ok, false, version .. ": export is still refused")
|
||||
check(type(why) == "string" and why:find("exporting", 1, true) ~= nil,
|
||||
version .. ": and the sentence is about exporting -- " .. tostring(why))
|
||||
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()
|
||||
|
||||
@@ -62,8 +62,11 @@ write(goldPath, SaveSerializer.encode({
|
||||
|
||||
local out = run(("luajit tools/save_convert/convert.lua export %q %q")
|
||||
:format(goldPath, outPath))
|
||||
check(out:find("Gen 2 cart save", 1, true) ~= nil,
|
||||
"exporting a Gold save.lua is refused by name: " .. (out:gsub("%s+$", "")))
|
||||
-- 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")
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil,
|
||||
|
||||
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("not supported yet", 1, true),
|
||||
"the refusal is the plain launcher message: " .. tostring(res))
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user