Files
gen1recomp/src/save_convert/Gen2Save.lua
T

1082 lines
39 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 {},
moveDefs = data.moves 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
-- constants/pokemon_data_constants.asm:216, :218
local function decodePPByte(b) return b % 64, math.floor(b / 64) % 4 end
local function encodePPByte(pp, ppUps)
return ((ppUps or 0) % 4) * 64 + ((pp or 0) % 64)
end
-- engine/items/item_effects.asm:2736
local function maxPPOf(base, ppUps)
if not base then return nil end
return base + (ppUps or 0) * math.min(math.floor(base / 5), 7)
end
-- The first 32 bytes, which a box mon and a party mon share.
local function decodeSharedMon(bytes, o, x)
local moves = {}
for i = 0, 3 do
local id = named(x.moves, u8(bytes, o + 2 + i))
if id then
local pp, ppUps = decodePPByte(u8(bytes, o + 0x17 + i))
local def = x.moveDefs[id]
local base = type(def) == "table" and def.pp or nil
moves[#moves + 1] = {
id = id, pp = pp, ppUps = ppUps, maxPp = maxPPOf(base, ppUps),
}
end
end
return {
species = named(x.pokemon, u8(bytes, o)),
item = named(x.items, u8(bytes, o + 1)),
moves = moves,
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
-- data/events/engine_flags.asm:11-40
local ENGINE_FLAG_BITS = {
wPokegearFlags = { { 1, 0 }, { 0, 1 }, { 2, 2 }, { 3, 3 }, { 4, 7 } },
wStatusFlags = { { 11, 0 }, { 12, 1 }, { 13, 3 }, { 14, 4 }, { 15, 6 } },
wStatusFlags2 = {
{ 18, 0 }, { 17, 1 }, { 19, 4 }, { 20, 5 }, { 21, 6 }, { 22, 7 },
},
}
-- data/events/engine_flags.asm:74-101 over the spawn indexes at
-- constants/map_data_constants.asm:68-99
local FLYPOINT_BITS = {
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
18, 19, 20, 21, 22, 23, 24, 25, 26, 28,
}
local FLYPOINT_FIRST_ID = 50
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM
local function engineId(gold, gameVersion)
if gameVersion == "crystal" and gold >= 16 then return gold + 1 end
return gold
end
-- engine/pokegear/pokegear.asm:2189 HasVisitedSpawn
local function eachSpawnBit(L, fn)
if not L.wVisitedSpawns then return end
for i, bit in ipairs(FLYPOINT_BITS) do
fn(FLYPOINT_FIRST_ID + i - 1, L.wVisitedSpawns + math.floor(bit / 8),
2 ^ (bit % 8))
end
end
local function decodeEngineFlags(bytes, L, gameVersion)
local out = {}
for field, rows in pairs(ENGINE_FLAG_BITS) do
local at = L[field]
if at then
local byte = u8(bytes, at)
for _, row in ipairs(rows) do
if math.floor(byte / 2 ^ row[2]) % 2 == 1 then
out[engineId(row[1], gameVersion)] = true
end
end
end
end
eachSpawnBit(L, function(id, at, mask)
if math.floor(u8(bytes, at) / mask) % 2 == 1 then
out[engineId(id, gameVersion)] = true
end
end)
return out
end
-- constants/sprite_constants.asm:149 SPRITE_VARS; ram/sram.asm:96
Gen2Save.VARIABLE_SPRITES = 16
-- engine/overworld/overworld.asm:326 GetMonSprite.Variable
local function decodeVariableSprites(bytes, at, count)
if not at then return nil end
local out = {}
for i = 0, count - 1 do
local b = u8(bytes, at + i)
if b and b ~= 0 then out[i] = b end
end
return out
end
-- data/maps/scenes.asm:7
local function decodeMapScenes(bytes, L)
local out = {}
for mapId, at in pairs(L.sceneVars or {}) do
local b = u8(bytes, at)
if b and b ~= 0 then out[mapId] = b 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),
-- constants/ram_constants.asm:177 PLAYERGENDER_FEMALE_F
gender = L.wPlayerGender
and (u8(bytes, L.wPlayerGender) % 2 == 1 and "female" or "male") or nil,
},
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,
boxNames = (function()
if not L.wBoxNames then return nil end
local names = {}
for i = 1, 14 do
names[i] = text(bytes, L.wBoxNames + (i - 1) * 9, 9)
end
return names
end)(),
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),
mapScenes = decodeMapScenes(bytes, L),
engineFlags = decodeEngineFlags(bytes, L, gameVersion),
variableSprites = decodeVariableSprites(bytes, L.wVariableSprites,
Gen2Save.VARIABLE_SPRITES),
-- 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 = L.wGameTimeSeconds and u8(bytes, L.wGameTimeSeconds) or 0,
frames = L.wGameTimeFrames and u8(bytes, L.wGameTimeFrames) or 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
for k = written, n - 1 do
t[at + k] = 0x50
end
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
local mv = (mon.moves or {})[i + 1]
putU8(t, o + 2 + i,
indexOf(x.moveIndex, type(mv) == "table" and mv.id or mv))
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
local mv = (mon.moves or {})[i + 1]
if type(mv) == "table" then
putU8(t, o + 0x17 + i, encodePPByte(mv.pp, mv.ppUps))
else
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
end
end
putU8(t, o + 0x1B, mon.happiness or mon.friendship or 70)
-- 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
local function putBoxStruct(t, base, box, x)
box = box or {}
local count = math.min(#box, Gen2Save.BOX_CAPACITY)
putU8(t, base, count)
for i = 1, count do
local mon = box[i]
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 + count, 0xFF)
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 putEngineFlags(t, L, flags, gameVersion)
for field, rows in pairs(ENGINE_FLAG_BITS) do
local at = L[field]
if at then
local byte = t[at] or 0
for _, row in ipairs(rows) do
local mask = 2 ^ row[2]
local on = math.floor(byte / mask) % 2 == 1
local want = flags[engineId(row[1], gameVersion)] == true
if on ~= want then byte = want and (byte + mask) or (byte - mask) end
end
t[at] = byte
end
end
eachSpawnBit(L, function(id, at, mask)
local cur = t[at] or 0
local on = math.floor(cur / mask) % 2 == 1
local want = flags[engineId(id, gameVersion)] == true
if on ~= want then t[at] = want and (cur + mask) or (cur - mask) end
end)
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
-- data/default_options.asm, as engine/menus/save.asm:384
local DEFAULT_OPTIONS = { 0x03, 0x01, 0x00, 0x01, 0x40, 0x01, 0x00, 0x00 }
-- engine/overworld/player_object.asm:39, home/map_objects.asm:352
local PLAYER_MAP_OBJECT = {
0x00, 0x01, 0x00, 0x00, 0x0B, 0xFF, 0xFF, 0xFF,
0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xD5, 0x78,
}
-- engine/overworld/player_object.asm:411, home/map_objects.asm:485,
-- data/sprites/map_objects.asm:93
local PLAYER_STRUCT = {
0x01, 0x00, 0x00, 0x0B, 0x02, 0x00, 0x00, 0xFF,
0x00, 0x01, 0x00, 0x01, 0x00, 0xFF, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40,
}
local STRUCT_X_AT = { 16, 18, 20 }
local STRUCT_Y_AT = { 17, 19, 21 }
-- pokecrystal engine/overworld/player_object.asm:32
local PLAYER_PAL_MALE, PLAYER_PAL_FEMALE = 0x80, 0x90
-- constants/deco_constants.asm:123,140
local DECO_FEATHERY_BED, DECO_TOWN_MAP = 2, 16
-- (engine/menus/intro_menu.asm:28) and ErasePreviousSave
-- (engine/menus/save.asm:333) zero-fill, plus the bytes
-- MapSetupScript_Continue (data/maps/setup_scripts.asm:161) never rebuilds.
function Gen2Save.blankImage(gameVersion, save, data)
local L = Gen2Save.layoutFor(gameVersion)
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
local O = require("src.save_convert.Gen2MapContext").offsetsFor(gameVersion)
if not O then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
local x = Gen2Save.crosswalks(data)
local pos = (type(save) == "table" and save.position) or nil
local ids = pos and pos.map and x.mapIds[pos.map]
local group = (ids and ids[1]) or (pos and tonumber(pos.mapGroup))
local number = (ids and ids[2]) or (pos and tonumber(pos.mapNumber))
if not (group and number) then
return nil, "a save begun in this game needs the map it stands on to "
.. "build a cartridge image from, and this save does not name one"
end
local px = math.floor(tonumber(pos.x) or 0) + 4
local py = math.floor(tonumber(pos.y) or 0) + 4
local t = {}
for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = 0 end
for i = 1, #DEFAULT_OPTIONS do t[L.sOptions + i - 1] = DEFAULT_OPTIONS[i] end
putU8(t, L.wSavedAtLeastOnce, 1)
putText(t, L.wRedsName, "RED", Gen2Save.NAME_LENGTH)
putText(t, L.wGreensName, "GREEN", Gen2Save.NAME_LENGTH)
putBE(t, L.wMomItemTriggerBalance, 2300, 3)
for _, at in ipairs({ L.wRoamMon1MapGroup, L.wRoamMon2MapGroup,
L.wRoamMon3MapGroup }) do
putU8(t, at, 0xFF)
putU8(t, at + 1, 0xFF)
end
putU8(t, L.wBestMagikarpLengthFeet, 3)
putU8(t, L.wBestMagikarpLengthInches, 6)
putText(t, L.wMagikarpRecordHoldersName, "RALPH", Gen2Save.NAME_LENGTH)
putU8(t, L.wNumPCItems, 0)
putU8(t, L.wNumPCItems + 1, 0xFF)
putU8(t, L.sMysteryGiftUnlocked, 0xFF)
-- engine/overworld/decorations.asm:1, :1080
putU8(t, L.wDecoBed, DECO_FEATHERY_BED)
putU8(t, L.wDecoPoster, DECO_TOWN_MAP)
-- engine/menus/intro_menu.asm:148 SetDefaultBoxNames
if L.wBoxNames then
for i = 1, 14 do
putText(t, L.wBoxNames + (i - 1) * 9, "BOX" .. i, 9)
end
end
-- Initialize active box in Bank 1 and archived boxes in Banks 2 & 3
if L.sBox then
putU8(t, L.sBox, 0)
putU8(t, L.sBox + BOX_SPECIES, 0xFF)
end
if L.boxes then
for _, base in ipairs(L.boxes) do
putU8(t, base, 0)
putU8(t, base + BOX_SPECIES, 0xFF)
end
end
for i = 1, #PLAYER_MAP_OBJECT do
t[O.mapObjects + i - 1] = PLAYER_MAP_OBJECT[i]
end
putU8(t, O.mapObjects + 2, py)
putU8(t, O.mapObjects + 3, px)
if L.wPlayerGender then
local female = ((type(save) == "table" and save.player) or {}).gender == "female"
putU8(t, O.mapObjects + 8, female and PLAYER_PAL_FEMALE or PLAYER_PAL_MALE)
end
for i = 1, #PLAYER_STRUCT do
t[O.objectStructs + i - 1] = PLAYER_STRUCT[i]
end
for _, at in ipairs(STRUCT_X_AT) do putU8(t, O.objectStructs + at, px) end
for _, at in ipairs(STRUCT_Y_AT) do putU8(t, O.objectStructs + at, py) end
putU8(t, L.wMapGroup, group)
putU8(t, L.wMapNumber, number)
-- Crystal backup initialization
if L.backup then
if L.backup.sOptions then
for i = 1, #DEFAULT_OPTIONS do t[L.backup.sOptions + i - 1] = DEFAULT_OPTIONS[i] end
end
if L.backup.wSavedAtLeastOnce then putU8(t, L.backup.wSavedAtLeastOnce, 1) end
if L.backup.wRedsName then putText(t, L.backup.wRedsName, "RED", Gen2Save.NAME_LENGTH) end
if L.backup.wGreensName then putText(t, L.backup.wGreensName, "GREEN", Gen2Save.NAME_LENGTH) end
if L.backup.wMomItemTriggerBalance then putBE(t, L.backup.wMomItemTriggerBalance, 2300, 3) end
if L.backup.wNumPCItems then
putU8(t, L.backup.wNumPCItems, 0)
putU8(t, L.backup.wNumPCItems + 1, 0xFF)
end
if L.backup.wBoxNames then
for i = 1, 14 do
putText(t, L.backup.wBoxNames + (i - 1) * 9, "BOX" .. i, 9)
end
end
if L.backup.sCheckValue1 then putU8(t, L.backup.sCheckValue1, 0x63) end
if L.backup.sCheckValue2 then putU8(t, L.backup.sCheckValue2, 0x7F) end
end
local out = {}
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end
return table.concat(out)
end
-- encode(save, gameVersion, template, data) -> bytes, err
--
-- Writes into the cartridge image the save came from. A save begun in this
-- port has none, so Gen2Save.blankImage builds one and the map window is
-- always rebuilt for where it stands.
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
local fresh = type(template) ~= "string"
if not fresh and #template < Gen2Save.SAVE_SIZE then
return nil, ("the cartridge image beside this save is %d bytes and a Gen 2 "
.. "save file is %d; it is truncated or is not a save file")
:format(#template, Gen2Save.SAVE_SIZE)
end
if fresh then
local built, why = Gen2Save.blankImage(gameVersion, save, data)
if not built then return nil, why end
template = built
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)
-- engine/menus/init_gender.asm:38
if L.wPlayerGender then
local byte = t[L.wPlayerGender] or 0
putU8(t, L.wPlayerGender,
byte - byte % 2 + (p.gender == "female" and 1 or 0))
end
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
putBoxStruct(t, base, box, x)
end
-- Write active box in Bank 1
local curBoxIdx = math.max(1, math.min(save.currentBox or 1, 14))
local activeBox = (save.boxes or {})[curBoxIdx] or {}
if L.sBox then
putBoxStruct(t, L.sBox, activeBox, x)
end
-- Bank 0 Party Mail
local MAIL_STRUCT_LEN = 47
for i = 1, Gen2Save.PARTY_LENGTH do
local mon = party[i]
local slotBase = (L.sPartyMail or 0) + (i - 1) * MAIL_STRUCT_LEN
if mon and (mon.item and (mon.item:find("_MAIL$") or mon.item == "LITEBLUEMAIL" or mon.item == "PORTRAITMAIL")) then
local entry = (save.mail and save.mail.party and save.mail.party[i]) or {}
local msg = entry.message or ""
local author = entry.author or mon.ot or p.name or ""
local authorId = entry.authorId or mon.otId or p.id or 0
local speciesId = indexOf(x.pokemonIndex, mon.species)
local itemId = indexOf(x.itemIndex, mon.item)
putText(t, slotBase, msg, 32)
putText(t, slotBase + 32, author, 11)
putBE(t, slotBase + 43, authorId, 2)
putU8(t, slotBase + 45, speciesId)
putU8(t, slotBase + 46, itemId)
else
for k = 0, MAIL_STRUCT_LEN - 1 do
t[slotBase + k] = 0
end
end
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
-- engine/menus/intro_menu.asm:148 SetDefaultBoxNames
if L.wBoxNames then
local names = save.boxNames or {}
for i = 1, 14 do
local name = names[i]
if type(name) ~= "string" or name == "" then name = "BOX" .. i end
putText(t, L.wBoxNames + (i - 1) * 9, name, 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
if L.sceneVars and type(save.mapScenes) == "table" then
for mapId, at in pairs(L.sceneVars) do
local scene = tonumber(save.mapScenes[mapId])
if scene then putU8(t, at, math.floor(scene)) end
end
end
if type(save.engineFlags) == "table" then
putEngineFlags(t, L, save.engineFlags, gameVersion)
end
-- engine/overworld/scripting.asm:869 Script_variablesprite
if L.wVariableSprites and type(save.variableSprites) == "table" then
for i = 0, Gen2Save.VARIABLE_SPRITES - 1 do
local b = save.variableSprites[i]
if type(b) == "number" then putU8(t, L.wVariableSprites + i, b) end
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
-- The save may now stand on a different map than the cartridge image it
-- was written into. Everything the template carried for ITS map is right
-- only for that map: the real game's CONTINUE re-derives attributes and
-- blocks from the ROM but keeps the saved object window
-- (MapSetupScript_Continue runs LoadMapAttributes_SkipObjects), so a
-- moved save needs that window rebuilt for where it stands, or the new
-- map continues with the old map's people in it. Same-map exports leave
-- the template's window untouched, byte for byte.
local templateGroup = template:byte(L.wMapGroup + 1)
local templateNumber = template:byte(L.wMapNumber + 1)
if fresh or t[L.wMapGroup] ~= templateGroup
or t[L.wMapNumber] ~= templateNumber then
local Gen2MapContext = require("src.save_convert.Gen2MapContext")
local ctx, why = Gen2MapContext.build(data, gameVersion,
t[L.wMapGroup], t[L.wMapNumber], t[L.wXCoord], t[L.wYCoord])
if not ctx then
return nil, ("this save cannot be exported onto map %d/%d: %s")
:format(t[L.wMapGroup], t[L.wMapNumber], tostring(why))
end
for offset, values in pairs(ctx.writes) do
for i, value in ipairs(values) do t[offset + i - 1] = value % 256 end
end
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)
if L.wGameTimeSeconds then putU8(t, L.wGameTimeSeconds, pt.seconds or 0) end
if L.wGameTimeFrames then putU8(t, L.wGameTimeFrames, pt.frames or 0) end
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)
-- Crystal Backup mirroring & checksum in Bank 0
if L.backup and L.backup.sGameData and L.backup.sGameDataEnd then
local delta = L.backup.sGameData - L.sGameData
for i = L.sGameData, L.sGameDataEnd - 1 do
t[i + delta] = t[i]
end
if L.backup.sOptions and L.sOptions then
for i = 1, #DEFAULT_OPTIONS do
t[L.backup.sOptions + i - 1] = t[L.sOptions + i - 1]
end
end
if L.backup.sCheckValue1 then putU8(t, L.backup.sCheckValue1, 0x63) end
if L.backup.sCheckValue2 then putU8(t, L.backup.sCheckValue2, 0x7F) end
local bsum = 0
for i = L.backup.sGameData, L.backup.sGameDataEnd - 1 do
bsum = (bsum + t[i]) % 65536
end
if L.backup.sChecksum then
putU8(t, L.backup.sChecksum, bsum % 256)
putU8(t, L.backup.sChecksum + 1, math.floor(bsum / 256) % 256)
end
end
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