-- 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