mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-03 08:07:07 -04:00
1082 lines
39 KiB
Lua
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
|