mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 09:54:43 +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>
691 lines
25 KiB
Lua
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
|