Files
gen1recomp/src/save_convert/Gen2Save.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

691 lines
25 KiB
Lua

-- Vanilla Gen 2 (Gold/Silver/Crystal) raw SRAM <-> src/core/gen2/Save.lua.
-- Companion to GenSave.lua, which is Gen 1 only.
--
-- Offsets come from Gen2Layout.lua, generated by tools/gen2_sram_offsets.py.
-- Gold and Silver share a layout; Crystal does not.
--
-- Pure Lua, no love.* at require time, same as GenSave.
local Gen2Layout = require("src.save_convert.Gen2Layout")
local Gen2Save = {}
Gen2Save.SAVE_SIZE = 32768
Gen2Save.PARTY_STRUCT = 48
Gen2Save.NAME_LENGTH = 11
Gen2Save.PARTY_LENGTH = 6
function Gen2Save.layoutFor(gameVersion)
if gameVersion == "crystal" then return Gen2Layout.crystal end
if gameVersion == "gold" or gameVersion == "silver" then return Gen2Layout.goldSilver end
return nil
end
local function u8(b, o) return b:byte(o + 1) end
local function be(b, o, n)
local v = 0
for i = 0, n - 1 do v = v * 256 + b:byte(o + i + 1) end
return v
end
-- The cart's text encoding, from Gen2Layout.charmap. 0x50 terminates.
local function text(b, o, n)
local out = {}
for i = 0, n - 1 do
local c = b:byte(o + i + 1)
if not c or c == 0x50 then break end
out[#out + 1] = Gen2Layout.charmap[c] or "?"
end
return table.concat(out)
end
-- Both check values AND the 16-bit sum between them. A blank SRAM sums to a
-- valid 0 == 0, so the check values are what reject it.
function Gen2Save.checksumValid(bytes, L)
if #bytes < Gen2Save.SAVE_SIZE then return nil end
if u8(bytes, L.sCheckValue1) ~= 0x63 then return false end
if u8(bytes, L.sCheckValue2) ~= 0x7F then return false end
local sum = 0
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + u8(bytes, i)) % 65536 end
local stored = u8(bytes, L.sChecksum) + u8(bytes, L.sChecksum + 1) * 256
return sum == stored
end
-- The cart stores numbers, the engine is keyed by name. Same shape
-- GenSave.crosswalks builds for Gen 1. Without `data` the raw numbers survive.
local function byIndex(defs)
local out = {}
for id, def in pairs(defs or {}) do
if type(def) == "table" and def.index ~= nil then out[def.index] = id end
end
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)
-- Maps are found by (group, number) rather than by a flat index.
local maps = {}
for id, def in pairs(data.maps or {}) do
if type(def) == "table" and def.group and def.map then
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
-- Name if the crosswalk knows it, the raw number if not. Never silently drops
-- a value: an id this build cannot name is still the player's.
local function named(map, index)
if index == nil or index == 0 then return nil end
return map[index] or index
end
-- constants/battle_constants.asm: SLP_MASK is bits 0-2, PSN=3 BRN=4 FRZ=5
-- 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
for _, row in ipairs(STATUS_BITS) do
if math.floor(byte / 2 ^ row[1]) % 2 == 1 then return row[2], nil end
end
return nil, nil
end
-- Four DVs as nibbles in two bytes; the HP DV is rebuilt from the low bit of
-- each (pokecrystal engine/pokemon/health.asm).
local function decodeDVs(bytes, o)
local hi, lo = u8(bytes, o), u8(bytes, o + 1)
local atk = math.floor(hi / 16)
local def = hi % 16
local spd = math.floor(lo / 16)
local spc = lo % 16
local hp = (atk % 2) * 8 + (def % 2) * 4 + (spd % 2) * 2 + (spc % 2)
return { hp = hp, attack = atk, defense = def, speed = spd, special = spc }
end
-- Five 16-bit stat experience words, in the cart's order.
local function decodeStatExp(bytes, o)
return {
hp = be(bytes, o, 2), attack = be(bytes, o + 2, 2),
defense = be(bytes, o + 4, 2), speed = be(bytes, o + 6, 2),
special = be(bytes, o + 8, 2),
}
end
-- The first 32 bytes, which a box mon and a party mon share.
local function decodeSharedMon(bytes, o, x)
local moves, pp = {}, {}
for i = 0, 3 do
local id = named(x.moves, u8(bytes, o + 2 + i))
if id then moves[#moves + 1] = id end
end
for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end
return {
species = named(x.pokemon, u8(bytes, o)),
item = named(x.items, u8(bytes, o + 1)),
moves = moves, pp = pp,
otId = be(bytes, o + 6, 2),
experience = be(bytes, o + 8, 3),
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
local function decodeMon(bytes, o, x)
local mon = decodeSharedMon(bytes, o, x)
mon.status, mon.statusTurns = decodeStatus(u8(bytes, o + 0x20))
mon.hp = be(bytes, o + 0x22, 2)
mon.maxHp = be(bytes, o + 0x24, 2)
mon.stats = {
hp = mon.maxHp,
attack = be(bytes, o + 0x26, 2), defense = be(bytes, o + 0x28, 2),
speed = be(bytes, o + 0x2A, 2), specialAttack = be(bytes, o + 0x2C, 2),
specialDefense = be(bytes, o + 0x2E, 2),
}
return mon
end
-- box_struct: OTs come BEFORE nicknames, and a box mon is 32 bytes with none
-- of the party's computed stats.
Gen2Save.BOX_CAPACITY = 20
Gen2Save.BOX_MON_STRUCT = 32
local BOX_SPECIES, BOX_MONS, BOX_OTS, BOX_NICKS = 0x01, 0x16, 0x296, 0x372
local decodeBoxMon = decodeSharedMon
-- A count past BOX_CAPACITY means the wrong layout, not an odd save.
function Gen2Save.decodeBoxes(bytes, L, x)
local boxes = {}
for index, base in ipairs(L.boxes) do
local count = u8(bytes, base)
if count > Gen2Save.BOX_CAPACITY then
return nil, ("box %d reports %d Pokemon, which is impossible; this is the "
.. "wrong layout for this save"):format(index, count)
end
local mons = {}
for i = 0, count - 1 do
local mon = decodeBoxMon(bytes, base + BOX_MONS + i * Gen2Save.BOX_MON_STRUCT, x)
-- The species list beside the mons must agree, which is a free check
-- on the layout.
local listed = named(x.pokemon, u8(bytes, base + BOX_SPECIES + i))
if listed ~= mon.species then
return nil, ("box %d slot %d: the species list says %s and the stored "
.. "Pokemon says %s; wrong layout for this save")
:format(index, i + 1, tostring(listed), tostring(mon.species))
end
mon.ot = text(bytes, base + BOX_OTS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
mon.nickname = text(bytes, base + BOX_NICKS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
mons[#mons + 1] = mon
end
boxes[index] = mons
end
return boxes
end
-- The bag. Gen 2 splits it into four pockets, and they are not all shaped the
-- same: ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids because a
-- key item is unique, and TM_HM is a flat run of counts indexed by TM number.
-- All of the list pockets are terminated by 0xFF as well as counted, and the
-- count is trusted only as far as the terminator.
-- One bag keyed by item id; PackMenu buckets it by each item's `pocket`.
-- ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids.
local function addPairs(out, bytes, countAt, listAt, cap, x)
local n = u8(bytes, countAt)
if n > cap then n = cap end
for i = 0, n - 1 do
local raw = u8(bytes, listAt + i * 2)
if raw == 0xFF or raw == 0 then break end
local id = named(x.items, raw)
out[id] = (out[id] or 0) + u8(bytes, listAt + i * 2 + 1)
end
end
local function addIds(out, bytes, countAt, listAt, cap, x)
local n = u8(bytes, countAt)
if n > cap then n = cap end
for i = 0, n - 1 do
local raw = u8(bytes, listAt + i)
if raw == 0xFF or raw == 0 then break end
local id = named(x.items, raw)
out[id] = (out[id] or 0) + 1
end
end
-- TM_HM is a flat run of counts indexed by TM number.
local function addMachines(out, bytes, at, x, items)
local byNumber = {}
for id, def in pairs(items or {}) do
if type(def) == "table" and def.tmNumber then byNumber[def.tmNumber] = id end
end
for number, id in pairs(byNumber) do
local count = u8(bytes, at + number - 1)
if count > 0 then out[id] = (out[id] or 0) + count end
end
end
-- Byte index -> byte, which is what Save.scrubEvents validates.
local function decodeFlagBytes(bytes, at, count)
local out = {}
for i = 0, count - 1 do out[i] = u8(bytes, at + i) end
return out
end
-- A set keyed by species id: save.pokedex.caught[species] = true.
local function decodeDex(bytes, at, x)
local out = {}
for i = 0, Gen2Save.NUM_SPECIES - 1 do
local byte = u8(bytes, at + math.floor(i / 8))
if math.floor(byte / (2 ^ (i % 8))) % 2 == 1 then
out[named(x.pokemon, i + 1) or (i + 1)] = true
end
end
return out
end
-- Badges by NAME, which is how FieldMoves.hasBadge and Battle:hasBadge read
-- them. Bit position is accepted as a fallback key there, but the name is the
-- primary and is what a save written by this project carries.
Gen2Save.JOHTO_BADGES = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
Gen2Save.KANTO_BADGES = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
local function decodeBadges(byte, order)
local out = {}
for bit, name in ipairs(order) do
if math.floor(byte / (2 ^ (bit - 1))) % 2 == 1 then out[name] = true end
end
return out
end
Gen2Save.NUM_SPECIES = 251
Gen2Save.EVENT_BYTES = 256
-- decode(bytes, gameVersion, data) -> partial save table, err
function Gen2Save.decode(bytes, gameVersion, data)
local L = Gen2Save.layoutFor(gameVersion)
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
if type(bytes) ~= "string" or #bytes < Gen2Save.SAVE_SIZE then
return nil, ("save must be at least %d bytes"):format(Gen2Save.SAVE_SIZE)
end
-- TryLoadSaveFile falls back to VerifyBackupChecksum and loads the backup
-- copy, so a save the real cartridge would open must not be refused here.
-- Crystal's backup is contiguous and laid out like the primary; Gold and
-- Silver split theirs across three sections and have none to offer.
if Gen2Save.checksumValid(bytes, L) ~= true then
if L.backup and Gen2Save.checksumValid(bytes, L.backup) == true then
L = L.backup
else
return nil, "save data checksum invalid (Gen 2 check values or sum mismatch)"
end
end
local x = Gen2Save.crosswalks(data)
local count = u8(bytes, L.wPartyCount)
if count > Gen2Save.PARTY_LENGTH then
return nil, ("party count %d is impossible; this is probably the wrong layout")
:format(count)
end
local party = {}
for i = 0, count - 1 do
local mon = decodeMon(bytes, L.wPartyMons + i * Gen2Save.PARTY_STRUCT, x)
mon.nickname = text(bytes, L.wPartyMonNicknames + i * Gen2Save.NAME_LENGTH,
Gen2Save.NAME_LENGTH)
mon.ot = text(bytes, L.wPartyMonOTs + i * Gen2Save.NAME_LENGTH,
Gen2Save.NAME_LENGTH)
party[#party + 1] = mon
end
local boxes, boxErr = Gen2Save.decodeBoxes(bytes, L, x)
if not boxes then return nil, boxErr end
local inventory = {}
addPairs(inventory, bytes, L.wNumItems, L.wItems, 20, x)
addIds(inventory, bytes, L.wNumKeyItems, L.wKeyItems, 25, x)
addPairs(inventory, bytes, L.wNumBalls, L.wBalls, 12, x)
if L.wTMsHMs then addMachines(inventory, bytes, L.wTMsHMs, x, (data or {}).items) end
return {
generation = 2,
version = gameVersion,
player = {
name = text(bytes, L.wPlayerName, Gen2Save.NAME_LENGTH),
id = be(bytes, L.wPlayerID, 2),
money = be(bytes, L.wMoney, 3),
coins = be(bytes, L.wCoins, 2),
badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES),
kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES),
},
rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) },
mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) },
party = party,
boxes = boxes,
currentBox = u8(bytes, L.wCurBox) % 16 + 1,
inventory = inventory,
-- Species ids, 1-based, so the set keys match save.pokedex.caught[species].
pokedex = {
caught = decodeDex(bytes, L.wPokedexCaught, x),
seen = decodeDex(bytes, L.wPokedexSeen, x),
},
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
-- Save.summary does `save.position.map or save.spawn`.
position = {
map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)],
mapGroup = u8(bytes, L.wMapGroup), mapNumber = u8(bytes, L.wMapNumber),
x = u8(bytes, L.wXCoord), y = u8(bytes, L.wYCoord),
},
playTime = {
hours = be(bytes, L.wGameTimeHours, 2),
minutes = u8(bytes, L.wGameTimeMinutes),
seconds = 0, frames = 0,
},
}
end
-- Everything the cart does not carry comes from a fresh game, exactly as
-- Gen 1's mergeDefaults does: the decode above models what the SRAM holds,
-- and mail, phone contacts, the unown dex, the hall of fame and the RTC are
-- left to the engine's own defaults rather than invented here.
--
-- Loaded lazily. src/core/gen2/Save.lua pulls in love.filesystem at require
-- time, and this module has to stay require-clean for the headless CLI and
-- the tests, same rule GenSave follows.
function Gen2Save.mergeDefaults(decoded, gameVersion)
local ok, Save = pcall(require, "src.core.gen2.Save")
if not ok then return decoded end
local base = Save.newGame({ playerName = decoded.player and decoded.player.name })
for k, v in pairs(decoded) do base[k] = v end
base.version = 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