mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 18:06:09 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -16,6 +16,7 @@ local Damage = require("src.battle.Damage")
|
||||
local EffectRegistry = require("src.battle.EffectRegistry")
|
||||
local Experience = require("src.battle.Experience")
|
||||
local Font = require("src.render.Font")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Logger = require("src.core.Logger")
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Party = require("src.pokemon.Party")
|
||||
@@ -6338,7 +6339,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
if self.enemy.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||
else
|
||||
elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then
|
||||
hudTile(0x6E, 32, 8) -- <LV>
|
||||
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
||||
end
|
||||
@@ -6422,7 +6423,7 @@ function BattleState:drawHUDs(slide)
|
||||
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
||||
if self.player.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
|
||||
else
|
||||
elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then
|
||||
hudTile(0x6E, 112, 64) -- <LV>
|
||||
Font.draw(tostring(self.player.mon.level), 120, 64)
|
||||
end
|
||||
|
||||
@@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveFileIO = {}
|
||||
|
||||
-- The cartridge image an imported Gen 2 slot came from, kept BESIDE the slot
|
||||
-- rather than inside it: export needs the regions the codec does not model,
|
||||
-- and 32 KB of binary in the serialized table is 40 KB of Lua source reparsed
|
||||
-- on every save and load.
|
||||
local function cartPath(version, slotId)
|
||||
return ("saves/%s/%s.cart"):format(version, tostring(slotId))
|
||||
end
|
||||
|
||||
local function cartFs()
|
||||
local portable = SaveData.portableFs and SaveData.portableFs()
|
||||
return portable or (love and love.filesystem)
|
||||
end
|
||||
|
||||
local function writeCart(version, slotId, bytes)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.write and bytes) then return end
|
||||
if fs.createDirectory then
|
||||
fs.createDirectory("saves")
|
||||
fs.createDirectory("saves/" .. version)
|
||||
end
|
||||
fs.write(cartPath(version, slotId), bytes)
|
||||
end
|
||||
|
||||
local function readCart(version, slotId)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.read) then return nil end
|
||||
local ok, bytes = pcall(fs.read, cartPath(version, slotId))
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||
|
||||
-- Resolve raw save bytes from whatever the launcher hands us:
|
||||
@@ -78,14 +109,17 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
version = version or GameVersion.get()
|
||||
local bytes, readErr = readSource(source)
|
||||
if not bytes then return false, readErr end
|
||||
-- The GAME decides before the BYTES do. Everything below this line judges a
|
||||
-- save by Gen 1's rules -- the size test, and mainChecksumValid, which is
|
||||
-- pokered's checksum -- so a Gen 2 cart save reaching it is measured against
|
||||
-- a rule that cannot match and comes back "checksum invalid" (#1832).
|
||||
-- The GAME decides before the BYTES do. Everything below this line used to
|
||||
-- judge a save by Gen 1's rules whatever game it was for, and a Gen 2 cart
|
||||
-- is MBC3+TIMER: a real Gold/Silver/Crystal .sav carries an RTC footer, so
|
||||
-- it is 32786 bytes, misses the size test, and was then measured against
|
||||
-- pokered's checksum -- which is why a perfectly good Crystal save reported
|
||||
-- as corrupt (#1832). mainChecksumValid now takes the game and asks that
|
||||
-- generation's rule.
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(version)
|
||||
if not supported then return false, unsupportedWhy end
|
||||
if #bytes ~= SAVE_SIZE then
|
||||
local check = SaveConvert.mainChecksumValid(bytes)
|
||||
local check = SaveConvert.mainChecksumValid(bytes, version)
|
||||
if check == nil then
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
@@ -93,7 +127,12 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
if check == false then
|
||||
return false, "save data checksum invalid (main data checksum mismatch)"
|
||||
end
|
||||
if #bytes > SAVE_SIZE and not force then
|
||||
-- The confirm exists because a Gen 1 save bigger than 32768 is a surprise
|
||||
-- worth asking about. On a Gen 2 cart it is the normal shape -- every
|
||||
-- real one has the footer -- so asking would be a prompt with one sensible
|
||||
-- answer, on every import, forever.
|
||||
if #bytes > SAVE_SIZE and not force
|
||||
and not SaveConvert.isGen2Cart(version) then
|
||||
return false, nil, { needsConfirm = true, size = #bytes }
|
||||
end
|
||||
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
|
||||
@@ -117,6 +156,7 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||
end
|
||||
SaveData.setActiveSlot(version, slotId)
|
||||
if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end
|
||||
return true, slotId
|
||||
end
|
||||
|
||||
@@ -131,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version,
|
||||
readCart(version, slotId))
|
||||
if not bytes then return false, exportErr end
|
||||
-- Portable mode is the same seam SaveData's own persistFs uses: when
|
||||
-- portable.txt marks the install every persistent write leaves the OS save
|
||||
-- directory for the game folder, and an export is no exception. Writing
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.
|
||||
-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that
|
||||
-- script's header for the derivation and the assertion behind it.
|
||||
local Gen2Layout = {}
|
||||
|
||||
Gen2Layout.goldSilver = {
|
||||
sCheckValue1 = 0x2008,
|
||||
sCheckValue2 = 0x2D6B,
|
||||
sChecksum = 0x2D69,
|
||||
sGameData = 0x2009,
|
||||
sGameDataEnd = 0x2D69,
|
||||
wPlayerName = 0x200B,
|
||||
wPlayerID = 0x2009,
|
||||
wMoney = 0x23DB,
|
||||
wCoins = 0x23E2,
|
||||
wBadges = 0x23E4,
|
||||
wKantoBadges = 0x23E5,
|
||||
wRivalName = 0x2021,
|
||||
wMomsName = 0x2016,
|
||||
wPartyCount = 0x288A,
|
||||
wPartySpecies = 0x288B,
|
||||
wPartyMons = 0x2892,
|
||||
wPartyMonNicknames = 0x29F4,
|
||||
wPartyMonOTs = 0x29B2,
|
||||
wNumItems = 0x241F,
|
||||
wItems = 0x2420,
|
||||
wNumKeyItems = 0x2449,
|
||||
wKeyItems = 0x244A,
|
||||
wNumBalls = 0x2464,
|
||||
wBalls = 0x2465,
|
||||
wTMsHMs = 0x23E6,
|
||||
wPokedexCaught = 0x2A4C,
|
||||
wPokedexSeen = 0x2A6C,
|
||||
wCurBox = 0x2724,
|
||||
wBoxNames = 0x2727,
|
||||
wMapGroup = 0x2868,
|
||||
wMapNumber = 0x2869,
|
||||
wXCoord = 0x286B,
|
||||
wYCoord = 0x286A,
|
||||
wEventFlags = 0x261F,
|
||||
wPlayerState = 0x24EA,
|
||||
wGameTimeHours = 0x2053,
|
||||
wGameTimeMinutes = 0x2055,
|
||||
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
}
|
||||
|
||||
Gen2Layout.crystal = {
|
||||
sCheckValue1 = 0x2008,
|
||||
sCheckValue2 = 0x2D0F,
|
||||
sChecksum = 0x2D0D,
|
||||
sGameData = 0x2009,
|
||||
sGameDataEnd = 0x2B83,
|
||||
wPlayerName = 0x200B,
|
||||
wPlayerID = 0x2009,
|
||||
wMoney = 0x23DC,
|
||||
wCoins = 0x23E3,
|
||||
wBadges = 0x23E5,
|
||||
wKantoBadges = 0x23E6,
|
||||
wRivalName = 0x2021,
|
||||
wMomsName = 0x2016,
|
||||
wPartyCount = 0x2865,
|
||||
wPartySpecies = 0x2866,
|
||||
wPartyMons = 0x286D,
|
||||
wPartyMonNicknames = 0x29CF,
|
||||
wPartyMonOTs = 0x298D,
|
||||
wNumItems = 0x2420,
|
||||
wItems = 0x2421,
|
||||
wNumKeyItems = 0x244A,
|
||||
wKeyItems = 0x244B,
|
||||
wNumBalls = 0x2465,
|
||||
wBalls = 0x2466,
|
||||
wTMsHMs = 0x23E7,
|
||||
wPokedexCaught = 0x2A27,
|
||||
wPokedexSeen = 0x2A47,
|
||||
wCurBox = 0x2700,
|
||||
wBoxNames = 0x2703,
|
||||
wMapGroup = 0x2843,
|
||||
wMapNumber = 0x2844,
|
||||
wXCoord = 0x2846,
|
||||
wYCoord = 0x2845,
|
||||
wEventFlags = 0x2600,
|
||||
wPlayerState = 0x24EB,
|
||||
wGameTimeHours = 0x2052,
|
||||
wGameTimeMinutes = 0x2054,
|
||||
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
-- The backup copy the game falls back to when the primary
|
||||
-- checksum fails. Same shape, shifted.
|
||||
backup = {
|
||||
sCheckValue1 = 0x1208,
|
||||
sCheckValue2 = 0x1F0F,
|
||||
sChecksum = 0x1F0D,
|
||||
sGameData = 0x1209,
|
||||
sGameDataEnd = 0x1D83,
|
||||
wPlayerName = 0x120B,
|
||||
wPlayerID = 0x1209,
|
||||
wMoney = 0x15DC,
|
||||
wCoins = 0x15E3,
|
||||
wBadges = 0x15E5,
|
||||
wKantoBadges = 0x15E6,
|
||||
wRivalName = 0x1221,
|
||||
wMomsName = 0x1216,
|
||||
wPartyCount = 0x1A65,
|
||||
wPartySpecies = 0x1A66,
|
||||
wPartyMons = 0x1A6D,
|
||||
wPartyMonNicknames = 0x1BCF,
|
||||
wPartyMonOTs = 0x1B8D,
|
||||
wNumItems = 0x1620,
|
||||
wItems = 0x1621,
|
||||
wNumKeyItems = 0x164A,
|
||||
wKeyItems = 0x164B,
|
||||
wNumBalls = 0x1665,
|
||||
wBalls = 0x1666,
|
||||
wTMsHMs = 0x15E7,
|
||||
wPokedexCaught = 0x1C27,
|
||||
wPokedexSeen = 0x1C47,
|
||||
wCurBox = 0x1900,
|
||||
wBoxNames = 0x1903,
|
||||
wMapGroup = 0x1A43,
|
||||
wMapNumber = 0x1A44,
|
||||
wXCoord = 0x1A46,
|
||||
wYCoord = 0x1A45,
|
||||
wEventFlags = 0x1800,
|
||||
wPlayerState = 0x16EB,
|
||||
wGameTimeHours = 0x1252,
|
||||
wGameTimeMinutes = 0x1254,
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
},
|
||||
}
|
||||
|
||||
Gen2Layout.charmap = {
|
||||
[0x05] = "ガ",
|
||||
[0x06] = "ギ",
|
||||
[0x07] = "グ",
|
||||
[0x08] = "ゲ",
|
||||
[0x09] = "ゴ",
|
||||
[0x0A] = "ザ",
|
||||
[0x0B] = "ジ",
|
||||
[0x0C] = "ズ",
|
||||
[0x0D] = "ゼ",
|
||||
[0x0E] = "ゾ",
|
||||
[0x0F] = "ダ",
|
||||
[0x10] = "ヂ",
|
||||
[0x11] = "ヅ",
|
||||
[0x12] = "デ",
|
||||
[0x13] = "ド",
|
||||
[0x19] = "バ",
|
||||
[0x1A] = "ビ",
|
||||
[0x1B] = "ブ",
|
||||
[0x1C] = "ボ",
|
||||
[0x26] = "が",
|
||||
[0x27] = "ぎ",
|
||||
[0x28] = "ぐ",
|
||||
[0x29] = "げ",
|
||||
[0x2A] = "ご",
|
||||
[0x2B] = "ざ",
|
||||
[0x2C] = "じ",
|
||||
[0x2D] = "ず",
|
||||
[0x2E] = "ぜ",
|
||||
[0x2F] = "ぞ",
|
||||
[0x30] = "だ",
|
||||
[0x31] = "ぢ",
|
||||
[0x32] = "づ",
|
||||
[0x33] = "で",
|
||||
[0x34] = "ど",
|
||||
[0x3A] = "ば",
|
||||
[0x3B] = "び",
|
||||
[0x3C] = "ぶ",
|
||||
[0x3D] = "べ",
|
||||
[0x3E] = "ぼ",
|
||||
[0x3F] = "⁂",
|
||||
[0x40] = "パ",
|
||||
[0x41] = "ピ",
|
||||
[0x42] = "プ",
|
||||
[0x43] = "ポ",
|
||||
[0x44] = "ぱ",
|
||||
[0x45] = "ぴ",
|
||||
[0x46] = "ぷ",
|
||||
[0x47] = "ぺ",
|
||||
[0x48] = "ぽ",
|
||||
[0x50] = "@",
|
||||
[0x54] = "#",
|
||||
[0x60] = "■",
|
||||
[0x61] = "▲",
|
||||
[0x62] = "☎",
|
||||
[0x6E] = "ぃ",
|
||||
[0x6F] = "ぅ",
|
||||
[0x70] = "PO",
|
||||
[0x71] = "KE",
|
||||
[0x72] = "“",
|
||||
[0x73] = "”",
|
||||
[0x74] = "·",
|
||||
[0x75] = "…",
|
||||
[0x76] = "ぁ",
|
||||
[0x77] = "ぇ",
|
||||
[0x78] = "ぉ",
|
||||
[0x79] = "┌",
|
||||
[0x7A] = "─",
|
||||
[0x7B] = "┐",
|
||||
[0x7C] = "│",
|
||||
[0x7D] = "└",
|
||||
[0x7E] = "┘",
|
||||
[0x7F] = " ",
|
||||
[0x80] = "A",
|
||||
[0x81] = "B",
|
||||
[0x82] = "C",
|
||||
[0x83] = "D",
|
||||
[0x84] = "E",
|
||||
[0x85] = "F",
|
||||
[0x86] = "G",
|
||||
[0x87] = "H",
|
||||
[0x88] = "I",
|
||||
[0x89] = "J",
|
||||
[0x8A] = "K",
|
||||
[0x8B] = "L",
|
||||
[0x8C] = "M",
|
||||
[0x8D] = "N",
|
||||
[0x8E] = "O",
|
||||
[0x8F] = "P",
|
||||
[0x90] = "Q",
|
||||
[0x91] = "R",
|
||||
[0x92] = "S",
|
||||
[0x93] = "T",
|
||||
[0x94] = "U",
|
||||
[0x95] = "V",
|
||||
[0x96] = "W",
|
||||
[0x97] = "X",
|
||||
[0x98] = "Y",
|
||||
[0x99] = "Z",
|
||||
[0x9A] = "(",
|
||||
[0x9B] = ")",
|
||||
[0x9C] = ":",
|
||||
[0x9D] = ";",
|
||||
[0x9E] = "[",
|
||||
[0x9F] = "]",
|
||||
[0xA0] = "a",
|
||||
[0xA1] = "b",
|
||||
[0xA2] = "c",
|
||||
[0xA3] = "d",
|
||||
[0xA4] = "e",
|
||||
[0xA5] = "f",
|
||||
[0xA6] = "g",
|
||||
[0xA7] = "h",
|
||||
[0xA8] = "i",
|
||||
[0xA9] = "j",
|
||||
[0xAA] = "k",
|
||||
[0xAB] = "l",
|
||||
[0xAC] = "m",
|
||||
[0xAD] = "n",
|
||||
[0xAE] = "o",
|
||||
[0xAF] = "p",
|
||||
[0xB0] = "q",
|
||||
[0xB1] = "r",
|
||||
[0xB2] = "s",
|
||||
[0xB3] = "t",
|
||||
[0xB4] = "u",
|
||||
[0xB5] = "v",
|
||||
[0xB6] = "w",
|
||||
[0xB7] = "x",
|
||||
[0xB8] = "y",
|
||||
[0xB9] = "z",
|
||||
[0xBA] = "こ",
|
||||
[0xBB] = "さ",
|
||||
[0xBC] = "し",
|
||||
[0xBD] = "す",
|
||||
[0xBE] = "せ",
|
||||
[0xBF] = "そ",
|
||||
[0xC0] = "Ä",
|
||||
[0xC1] = "Ö",
|
||||
[0xC2] = "Ü",
|
||||
[0xC3] = "ä",
|
||||
[0xC4] = "ö",
|
||||
[0xC5] = "ü",
|
||||
[0xC6] = "に",
|
||||
[0xC7] = "ぬ",
|
||||
[0xC8] = "ね",
|
||||
[0xC9] = "の",
|
||||
[0xCA] = "は",
|
||||
[0xCB] = "ひ",
|
||||
[0xCC] = "ふ",
|
||||
[0xCD] = "へ",
|
||||
[0xCE] = "ほ",
|
||||
[0xCF] = "ま",
|
||||
[0xD0] = "'d",
|
||||
[0xD1] = "'l",
|
||||
[0xD2] = "'m",
|
||||
[0xD3] = "'r",
|
||||
[0xD4] = "'s",
|
||||
[0xD5] = "'t",
|
||||
[0xD6] = "'v",
|
||||
[0xD7] = "ら",
|
||||
[0xD8] = "り",
|
||||
[0xD9] = "る",
|
||||
[0xDA] = "れ",
|
||||
[0xDB] = "ろ",
|
||||
[0xDC] = "わ",
|
||||
[0xDD] = "を",
|
||||
[0xDE] = "ん",
|
||||
[0xDF] = "←",
|
||||
[0xE0] = "'",
|
||||
[0xE1] = "PK",
|
||||
[0xE2] = "MN",
|
||||
[0xE3] = "-",
|
||||
[0xE4] = "゚",
|
||||
[0xE5] = "゙",
|
||||
[0xE6] = "?",
|
||||
[0xE7] = "!",
|
||||
[0xE8] = ".",
|
||||
[0xE9] = "&",
|
||||
[0xEA] = "é",
|
||||
[0xEB] = "→",
|
||||
[0xEC] = "▷",
|
||||
[0xED] = "▶",
|
||||
[0xEE] = "▼",
|
||||
[0xEF] = "♂",
|
||||
[0xF0] = "¥",
|
||||
[0xF1] = "×",
|
||||
[0xF2] = ".",
|
||||
[0xF3] = "/",
|
||||
[0xF4] = ",",
|
||||
[0xF5] = "♀",
|
||||
[0xF6] = "0",
|
||||
[0xF7] = "1",
|
||||
[0xF8] = "2",
|
||||
[0xF9] = "3",
|
||||
[0xFA] = "4",
|
||||
[0xFB] = "5",
|
||||
[0xFC] = "6",
|
||||
[0xFD] = "7",
|
||||
[0xFE] = "8",
|
||||
[0xFF] = "9",
|
||||
}
|
||||
|
||||
return Gen2Layout
|
||||
@@ -0,0 +1,690 @@
|
||||
-- Vanilla Gen 2 (Gold/Silver/Crystal) raw SRAM <-> src/core/gen2/Save.lua.
|
||||
-- Companion to GenSave.lua, which is Gen 1 only.
|
||||
--
|
||||
-- Offsets come from Gen2Layout.lua, generated by tools/gen2_sram_offsets.py.
|
||||
-- Gold and Silver share a layout; Crystal does not.
|
||||
--
|
||||
-- Pure Lua, no love.* at require time, same as GenSave.
|
||||
|
||||
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
||||
|
||||
local Gen2Save = {}
|
||||
|
||||
Gen2Save.SAVE_SIZE = 32768
|
||||
Gen2Save.PARTY_STRUCT = 48
|
||||
Gen2Save.NAME_LENGTH = 11
|
||||
Gen2Save.PARTY_LENGTH = 6
|
||||
|
||||
function Gen2Save.layoutFor(gameVersion)
|
||||
if gameVersion == "crystal" then return Gen2Layout.crystal end
|
||||
if gameVersion == "gold" or gameVersion == "silver" then return Gen2Layout.goldSilver end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function u8(b, o) return b:byte(o + 1) end
|
||||
local function be(b, o, n)
|
||||
local v = 0
|
||||
for i = 0, n - 1 do v = v * 256 + b:byte(o + i + 1) end
|
||||
return v
|
||||
end
|
||||
|
||||
-- The cart's text encoding, from Gen2Layout.charmap. 0x50 terminates.
|
||||
local function text(b, o, n)
|
||||
local out = {}
|
||||
for i = 0, n - 1 do
|
||||
local c = b:byte(o + i + 1)
|
||||
if not c or c == 0x50 then break end
|
||||
out[#out + 1] = Gen2Layout.charmap[c] or "?"
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- Both check values AND the 16-bit sum between them. A blank SRAM sums to a
|
||||
-- valid 0 == 0, so the check values are what reject it.
|
||||
function Gen2Save.checksumValid(bytes, L)
|
||||
if #bytes < Gen2Save.SAVE_SIZE then return nil end
|
||||
if u8(bytes, L.sCheckValue1) ~= 0x63 then return false end
|
||||
if u8(bytes, L.sCheckValue2) ~= 0x7F then return false end
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + u8(bytes, i)) % 65536 end
|
||||
local stored = u8(bytes, L.sChecksum) + u8(bytes, L.sChecksum + 1) * 256
|
||||
return sum == stored
|
||||
end
|
||||
|
||||
-- The cart stores numbers, the engine is keyed by name. Same shape
|
||||
-- GenSave.crosswalks builds for Gen 1. Without `data` the raw numbers survive.
|
||||
local function byIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[def.index] = id end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function toIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[id] = def.index end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Gen2Save.crosswalks(data)
|
||||
data = data or {}
|
||||
local items = byIndex(data.items)
|
||||
-- Maps are found by (group, number) rather than by a flat index.
|
||||
local maps = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
maps[def.group * 256 + def.map] = id
|
||||
end
|
||||
end
|
||||
local mapIds = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
mapIds[id] = { def.group, def.map }
|
||||
end
|
||||
end
|
||||
return {
|
||||
pokemon = byIndex(data.pokemon),
|
||||
moves = byIndex(data.moves),
|
||||
items = items,
|
||||
maps = maps,
|
||||
pokemonIndex = toIndex(data.pokemon),
|
||||
moveIndex = toIndex(data.moves),
|
||||
itemIndex = toIndex(data.items),
|
||||
mapIds = mapIds,
|
||||
itemDefs = data.items or {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Name if the crosswalk knows it, the raw number if not. Never silently drops
|
||||
-- a value: an id this build cannot name is still the player's.
|
||||
local function named(map, index)
|
||||
if index == nil or index == 0 then return nil end
|
||||
return map[index] or index
|
||||
end
|
||||
|
||||
-- constants/battle_constants.asm: SLP_MASK is bits 0-2, PSN=3 BRN=4 FRZ=5
|
||||
-- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy.
|
||||
local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } }
|
||||
|
||||
local function encodeStatus(name, turns)
|
||||
if name == "slp" then return math.min(math.max(turns or 1, 1), 7) end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if row[2] == name then return 2 ^ row[1] end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function decodeStatus(byte)
|
||||
local turns = byte % 8
|
||||
if turns > 0 then return "slp", turns end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if math.floor(byte / 2 ^ row[1]) % 2 == 1 then return row[2], nil end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Four DVs as nibbles in two bytes; the HP DV is rebuilt from the low bit of
|
||||
-- each (pokecrystal engine/pokemon/health.asm).
|
||||
local function decodeDVs(bytes, o)
|
||||
local hi, lo = u8(bytes, o), u8(bytes, o + 1)
|
||||
local atk = math.floor(hi / 16)
|
||||
local def = hi % 16
|
||||
local spd = math.floor(lo / 16)
|
||||
local spc = lo % 16
|
||||
local hp = (atk % 2) * 8 + (def % 2) * 4 + (spd % 2) * 2 + (spc % 2)
|
||||
return { hp = hp, attack = atk, defense = def, speed = spd, special = spc }
|
||||
end
|
||||
|
||||
-- Five 16-bit stat experience words, in the cart's order.
|
||||
local function decodeStatExp(bytes, o)
|
||||
return {
|
||||
hp = be(bytes, o, 2), attack = be(bytes, o + 2, 2),
|
||||
defense = be(bytes, o + 4, 2), speed = be(bytes, o + 6, 2),
|
||||
special = be(bytes, o + 8, 2),
|
||||
}
|
||||
end
|
||||
|
||||
-- The first 32 bytes, which a box mon and a party mon share.
|
||||
local function decodeSharedMon(bytes, o, x)
|
||||
local moves, pp = {}, {}
|
||||
for i = 0, 3 do
|
||||
local id = named(x.moves, u8(bytes, o + 2 + i))
|
||||
if id then moves[#moves + 1] = id end
|
||||
end
|
||||
for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end
|
||||
return {
|
||||
species = named(x.pokemon, u8(bytes, o)),
|
||||
item = named(x.items, u8(bytes, o + 1)),
|
||||
moves = moves, pp = pp,
|
||||
otId = be(bytes, o + 6, 2),
|
||||
experience = be(bytes, o + 8, 3),
|
||||
statExp = decodeStatExp(bytes, o + 0x0B),
|
||||
dvs = decodeDVs(bytes, o + 0x15),
|
||||
happiness = u8(bytes, o + 0x1B),
|
||||
pokerus = u8(bytes, o + 0x1C),
|
||||
caughtData = be(bytes, o + 0x1D, 2),
|
||||
level = u8(bytes, o + 0x1F),
|
||||
}
|
||||
end
|
||||
|
||||
local function decodeMon(bytes, o, x)
|
||||
local mon = decodeSharedMon(bytes, o, x)
|
||||
mon.status, mon.statusTurns = decodeStatus(u8(bytes, o + 0x20))
|
||||
mon.hp = be(bytes, o + 0x22, 2)
|
||||
mon.maxHp = be(bytes, o + 0x24, 2)
|
||||
mon.stats = {
|
||||
hp = mon.maxHp,
|
||||
attack = be(bytes, o + 0x26, 2), defense = be(bytes, o + 0x28, 2),
|
||||
speed = be(bytes, o + 0x2A, 2), specialAttack = be(bytes, o + 0x2C, 2),
|
||||
specialDefense = be(bytes, o + 0x2E, 2),
|
||||
}
|
||||
return mon
|
||||
end
|
||||
|
||||
-- box_struct: OTs come BEFORE nicknames, and a box mon is 32 bytes with none
|
||||
-- of the party's computed stats.
|
||||
Gen2Save.BOX_CAPACITY = 20
|
||||
Gen2Save.BOX_MON_STRUCT = 32
|
||||
local BOX_SPECIES, BOX_MONS, BOX_OTS, BOX_NICKS = 0x01, 0x16, 0x296, 0x372
|
||||
|
||||
local decodeBoxMon = decodeSharedMon
|
||||
|
||||
-- A count past BOX_CAPACITY means the wrong layout, not an odd save.
|
||||
function Gen2Save.decodeBoxes(bytes, L, x)
|
||||
local boxes = {}
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local count = u8(bytes, base)
|
||||
if count > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d reports %d Pokemon, which is impossible; this is the "
|
||||
.. "wrong layout for this save"):format(index, count)
|
||||
end
|
||||
local mons = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = decodeBoxMon(bytes, base + BOX_MONS + i * Gen2Save.BOX_MON_STRUCT, x)
|
||||
-- The species list beside the mons must agree, which is a free check
|
||||
-- on the layout.
|
||||
local listed = named(x.pokemon, u8(bytes, base + BOX_SPECIES + i))
|
||||
if listed ~= mon.species then
|
||||
return nil, ("box %d slot %d: the species list says %s and the stored "
|
||||
.. "Pokemon says %s; wrong layout for this save")
|
||||
:format(index, i + 1, tostring(listed), tostring(mon.species))
|
||||
end
|
||||
mon.ot = text(bytes, base + BOX_OTS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
|
||||
mon.nickname = text(bytes, base + BOX_NICKS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
|
||||
mons[#mons + 1] = mon
|
||||
end
|
||||
boxes[index] = mons
|
||||
end
|
||||
return boxes
|
||||
end
|
||||
|
||||
-- The bag. Gen 2 splits it into four pockets, and they are not all shaped the
|
||||
-- same: ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids because a
|
||||
-- key item is unique, and TM_HM is a flat run of counts indexed by TM number.
|
||||
-- All of the list pockets are terminated by 0xFF as well as counted, and the
|
||||
-- count is trusted only as far as the terminator.
|
||||
-- One bag keyed by item id; PackMenu buckets it by each item's `pocket`.
|
||||
-- ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids.
|
||||
local function addPairs(out, bytes, countAt, listAt, cap, x)
|
||||
local n = u8(bytes, countAt)
|
||||
if n > cap then n = cap end
|
||||
for i = 0, n - 1 do
|
||||
local raw = u8(bytes, listAt + i * 2)
|
||||
if raw == 0xFF or raw == 0 then break end
|
||||
local id = named(x.items, raw)
|
||||
out[id] = (out[id] or 0) + u8(bytes, listAt + i * 2 + 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function addIds(out, bytes, countAt, listAt, cap, x)
|
||||
local n = u8(bytes, countAt)
|
||||
if n > cap then n = cap end
|
||||
for i = 0, n - 1 do
|
||||
local raw = u8(bytes, listAt + i)
|
||||
if raw == 0xFF or raw == 0 then break end
|
||||
local id = named(x.items, raw)
|
||||
out[id] = (out[id] or 0) + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- TM_HM is a flat run of counts indexed by TM number.
|
||||
local function addMachines(out, bytes, at, x, items)
|
||||
local byNumber = {}
|
||||
for id, def in pairs(items or {}) do
|
||||
if type(def) == "table" and def.tmNumber then byNumber[def.tmNumber] = id end
|
||||
end
|
||||
for number, id in pairs(byNumber) do
|
||||
local count = u8(bytes, at + number - 1)
|
||||
if count > 0 then out[id] = (out[id] or 0) + count end
|
||||
end
|
||||
end
|
||||
|
||||
-- Byte index -> byte, which is what Save.scrubEvents validates.
|
||||
local function decodeFlagBytes(bytes, at, count)
|
||||
local out = {}
|
||||
for i = 0, count - 1 do out[i] = u8(bytes, at + i) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A set keyed by species id: save.pokedex.caught[species] = true.
|
||||
local function decodeDex(bytes, at, x)
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.NUM_SPECIES - 1 do
|
||||
local byte = u8(bytes, at + math.floor(i / 8))
|
||||
if math.floor(byte / (2 ^ (i % 8))) % 2 == 1 then
|
||||
out[named(x.pokemon, i + 1) or (i + 1)] = true
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Badges by NAME, which is how FieldMoves.hasBadge and Battle:hasBadge read
|
||||
-- them. Bit position is accepted as a fallback key there, but the name is the
|
||||
-- primary and is what a save written by this project carries.
|
||||
Gen2Save.JOHTO_BADGES = {
|
||||
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
|
||||
}
|
||||
Gen2Save.KANTO_BADGES = {
|
||||
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
|
||||
"SOUL", "MARSH", "VOLCANO", "EARTH",
|
||||
}
|
||||
local function decodeBadges(byte, order)
|
||||
local out = {}
|
||||
for bit, name in ipairs(order) do
|
||||
if math.floor(byte / (2 ^ (bit - 1))) % 2 == 1 then out[name] = true end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Gen2Save.NUM_SPECIES = 251
|
||||
Gen2Save.EVENT_BYTES = 256
|
||||
|
||||
-- decode(bytes, gameVersion, data) -> partial save table, err
|
||||
function Gen2Save.decode(bytes, gameVersion, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(bytes) ~= "string" or #bytes < Gen2Save.SAVE_SIZE then
|
||||
return nil, ("save must be at least %d bytes"):format(Gen2Save.SAVE_SIZE)
|
||||
end
|
||||
-- TryLoadSaveFile falls back to VerifyBackupChecksum and loads the backup
|
||||
-- copy, so a save the real cartridge would open must not be refused here.
|
||||
-- Crystal's backup is contiguous and laid out like the primary; Gold and
|
||||
-- Silver split theirs across three sections and have none to offer.
|
||||
if Gen2Save.checksumValid(bytes, L) ~= true then
|
||||
if L.backup and Gen2Save.checksumValid(bytes, L.backup) == true then
|
||||
L = L.backup
|
||||
else
|
||||
return nil, "save data checksum invalid (Gen 2 check values or sum mismatch)"
|
||||
end
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local count = u8(bytes, L.wPartyCount)
|
||||
if count > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("party count %d is impossible; this is probably the wrong layout")
|
||||
:format(count)
|
||||
end
|
||||
local party = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = decodeMon(bytes, L.wPartyMons + i * Gen2Save.PARTY_STRUCT, x)
|
||||
mon.nickname = text(bytes, L.wPartyMonNicknames + i * Gen2Save.NAME_LENGTH,
|
||||
Gen2Save.NAME_LENGTH)
|
||||
mon.ot = text(bytes, L.wPartyMonOTs + i * Gen2Save.NAME_LENGTH,
|
||||
Gen2Save.NAME_LENGTH)
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
|
||||
local boxes, boxErr = Gen2Save.decodeBoxes(bytes, L, x)
|
||||
if not boxes then return nil, boxErr end
|
||||
|
||||
local inventory = {}
|
||||
addPairs(inventory, bytes, L.wNumItems, L.wItems, 20, x)
|
||||
addIds(inventory, bytes, L.wNumKeyItems, L.wKeyItems, 25, x)
|
||||
addPairs(inventory, bytes, L.wNumBalls, L.wBalls, 12, x)
|
||||
if L.wTMsHMs then addMachines(inventory, bytes, L.wTMsHMs, x, (data or {}).items) end
|
||||
|
||||
return {
|
||||
generation = 2,
|
||||
version = gameVersion,
|
||||
player = {
|
||||
name = text(bytes, L.wPlayerName, Gen2Save.NAME_LENGTH),
|
||||
id = be(bytes, L.wPlayerID, 2),
|
||||
money = be(bytes, L.wMoney, 3),
|
||||
coins = be(bytes, L.wCoins, 2),
|
||||
badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES),
|
||||
kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES),
|
||||
},
|
||||
rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) },
|
||||
mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) },
|
||||
party = party,
|
||||
boxes = boxes,
|
||||
currentBox = u8(bytes, L.wCurBox) % 16 + 1,
|
||||
inventory = inventory,
|
||||
-- Species ids, 1-based, so the set keys match save.pokedex.caught[species].
|
||||
pokedex = {
|
||||
caught = decodeDex(bytes, L.wPokedexCaught, x),
|
||||
seen = decodeDex(bytes, L.wPokedexSeen, x),
|
||||
},
|
||||
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
|
||||
-- Save.summary does `save.position.map or save.spawn`.
|
||||
position = {
|
||||
map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)],
|
||||
mapGroup = u8(bytes, L.wMapGroup), mapNumber = u8(bytes, L.wMapNumber),
|
||||
x = u8(bytes, L.wXCoord), y = u8(bytes, L.wYCoord),
|
||||
},
|
||||
playTime = {
|
||||
hours = be(bytes, L.wGameTimeHours, 2),
|
||||
minutes = u8(bytes, L.wGameTimeMinutes),
|
||||
seconds = 0, frames = 0,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Everything the cart does not carry comes from a fresh game, exactly as
|
||||
-- Gen 1's mergeDefaults does: the decode above models what the SRAM holds,
|
||||
-- and mail, phone contacts, the unown dex, the hall of fame and the RTC are
|
||||
-- left to the engine's own defaults rather than invented here.
|
||||
--
|
||||
-- Loaded lazily. src/core/gen2/Save.lua pulls in love.filesystem at require
|
||||
-- time, and this module has to stay require-clean for the headless CLI and
|
||||
-- the tests, same rule GenSave follows.
|
||||
function Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
local ok, Save = pcall(require, "src.core.gen2.Save")
|
||||
if not ok then return decoded end
|
||||
local base = Save.newGame({ playerName = decoded.player and decoded.player.name })
|
||||
for k, v in pairs(decoded) do base[k] = v end
|
||||
base.version = gameVersion
|
||||
return base
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function putU8(t, at, v) t[at] = v % 256 end
|
||||
local function putBE(t, at, v, n)
|
||||
for i = n - 1, 0, -1 do t[at + i] = v % 256; v = math.floor(v / 256) end
|
||||
end
|
||||
|
||||
-- Engine id back to the cart's number. A raw number passes through, which is
|
||||
-- how a save imported without a crosswalk round trips.
|
||||
local function indexOf(map, id)
|
||||
if id == nil then return 0 end
|
||||
if type(id) == "number" then return id end
|
||||
return map[id] or 0
|
||||
end
|
||||
|
||||
local function charBytes(str, i)
|
||||
local b = str:byte(i)
|
||||
if not b then return 0 end
|
||||
if b < 0x80 then return 1 end
|
||||
if b >= 0xF0 then return 4 end
|
||||
if b >= 0xE0 then return 3 end
|
||||
if b >= 0xC0 then return 2 end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function glyphChars(glyph)
|
||||
local n, i = 0, 1
|
||||
while i <= #glyph do i = i + charBytes(glyph, i); n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
-- One-CHARACTER glyphs plus PK and MN. The cart's table also carries the
|
||||
-- ligature halves PO and KE, and accepting those turns a name containing "PO"
|
||||
-- into 0x70 where the cart had a plain P. #glyph counts BYTES, so a byte test
|
||||
-- would also drop every multi-byte glyph and turn NIDORAN into NIDORAN?.
|
||||
local REVERSE = nil
|
||||
local function reverseCharmap()
|
||||
if REVERSE then return REVERSE end
|
||||
REVERSE = {}
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyphChars(glyph) == 1 then REVERSE[glyph] = code end
|
||||
end
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyph == "PK" or glyph == "MN" then REVERSE[glyph] = code end
|
||||
end
|
||||
return REVERSE
|
||||
end
|
||||
|
||||
local function putText(t, at, str, n)
|
||||
local codes = reverseCharmap()
|
||||
local i, written = 1, 0
|
||||
while i <= #str and written < n - 1 do
|
||||
local two = str:sub(i, i + 1)
|
||||
if (two == "PK" or two == "MN") and codes[two] then
|
||||
t[at + written] = codes[two]; i = i + 2
|
||||
else
|
||||
local w = charBytes(str, i)
|
||||
t[at + written] = codes[str:sub(i, i + w - 1)] or 0xE6
|
||||
i = i + w
|
||||
end
|
||||
written = written + 1
|
||||
end
|
||||
t[at + written] = 0x50
|
||||
end
|
||||
|
||||
local function putBadges(t, at, owned, order)
|
||||
local byte = 0
|
||||
for bit, name in ipairs(order) do
|
||||
if owned and owned[name] then byte = byte + 2 ^ (bit - 1) end
|
||||
end
|
||||
t[at] = byte
|
||||
end
|
||||
|
||||
local function putSharedMon(t, o, mon, x)
|
||||
putU8(t, o, indexOf(x.pokemonIndex, mon.species))
|
||||
putU8(t, o + 1, indexOf(x.itemIndex, mon.item))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1]))
|
||||
end
|
||||
putBE(t, o + 6, mon.otId or 0, 2)
|
||||
putBE(t, o + 8, mon.experience or 0, 3)
|
||||
local se = mon.statExp or {}
|
||||
putBE(t, o + 0x0B, se.hp or 0, 2); putBE(t, o + 0x0D, se.attack or 0, 2)
|
||||
putBE(t, o + 0x0F, se.defense or 0, 2); putBE(t, o + 0x11, se.speed or 0, 2)
|
||||
putBE(t, o + 0x13, se.special or 0, 2)
|
||||
local d = mon.dvs or {}
|
||||
putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0))
|
||||
putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
|
||||
end
|
||||
putU8(t, o + 0x1B, mon.happiness or 0)
|
||||
-- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template
|
||||
-- means reordering the party gives slot 1 the previous occupant's pokerus
|
||||
-- and caught data.
|
||||
putU8(t, o + 0x1C, mon.pokerus or 0)
|
||||
putBE(t, o + 0x1D, mon.caughtData or 0, 2)
|
||||
putU8(t, o + 0x1F, mon.level or 0)
|
||||
end
|
||||
|
||||
-- The bag back into its four pockets, by each item's own `pocket`.
|
||||
local function putBag(t, L, inventory, x)
|
||||
local buckets = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} }
|
||||
for id, count in pairs(inventory or {}) do
|
||||
local def = x.itemDefs[id]
|
||||
local pocket = (type(def) == "table" and def.pocket) or "ITEM"
|
||||
if buckets[pocket] == nil then pocket = "ITEM" end
|
||||
buckets[pocket][#buckets[pocket] + 1] = { id = id, count = count, def = def }
|
||||
end
|
||||
-- By cart index, so the order is deterministic. A flat inventory has no
|
||||
-- order of its own, so the pocket order a round trip produces is stable
|
||||
-- rather than original.
|
||||
for _, list in pairs(buckets) do
|
||||
table.sort(list, function(a, b)
|
||||
return indexOf(x.itemIndex, a.id) < indexOf(x.itemIndex, b.id)
|
||||
end)
|
||||
end
|
||||
|
||||
local overflow = nil
|
||||
local function writePairs(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do
|
||||
putU8(t, listAt + (i - 1) * 2, indexOf(x.itemIndex, list[i].id))
|
||||
putU8(t, listAt + (i - 1) * 2 + 1, math.min(list[i].count, 99))
|
||||
end
|
||||
putU8(t, listAt + n * 2, 0xFF)
|
||||
end
|
||||
local function writeIds(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do putU8(t, listAt + i - 1, indexOf(x.itemIndex, list[i].id)) end
|
||||
putU8(t, listAt + n, 0xFF)
|
||||
end
|
||||
|
||||
writePairs(L.wNumItems, L.wItems, 20, buckets.ITEM, "ITEM")
|
||||
writeIds(L.wNumKeyItems, L.wKeyItems, 25, buckets.KEY_ITEM, "KEY_ITEM")
|
||||
writePairs(L.wNumBalls, L.wBalls, 12, buckets.BALL, "BALL")
|
||||
if L.wTMsHMs then
|
||||
for _, row in ipairs(buckets.TM_HM) do
|
||||
local number = type(row.def) == "table" and row.def.tmNumber
|
||||
if number then putU8(t, L.wTMsHMs + number - 1, math.min(row.count, 99)) end
|
||||
end
|
||||
end
|
||||
return overflow
|
||||
end
|
||||
|
||||
local function putFlagSet(t, at, set, count, indexFor)
|
||||
for i = 0, count - 1 do
|
||||
local byteAt = at + math.floor(i / 8)
|
||||
local bit = 2 ^ (i % 8)
|
||||
local cur = t[byteAt] or 0
|
||||
local on = math.floor(cur / bit) % 2 == 1
|
||||
local want = set and set[indexFor(i)] == true
|
||||
if on ~= want then t[byteAt] = want and (cur + bit) or (cur - bit) end
|
||||
end
|
||||
end
|
||||
|
||||
-- encode(save, gameVersion, template, data) -> bytes, err
|
||||
--
|
||||
-- Writes into the cartridge image the save came from: Gen 2 SRAM holds a great
|
||||
-- deal this codec does not model and the real game trusts it on CONTINUE, so a
|
||||
-- save with no image behind it is refused rather than built from nothing.
|
||||
--
|
||||
-- Only the primary copy is written. TryLoadSaveFile rewrites the backup from
|
||||
-- the primary on every successful load.
|
||||
function Gen2Save.encode(save, gameVersion, template, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(save) ~= "table" then return nil, "expected a save table" end
|
||||
if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then
|
||||
return nil, "this save has no cartridge image to write back into, and a "
|
||||
.. "Gen 2 save built from nothing does not boot on real hardware"
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local t = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = template:byte(i + 1) end
|
||||
|
||||
local p = save.player or {}
|
||||
putText(t, L.wPlayerName, p.name or "", Gen2Save.NAME_LENGTH)
|
||||
putBE(t, L.wPlayerID, p.id or 0, 2)
|
||||
putBE(t, L.wMoney, p.money or 0, 3)
|
||||
putBE(t, L.wCoins, p.coins or 0, 2)
|
||||
putBadges(t, L.wBadges, p.badges, Gen2Save.JOHTO_BADGES)
|
||||
putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES)
|
||||
putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
|
||||
local party = save.party or {}
|
||||
if #party > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("a party of %d cannot be written to a cartridge"):format(#party)
|
||||
end
|
||||
putU8(t, L.wPartyCount, #party)
|
||||
for i, mon in ipairs(party) do
|
||||
putU8(t, L.wPartySpecies + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
local o = L.wPartyMons + (i - 1) * Gen2Save.PARTY_STRUCT
|
||||
putSharedMon(t, o, mon, x)
|
||||
putU8(t, o + 0x20, encodeStatus(mon.status, mon.statusTurns))
|
||||
putBE(t, o + 0x22, mon.hp or 0, 2)
|
||||
local st = mon.stats or {}
|
||||
putBE(t, o + 0x24, mon.maxHp or st.hp or 0, 2)
|
||||
putBE(t, o + 0x26, st.attack or 0, 2); putBE(t, o + 0x28, st.defense or 0, 2)
|
||||
putBE(t, o + 0x2A, st.speed or 0, 2); putBE(t, o + 0x2C, st.specialAttack or 0, 2)
|
||||
putBE(t, o + 0x2E, st.specialDefense or 0, 2)
|
||||
putText(t, L.wPartyMonNicknames + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wPartyMonOTs + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, L.wPartySpecies + #party, 0xFF)
|
||||
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local box = (save.boxes or {})[index] or {}
|
||||
if #box > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d holds %d, which a cartridge cannot"):format(index, #box)
|
||||
end
|
||||
putU8(t, base, #box)
|
||||
for i, mon in ipairs(box) do
|
||||
putU8(t, base + BOX_SPECIES + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
putSharedMon(t, base + BOX_MONS + (i - 1) * Gen2Save.BOX_MON_STRUCT, mon, x)
|
||||
putText(t, base + BOX_OTS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, base + BOX_NICKS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, base + BOX_SPECIES + #box, 0xFF)
|
||||
end
|
||||
|
||||
-- Refuse rather than drop. Without item defs every item buckets into ITEM,
|
||||
-- which holds 20, and a real bag is bigger than that.
|
||||
local overflow = putBag(t, L, save.inventory, x)
|
||||
if overflow then
|
||||
return nil, ("the %s pocket would need %d slots and a cartridge has %d; "
|
||||
.. "the item table for this game is needed to sort the bag")
|
||||
:format(overflow[1], overflow[2], overflow[3])
|
||||
end
|
||||
if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end
|
||||
if save.boxNames and L.wBoxNames then
|
||||
for i = 1, 14 do
|
||||
putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9)
|
||||
end
|
||||
end
|
||||
|
||||
if save.pokedex then
|
||||
local function species(i) return x.pokemon[i + 1] or (i + 1) end
|
||||
putFlagSet(t, L.wPokedexCaught, save.pokedex.caught, Gen2Save.NUM_SPECIES, species)
|
||||
putFlagSet(t, L.wPokedexSeen, save.pokedex.seen, Gen2Save.NUM_SPECIES, species)
|
||||
end
|
||||
for i = 0, Gen2Save.EVENT_BYTES - 1 do
|
||||
local byte = (save.events or {})[i]
|
||||
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
|
||||
end
|
||||
|
||||
local pos = save.position
|
||||
if pos then
|
||||
local ids = pos.map and x.mapIds[pos.map]
|
||||
putU8(t, L.wMapGroup, (ids and ids[1]) or pos.mapGroup or 0)
|
||||
putU8(t, L.wMapNumber, (ids and ids[2]) or pos.mapNumber or 0)
|
||||
putU8(t, L.wXCoord, pos.x or 0)
|
||||
putU8(t, L.wYCoord, pos.y or 0)
|
||||
end
|
||||
local pt = save.playTime
|
||||
if pt then
|
||||
putBE(t, L.wGameTimeHours, pt.hours or 0, 2)
|
||||
putU8(t, L.wGameTimeMinutes, pt.minutes or 0)
|
||||
end
|
||||
|
||||
putU8(t, L.sCheckValue1, 0x63)
|
||||
putU8(t, L.sCheckValue2, 0x7F)
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + t[i]) % 65536 end
|
||||
putU8(t, L.sChecksum, sum % 256)
|
||||
putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256)
|
||||
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end
|
||||
-- Whatever followed the 32 KiB of SRAM is the cart's RTC footer. Dropping it
|
||||
-- resets the clock, which costs the player daily events and the bug contest
|
||||
-- and earns them the clock-adjustment penalty.
|
||||
return table.concat(out) .. template:sub(Gen2Save.SAVE_SIZE + 1)
|
||||
end
|
||||
|
||||
return Gen2Save
|
||||
@@ -21,12 +21,23 @@
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
||||
SaveConvert.mainChecksumValid = GenSave.mainChecksumValid
|
||||
-- Is this a real save for THIS GAME? Dispatches on the generation, because
|
||||
-- the two do not share a rule: Gen 1 stores a complement checksum of its main
|
||||
-- data block, Gen 2 stores two check values plus a 16-bit sum. Run one over
|
||||
-- the other's bytes and the answer is always no.
|
||||
--
|
||||
-- gameVersion is optional and defaults to Gen 1's rule, which is what every
|
||||
-- caller meant before Gen 2 had a codec.
|
||||
function SaveConvert.mainChecksumValid(bytes, gameVersion)
|
||||
local L = gameVersion and Gen2Save.layoutFor(gameVersion)
|
||||
if L then return Gen2Save.checksumValid(bytes, L) end
|
||||
return GenSave.mainChecksumValid(bytes)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||
@@ -125,6 +136,24 @@ local function loadCacheTable(gameVersion, filePath)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The generated tables Gen2Save needs to turn cart numbers into the ids the
|
||||
-- engine is keyed by. Deliberately not ensureData: that one also demands Gen
|
||||
-- 1's charmap, event flags and hidden items, none of which a Gen 2 cache has
|
||||
-- or a Gen 2 save uses.
|
||||
local gen2Data = {}
|
||||
local function ensureGen2Data(gameVersion)
|
||||
local key = gameVersion or "*"
|
||||
if gen2Data[key] == nil then
|
||||
local out = {}
|
||||
for _, name in ipairs({ "pokemon", "moves", "items", "maps" }) do
|
||||
out[name] = loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua")
|
||||
or (loadTable("data.generated." .. name, "data/generated/" .. name .. ".lua"))
|
||||
end
|
||||
gen2Data[key] = out
|
||||
end
|
||||
return gen2Data[key]
|
||||
end
|
||||
|
||||
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
|
||||
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||
-- never be handed the previous import's data (#420).
|
||||
@@ -228,13 +257,6 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local function gen2CartName(gameVersion)
|
||||
if not GameVersion.VERSIONS[gameVersion] then return nil end
|
||||
if GameVersion.generation(gameVersion) ~= 2 then return nil end
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
end
|
||||
|
||||
-- Can a cart save for this game cross in or out at all? Public because the
|
||||
-- launcher has to ask about the GAME before it measures the BYTES.
|
||||
--
|
||||
@@ -248,19 +270,22 @@ end
|
||||
-- Returns true, or false plus the same sentence importSav/exportSav would have
|
||||
-- answered with, so a caller that asks early and a caller that does not cannot
|
||||
-- describe the same game two different ways.
|
||||
-- Does this game use a Gen 2 cart save? The RTC footer that follows one is
|
||||
-- expected rather than surprising, which the import path needs to know.
|
||||
function SaveConvert.isGen2Cart(gameVersion)
|
||||
return Gen2Save.layoutFor(gameVersion) ~= nil
|
||||
end
|
||||
|
||||
function SaveConvert.importSupported(gameVersion)
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
end
|
||||
-- Gen 2 imports through Gen2Save now. Kept as a predicate rather than
|
||||
-- deleted: SaveFileIO asks it before it measures the bytes, and export
|
||||
-- still answers no.
|
||||
return true
|
||||
end
|
||||
|
||||
-- Gen 2 exports through Gen2Save now, but only for a save with a cartridge
|
||||
-- image behind it; encode says so itself.
|
||||
function SaveConvert.exportSupported(gameVersion)
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -278,6 +303,15 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
end
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 is a different SRAM entirely: different bank map, different party
|
||||
-- struct, its own check values. Gen2Save owns it, and it needs no crosswalk
|
||||
-- tables because it decodes ids the engine already speaks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
local decoded, gen2Err = Gen2Save.decode(bytes, gameVersion,
|
||||
ensureGen2Data(gameVersion))
|
||||
if not decoded then return nil, gen2Err end
|
||||
return Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
end
|
||||
@@ -300,18 +334,23 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
function SaveConvert.exportSav(saveTable, gameVersion, cartImage)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
return Gen2Save.encode(saveTable, gameVersion, cartImage,
|
||||
ensureGen2Data(gameVersion))
|
||||
end
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Whether a Pokémon's level is printed on a screen that would normally
|
||||
-- print it (RFC 0019).
|
||||
--
|
||||
-- Every Gen 1 screen that shows a level does it with the same pokered rule
|
||||
-- -- home/pokemon.asm:335-345 PrintLevel: the <LV> tile, then the digits
|
||||
-- left-aligned after it, with a level of 100 writing its third digit back
|
||||
-- over the tile -- and each screen hand-rolls that rule against its own
|
||||
-- coordinates. This module does not touch any of that. It answers one
|
||||
-- question, in one place, so that a mode which wants the number off does
|
||||
-- not have to know four call sites and a glyph code.
|
||||
--
|
||||
-- Default is true, and `Runtime.wantsHook` is checked first, so a build
|
||||
-- with no mod wrapping the hook prints exactly what it always did and pays
|
||||
-- nothing for the seam on a per-frame draw path.
|
||||
--
|
||||
-- `where` names the surface rather than the widget, because the number
|
||||
-- means different things on different screens: on the battle HUD an
|
||||
-- opponent's level is information about them, on the party and status
|
||||
-- screens your own level is information about you. A mode can hide one and
|
||||
-- keep the other.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local LevelDisplay = {}
|
||||
|
||||
-- where: "battle.enemy" | "battle.player" | "party" | "summary"
|
||||
function LevelDisplay.visible(mon, where, game)
|
||||
if not Runtime.wantsHook("pokemon.level_visible") then return true end
|
||||
return Runtime.call("pokemon.level_visible", function() return true end,
|
||||
mon, { where = where, game = game }) ~= false
|
||||
end
|
||||
|
||||
return LevelDisplay
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -794,7 +795,11 @@ function PartyMenu:draw()
|
||||
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
||||
-- status/FNT text at column 17 (PrintStatusCondition), like the
|
||||
-- original rows -- statused mons keep their level display
|
||||
if mon.level < 100 then
|
||||
if not LevelDisplay.visible(mon, "party", self.game) then -- RFC 0019
|
||||
-- the level column is simply empty; the status/FNT column at 17 is a
|
||||
-- separate field and still prints, exactly as it does for a mon whose
|
||||
-- level is on screen
|
||||
elseif mon.level < 100 then
|
||||
HudTiles.tile(0x6E, 104, y) -- <LV>
|
||||
Font.draw(tostring(mon.level), 112, y)
|
||||
else
|
||||
|
||||
+10
-3
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
@@ -137,7 +138,9 @@ function SummaryMenu:draw()
|
||||
if self.page == 1 then
|
||||
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
|
||||
-- (9,2) 5x10 (status_screen.asm:303-305). #280
|
||||
printLevel(14, 2, mon.level)
|
||||
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||
printLevel(14, 2, mon.level)
|
||||
end
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
-- engine/pokemon/status_screen.asm:120-125
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -196,8 +199,12 @@ function SummaryMenu:draw()
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||
-- the '<to>' arrow is half a sentence without the level it points at,
|
||||
-- so the pair is hidden together
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
end
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
Reference in New Issue
Block a user