Files
gen1recomp/src/core/gen2/Save.lua
T
bryanthaboi 6731937841 Pulled every open Gold bug (bodies, comments, screenshots/videos), matched them against the port and ../pokegold, then split them so each fixer owns a disjoint file set. Duplicates collapsed: #1164 and #1188 are the same Radio Tower stair softlock. #1150 and #1190 are the same Gold rebind freeze. Enhancements (#1161, #1130, #1112, #1100) were left out. Battle 1 src/battle/gen2/*, src/ui/gen2/BattleState.lua #1168 multi-hit always 2 #1180 DV / stats #1185 learn-move text order after a trainer fight #1152 no "Use next POKéMON?" / run after a wild faint Overworld 2 src/world/gen2/*, trade anim, hidden items #1184 Kurt blocking Slowpoke Well #1164 / #1188 Radio Tower gentleman on the stairs #1173 Rock Smash rocks walk left each visit #1172 last item name used Rock Smash #1167 Ilex Forest hidden items #1165 trade movie + ledge hop still missing (#1121 / #1126) UI 3 naming, party, mart, pokegear #1166 grey nickname screen #1162 TM/HM missing ABLE / NOT ABLE #1169 underground herb lady whites out the map #1151 Pokegear black player icon + extra A/B on the phone card Platform 4 Gold options, save slots, pack, mod tab #1150 / #1190 controls freeze #1178 no vibration row #1177 cannot move Gold touch controls #1107 SAVE with no launcher slot #1145 Gold mod tab still lists Gen 1-only mods #1192 RGXX missing tools/rom_manifest_gold.json Drivers only for the progress / data-loss ones (faint-run, multi-hit, Kurt, Radio Tower, Rock Smash shift, herb shop, save slot). No new unit tests. New comments are pokegold file.asm:line only. A verifier agent will get this same list once the four finish and check that the diffs actually fix the reports. Verifier checked the batch against pokegold. Almost everything landed. I closed the two leftovers that were still real bugs: #1185: the active mon no longer reprints GrewToLevel (engine/battle/core.asm:7044). Bench mons still print it, then the stats box, then the learn-move line. #1180: the party list recalcs stats on open, same as Summary. Gold boot now applies options.touchControls / haptics from the Gold options block, not the shared Red pad. # Verdict 1168 multi-hit FIXED 1180 DVs / stats FIXED 1185 learn-move order FIXED (was partial) 1152 run after faint FIXED 1184 Kurt FIXED 1164 / 1188 Radio Tower FIXED 1173 rocks slide FIXED 1172 Rock Smash name FIXED 1167 Ilex hidden items SKIP-OK (already in the Gold cache) 1165 trade + ledge FIXED 1166 nickname color FIXED 1162 TM ABLE / NOT ABLE FIXED 1169 herb shop white-out FIXED 1151 Pokegear FIXED 1150 / 1190 rebind freeze FIXED 1178 vibration FIXED 1177 touch layout FIXED 1107 save slot FIXED 1145 mod tab filter FIXED 1192 RGXX Gold manifest FIXED Nothing is committed. Drivers, from the repo root: POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . Fury Attack should hit 2-5 times. Always twice is still broken. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . After the lead faints: "Use next POKéMON?" YES opens the party. NO/B tries to run. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . Kurt at the well entrance (16, 14), not on the inner path. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . Gentleman in the 5F office (3, 6). Stairs at (12, 0) clear. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . Route 40 rocks stay put. A slide one cell left is the old bug. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . Herb shop intro over the Underground map, not a white screen. POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . Look for PASS and a saves/gold/slot*.lua path. CONTINUE should see it. Worth a hand check with no driver: nickname color, a TM for ABLE/NOT ABLE, Pokegear phone B-to-close, OPTION > CONTROLS, a ledge hop, a trade movie, and on a phone OPTION > TOUCH LAYOUT / VIBRATION. give me CLOSES #93939, in a comma separated list each saying CLOSES before each issue number that is fixed CLOSES #1107, CLOSES #1145, CLOSES #1150, CLOSES #1151, CLOSES #1152, CLOSES #1162, CLOSES #1164, CLOSES #1165, CLOSES #1166, CLOSES #1168, CLOSES #1169, CLOSES #1172, CLOSES #1173, CLOSES #1177, CLOSES #1178, CLOSES #1180, CLOSES #1184, CLOSES #1185, CLOSES #1188, CLOSES #1190, CLOSES #1192
2026-08-12 16:51:27 -04:00

805 lines
36 KiB
Lua

-- Gen 2 save file.
--
-- Deliberately separate from src/core/SaveData.lua rather than a branch inside
-- it: that module's shape is Gen 1's SRAM (Kanto badges, 12 boxes of 20,
-- pikachu happiness, the Gen 1 party struct with one `special` stat), and its
-- validate/migration chain asserts against that shape. A Gold save has a
-- different party struct (SpA/SpD, held item, happiness, pokerus), different
-- boxes, a phone book, a Pokedex with two orderings, and an RTC.
--
-- What IS shared, on purpose:
-- * SaveSerializer, so both generations' files are the same Lua-table format
-- and the standalone save editor can read either
-- * the save-file naming convention (GameVersion.saveSuffix -> save_gold.lua
-- plus .bak / .tmp), so Gold sits beside Red/Blue/Yellow without touching
-- them, and the same atomic write dance protects it
-- * options.lua, which is version-independent and survives New Game
--
-- Layout notes taken from the cart: a New Game starts at SPAWN_HOME
-- (PLAYERS_HOUSE_2F 3,3 -- engine/menus/intro_menu.asm NewGame), the money cap
-- is 999999, and playtime is kept as h/m/s/frames the way wGameTime* is.
local GameVersion = require("src.core.GameVersion")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Mail = require("src.core.gen2.Mail")
local MomShopping = require("src.core.gen2.MomShopping")
local Logger = require("src.core.Logger")
-- The mod hook bus. Same module the Gen 1 save reaches for
-- (src/core/SaveData.lua), because the hook NAMES are shared across
-- generations: a mod that wraps save.new_game reshapes either game's skeleton
-- without knowing which one it is running under. Null objects until a loader
-- installs the live buses, so a mod-free boot and every headless test pay
-- nothing for the call.
local Runtime = require("src.mods.Runtime")
local SaveSerializer = require("src.core.SaveSerializer")
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
end
return math.random(a, b)
end
local Save = {}
-- Bumped whenever a field's meaning changes; migrations key off it.
--
-- 1 -> 2 the Hall of Fame roster (sHallOfFame + wHallOfFameCount) and
-- wSpawnAfterChampion. A format-1 save predates the endgame, so it
-- has neither and the migration is the empty roster.
-- 2 -> 3 scriptMem, the script VM's sparse WRAM store. A format-2 save
-- kept those bytes per-session, so the migration is the empty table:
-- every address reads back as 0, which is what the cart's own
-- zero-filled WRAM gives a save that never touched one.
-- 3 -> 4 mail: sPartyMail (six `mailmsg` structs keyed by PARTY SLOT) and
-- sMailboxes + sMailboxCount (the PC's MAILBOX). A format-3 save
-- has neither and cannot have a letter anywhere, so the upgrade is
-- the empty pair -- and a mon in one of those saves carrying a MAIL
-- item is exactly the case Mail.sendToPc's blank-struct fallback
-- covers.
-- 4 -> 5 `events` (wEventFlags) and `mapScenes` (the w<Map>SceneID block)
-- became LOAD BEARING. Both fields existed in format 4 and both
-- were written on every save, but nothing ever read them back --
-- World:loadPlayerData is what does now -- so a format-4 file's
-- copies had never been validated by anything. The migration is
-- the pair of tables; Save.validate is what scrubs them from here
-- on, the same way it always has for scriptMem.
-- 5 -> 6 `playerState` (wPlayerState), which sits in the same sPlayerData
-- block the two fields above do and had never been written at all:
-- a format-5 save made on the BICYCLE or aboard a Lapras came back
-- on foot. There is nothing to carry across, so the upgrade is
-- PLAYER_NORMAL -- the cart's own zero byte, and what those files
-- have effectively been loading as all along.
-- 6 -> 7 `mom.whichItem` (wWhichMomItem) and `mom.triggerBalance`
-- (wMomItemTriggerBalance), the two bytes MomTriesToBuySomething
-- walks (src/core/gen2/MomShopping.lua). NewGame seeds them in
-- engine/menus/intro_menu.asm, so a file made before Mom could
-- spend anything upgrades to exactly those seeds: the ladder on its
-- first rung and the consolation threshold at MOM_MONEY. A save
-- that already has savings banked therefore starts buying from the
-- bottom of the list, which is what a cartridge whose owner had
-- saved that much would also do.
Save.FORMAT = 7
Save.MAX_MONEY = 999999
Save.MAX_COINS = 9999
-- constants/pokemon_data_constants.asm: 6 party slots, 14 boxes of 20.
Save.PARTY_SIZE = 6
Save.NUM_BOXES = 14
Save.MONS_PER_BOX = 20
-- wEventFlags is `flag_array NUM_EVENTS` (ram/wram.asm) and NUM_EVENTS is
-- $800, so the bitfield is 256 bytes and a byte index past the last one cannot
-- have come from the cart.
Save.EVENT_BYTES = 256
-- wPlayerState (constants/ram_constants.asm), kept by NAME rather than as the
-- raw byte so a save that round-trips one stays readable. These are the four
-- strings src/world/gen2/FieldMoves.lua names the states by, and this is the
-- set World:loadPlayerData tests a restored value against as well, so the two
-- ends of the round trip cannot drift apart. PLAYER_SKATE has no entry for
-- the same reason FieldMoves has no name for it: nothing in Gold writes it.
Save.PLAYER_NORMAL = "normal"
Save.PLAYER_STATES = {
normal = true, bike = true, surf = true, surf_pika = true,
}
local function saveNames(version)
version = version or "gold"
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
-- the flat save_<version>.lua when no slot is registered.
--
-- The launcher's slot system (src/core/SaveData.lua) migrates a flat
-- save_gold.lua into saves/<version>/<slot>.lua the first time it lists the
-- version's slots -- and DELETES the flat file. This module read the flat
-- name unconditionally, so after that migration the Gold title screen found
-- no save and dropped CONTINUE (and a following SAVE wrote a second copy to
-- the flat path the launcher no longer looks at). Reading through the same
-- slot resolution keeps the in-game load/save and the launcher on one file.
local ok, SaveData = pcall(require, "src.core.SaveData")
local slot = ok and SaveData.activeSlot and SaveData.activeSlot(version) or nil
if slot then
local main = "saves/" .. version .. "/" .. slot .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
Save.filenames = saveNames
local function fs()
-- portable.txt: same standard/portable root as Gen 1 (SaveData.persistenceFs).
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.persistenceFs then return SaveData.persistenceFs() end
return love.filesystem
end
-- A fresh Gold save. `opts` carries what the intro collected: player name,
-- rival name, and the options the OPTION screen was left on.
function Save.newGame(opts)
opts = opts or {}
local save = {
format = Save.FORMAT,
version = "gold",
generation = 2,
player = {
name = opts.playerName or "GOLD",
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535),
gender = opts.gender or "male",
money = 3000,
coins = 0,
badges = {},
kantoBadges = {},
},
-- NewGame seeds wRivalName with "???", not with SILVER: _ResetWRAM calls
-- InitializeNPCNames (engine/menus/intro_menu.asm:131, :193-214), whose
-- .Rival row is literally `db "???@"`. SILVER is NameRival's InitName
-- FALLBACK (engine/events/specials.asm:80-91), copied in only after the
-- naming screen has closed on a blank entry, so the rival is "???" for
-- every {RIVAL} line and every RIVAL1 battle before the officer scene --
-- the Cherrygrove fight included.
rival = { name = opts.rivalName or "???" },
-- wMomSavingMoney's two bits BankOfMom actually flips (MOM_ACTIVE_F,
-- MOM_SAVING_SOME_MONEY_F -- src/script/gen2/Specials.lua H.BankOfMom):
-- `active` is "the bank conversation has happened at least once", which
-- gates whether a later visit opens on InitializeBank or on
-- IsThisAboutYourMoney; `savingMoney` is only meaningful once active.
--
-- `whichItem` and `triggerBalance` are wWhichMomItem and
-- wMomItemTriggerBalance, both written by NewGame itself
-- (engine/menus/intro_menu.asm): the MomItems_2 ladder starts on its
-- first rung and the consolation threshold starts at MOM_MONEY.
mom = { name = opts.momName or "MOM", active = false, savingMoney = false,
savedMoney = 0, whichItem = 0,
triggerBalance = MomShopping.MOM_MONEY },
-- Where the world resumes. nil means "use SPAWN_HOME".
position = nil,
-- Last Pokecenter, for a whiteout warp.
spawn = "SPAWN_HOME",
-- wPlayerState. A New Game starts on foot; the BICYCLE and SURF are what
-- write it, and it rides the save because the sprite, the step duration
-- and the tiles a step may land on all follow from it
-- (World:loadPlayerData).
playerState = Save.PLAYER_NORMAL,
-- sHallOfFame + wHallOfFameCount: `count` is how many times the champion
-- has been beaten (capped at HOF_MASTER_COUNT) and `teams` is the roster,
-- newest first, NUM_HOF_TEAMS deep. src/core/gen2/HallOfFame.lua owns
-- every read and write of it.
hallOfFame = { count = 0, teams = {} },
-- wSpawnAfterChampion, a one-shot: set by the induction, consumed by the
-- next CONTINUE (HallOfFame.consumePostGameSpawn). nil means "resume
-- where the save says", which is every ordinary load.
spawnAfterChampion = nil,
party = {},
boxes = {},
currentBox = 1,
boxNames = {},
inventory = {},
-- Gen 2 splits the bag into four pockets (ITEM / KEY_ITEM / BALL / TM_HM);
-- `inventory` stays the flat id->count map Gen 1's Bag uses, and PackMenu
-- buckets it by each item's extracted `pocket`.
--
-- wWhichRegisteredItem/wRegisteredItem (engine/overworld/select_menu.asm):
-- the item the SELECT button dispatches, set from the PACK
-- (World:registerItem) and re-validated against the live inventory on
-- every SELECT press (World:registeredItemId). nil means nothing is
-- registered, the same as the cart's byte being 0.
registeredItem = nil,
-- MAIL, both SRAM regions (src/core/gen2/Mail.lua): `party` is sPartyMail
-- keyed by party slot and `box` is sMailboxes, with sMailboxCount implied
-- by its length.
mail = { party = {}, box = {} },
pcItems = {},
phoneContacts = {},
tradeFlags = {},
pokedex = { seen = {}, caught = {} },
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
-- record beside the #DEX because the #DEX knows only the species
-- (src/core/gen2/Unown.lua).
unownDex = {},
-- wFirstUnownSeen (ram/wram.asm:2703): the form letter of the FIRST Unown
-- the player ever met, latched once (engine/battle/core.asm:7894-7902) and
-- read back by the #DEX entry. 0 means "none yet", the cart's zero byte.
firstUnownSeen = 0,
events = {},
flags = {},
mapScenes = {},
-- The script VM's sparse WRAM store (src/script/gen2/Vm.lua `mem`):
-- address -> byte, for the addresses Script_readmem / Script_writemem
-- (engine/overworld/scripting.asm) poke that the port has nowhere else to
-- keep -- wUndergroundSwitchPositions in the Goldenrod underground and
-- wMooMooBerries at the Route 39 barn. Sparse on purpose: the cart's WRAM
-- is 8K and a save has no business carrying a dense image of it, only the
-- handful of bytes a script actually wrote.
scriptMem = {},
playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 },
-- RTC bookkeeping: which real day the save last saw, so daily events can
-- roll over (engine/rtc/rtc.asm StageRTCTimeForSave).
rtc = { day = tonumber(os.date("%j")) or 1, hour = tonumber(os.date("%H")) or 0,
minute = tonumber(os.date("%M")) or 0 },
options = nil, -- lives in options.lua; see SaveData.saveOptions
createdAt = os.time(),
}
-- Same hook, same name, same contract as Gen 1's SaveData.newGame: a total
-- conversion reshapes the skeleton (spawn, party, money) before anything
-- reads it. Unhooked this returns save unchanged, and it is the SAME table
-- so a caller holding the literal is never left behind.
return Runtime.call("save.new_game", function(s) return s end, save)
end
-- Gen 2's OPTION screen (engine/menus/options_menu.asm StringOptions).
-- Values are stored as names so a save stays readable and a changed enum
-- ordering cannot silently repoint an option.
Save.DEFAULT_OPTIONS = {
textSpeed = "MID", -- FAST / MID / SLOW
battleScene = true, -- animations on
battleStyle = "SHIFT", -- SHIFT / SET
sound = "MONO", -- MONO / STEREO
print = "NORMAL", -- LIGHTEST..DARKEST
menuAccount = true, -- show the start menu's description box
frame = 1, -- textbox frame 1-8
-- Port options, not the cart's. These are the same keys the Gen 1 save
-- uses (src/core/SaveData.lua) and they drive the same shared modules, so
-- a player's display and speed choices mean the same thing in both games.
speed = 1, -- GameSpeed.LEVELS multiplier, logic only
zoom = 0, -- Zoom offset from the window's fit scale
tilt = 0, -- Tilt.LEVELS degrees, 0 = off
gbcfx = 0, -- GBCFX ladder, 0 = off
-- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the
-- default -- this is a Game Boy Color game, so colour is ON out of the box
-- and the other two rungs are the deliberate step DOWN to a grey or green
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name.
color = "gbc",
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
haptics = "light",
touchControls = { enabled = true },
}
function Save.defaultOptions()
local out = {}
for key, value in pairs(Save.DEFAULT_OPTIONS) do out[key] = value end
return out
end
-- ------- options.lua
--
-- Gold's options live in the shared options.lua under their own `gold` key,
-- not on the flat path the Gen 1 keys use. Several names collide across the
-- two generations with DIFFERENT types -- battleStyle is "shift" in Gen 1 and
-- "SHIFT" here, textSpeed a frame delay there and a label here -- so sharing
-- the flat namespace would have each game quietly corrupting the other's
-- settings. The file itself is shared, which is what lets the launcher's
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
Save.OPTIONS_KEY = "gold"
function Save.loadOptions(fs)
local options = Save.defaultOptions()
local ok, SaveData = pcall(require, "src.core.SaveData")
if not ok then return options end
local loaded = SaveData.loadOptions(fs)
local stored = loaded and loaded[Save.OPTIONS_KEY]
if type(stored) == "table" then
for key, value in pairs(stored) do options[key] = value end
end
return options
end
-- Read-modify-write, so writing Gold's block never drops the Gen 1 keys (or
-- the slot registry, or modOptions) sitting beside it.
function Save.saveOptions(options, fs)
if type(options) ~= "table" then return false end
local ok, SaveData = pcall(require, "src.core.SaveData")
if not ok then return false end
local file = SaveData.loadOptions(fs) or {}
local block = {}
for key, value in pairs(options) do block[key] = value end
file[Save.OPTIONS_KEY] = block
SaveData.saveOptions(file, fs)
return true
end
-- MON_PKRS is one byte in the party and box structs, and every reader of it
-- (src/core/gen2/Pokerus.lua) splits it into two nybbles -- so a file that
-- somehow grew a float, a negative or a value past 255 there would hand out a
-- strain and a day count that no cartridge could produce. Folded rather than
-- dropped, the way the money and coin caps above are clamped rather than reset.
local function normalizePokerus(mons)
for _, mon in ipairs(mons or {}) do
if type(mon) == "table" and mon.pokerus ~= nil then
local value = tonumber(mon.pokerus) or 0
if value < 0 then value = 0 end
mon.pokerus = math.floor(value) % 256
end
end
end
-- Fill in anything a save (or an older save) is missing, so callers can index
-- freely. Runs on both newGame and load.
function Save.normalize(save)
if type(save) ~= "table" then return nil end
save.format = save.format or Save.FORMAT
save.version = "gold"
save.generation = 2
save.player = save.player or {}
save.player.name = save.player.name or "GOLD"
save.player.id = save.player.id or rand(0, 65535)
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
save.player.badges = save.player.badges or {}
save.player.kantoBadges = save.player.kantoBadges or {}
-- Same InitializeNPCNames seed as newGame: a save carrying no rival field is
-- a save that has not reached the officer, so it reads "???" rather than
-- NameRival's post-screen default.
save.rival = save.rival or { name = "???" }
save.mom = save.mom or {}
save.mom.name = save.mom.name or "MOM"
-- An older save (or one normalized before H.BankOfMom existed) has a `mom`
-- table with no `active`/`savingMoney` at all; both default to unset the
-- same way a cartridge that has never run BankOfMom reads wMomSavingMoney
-- as zero -- the bank has never been talked to and nothing is being saved.
if save.mom.active == nil then save.mom.active = false end
if save.mom.savingMoney == nil then save.mom.savingMoney = false end
save.mom.savedMoney = math.max(0, math.min(
tonumber(save.mom.savedMoney) or 0, Save.MAX_MONEY))
-- wWhichMomItem indexes MomItems_2 and wMomItemTriggerBalance is a money
-- field, so both are folded the way every other counter here is: an index
-- past the end of the ladder is what CheckBalance_MomItem2's own `cp
-- (MomItems_2.End - MomItems_2) / MOMITEM_SIZE` treats as "no rung left",
-- which is a legal resting state and not a value to clamp away.
save.mom.whichItem = math.max(0, math.floor(tonumber(save.mom.whichItem) or 0))
save.mom.triggerBalance = math.max(0, math.min(
math.floor(tonumber(save.mom.triggerBalance) or MomShopping.MOM_MONEY),
Save.MAX_MONEY + MomShopping.MOM_MONEY))
save.party = save.party or {}
save.boxes = save.boxes or {}
save.boxNames = save.boxNames or {}
save.currentBox = save.currentBox or 1
save.inventory = save.inventory or {}
-- Mail.state creates both SRAM regions on demand, so an older save (or one a
-- driver built by hand) can be indexed freely from the first letter on.
Mail.state(save)
save.pcItems = save.pcItems or {}
save.phoneContacts = save.phoneContacts or {}
-- wTradeFlags: one bit per NPC_TRADE_*, so a trade only ever happens once.
-- A set here, keyed by the trade's own id (src/core/gen2/NpcTrade.lua).
save.tradeFlags = save.tradeFlags or {}
save.pokedex = save.pokedex or {}
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.caught = save.pokedex.caught or {}
-- wUnownDex is NUM_UNOWN bytes; a file that somehow grew past that is
-- trimmed for the same reason an over-long party is.
save.unownDex = save.unownDex or {}
while #save.unownDex > 26 do table.remove(save.unownDex) end
-- wFirstUnownSeen is one byte holding a letter index 1..NUM_UNOWN, or 0
-- before any Unown has been met; anything else is a file that was edited.
local firstUnown = tonumber(save.firstUnownSeen) or 0
firstUnown = math.floor(firstUnown)
if firstUnown < 0 or firstUnown > 26 then firstUnown = 0 end
save.firstUnownSeen = firstUnown
-- wEventFlags, as the SERIALIZED BITFIELD src/world/gen2/Events.lua writes:
-- byte index -> byte value, sparse, keyed by NUMBER and not by name. Empty
-- means the file predates InitializeEventsScript ever running, which is what
-- World:loadPlayerData falls back to the seed on.
save.events = save.events or {}
save.flags = save.flags or {}
-- The w<Map>SceneID block (ram/wram.asm), as map id -> scene id. A map with
-- no entry is on scene 0, the same as the cart's zero-filled byte.
save.mapScenes = save.mapScenes or {}
-- wPlayerState. A file that predates the field reads as PLAYER_NORMAL, the
-- same as the cart's zero byte; Save.validate is what rejects a name no
-- cartridge could have produced.
save.playerState = save.playerState or Save.PLAYER_NORMAL
save.scriptMem = save.scriptMem or {}
save.playTime = save.playTime
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
save.rtc = save.rtc or {}
-- HallOfFame.record fills in the count and the roster list, and trims a
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
-- party gets below, for the same reason.
local hof = HallOfFame.record(save)
while #hof.teams > HallOfFame.NUM_TEAMS do
table.remove(hof.teams)
end
-- Trim an over-long party rather than letting a corrupt file feed a
-- seventh mon into battle.
while #save.party > Save.PARTY_SIZE do
table.remove(save.party)
end
normalizePokerus(save.party)
for _, box in pairs(save.boxes) do
if type(box) == "table" then normalizePokerus(box) end
end
-- move_mon.asm:143-149: a mon the player owns carries wPlayerID; saves
-- written before the stamp existed get it here.
local Mon = require("src.battle.gen2.Mon")
for _, mon in ipairs(save.party) do Mon.stampOT(save, mon) end
for _, box in pairs(save.boxes) do
if type(box) == "table" then
for _, mon in ipairs(box) do Mon.stampOT(save, mon) end
end
end
return save
end
-- Migrations, oldest first. Each entry upgrades a save at `from` to `from+1`.
Save.MIGRATIONS = {
-- 1 -> 2: the endgame landed. A format-1 save was written before the Hall
-- of Fame existed, so it has no roster and cannot have been inducted; the
-- upgrade is the empty block, and wSpawnAfterChampion stays nil so the first
-- load after the upgrade is an ordinary CONTINUE rather than a warp to New
-- Bark Town.
[1] = function(save)
save.hallOfFame = save.hallOfFame or { count = 0, teams = {} }
save.spawnAfterChampion = nil
end,
-- 2 -> 3: the script VM's readmem / writemem bytes started riding the save.
-- Nothing to carry across (a format-2 file never wrote them down), so the
-- upgrade is the empty store and every address reads back 0.
[2] = function(save)
save.scriptMem = save.scriptMem or {}
end,
-- 3 -> 4: MAIL. A format-3 file predates sPartyMail and sMailboxes
-- entirely, so there is nothing to carry across and the upgrade is the empty
-- pair. A mon in one of those saves may still be HOLDING a mail item
-- (`givepokemail` used to hand one over with no struct behind it), and that
-- mon reads back as holding a blank letter rather than as holding nothing --
-- which is what the cart's own zero-filled struct would say too.
[3] = function(save)
save.mail = save.mail or { party = {}, box = {} }
end,
-- 4 -> 5: the world state started being read back. A format-4 file already
-- carries both tables (the snapshot has always written them), so this is not
-- a conversion -- it is the point at which they stop being write-only, and a
-- file that never had them gets the empty pair. An empty `events` is the
-- honest answer for a save whose world never ran: World:loadPlayerData reads
-- it as "InitializeEventsScript has not happened yet" and applies the seed,
-- which is the same branch PlayersHouse2FInitializeRoomCallback takes.
[4] = function(save)
save.events = save.events or {}
save.mapScenes = save.mapScenes or {}
end,
-- 5 -> 6: wPlayerState. Unlike the pair above, this field was never written
-- by anything, so there is genuinely nothing to carry across and every
-- format-5 file upgrades to PLAYER_NORMAL -- which is exactly what those
-- saves already came back as, because a world that read no state started on
-- foot. The `or` is for a file some other tool put a state in.
[5] = function(save)
save.playerState = save.playerState or Save.PLAYER_NORMAL
end,
-- 6 -> 7: Mom's shopping pair. Nothing could have written either byte
-- before MomTriesToBuySomething existed, so the upgrade is NewGame's own
-- seed and a file that somehow carries one keeps it.
[6] = function(save)
save.mom = save.mom or {}
if save.mom.whichItem == nil then save.mom.whichItem = 0 end
if save.mom.triggerBalance == nil then
save.mom.triggerBalance = MomShopping.MOM_MONEY
end
end,
}
function Save.migrate(save)
local format = tonumber(save.format) or 1
while format < Save.FORMAT do
local step = Save.MIGRATIONS[format]
if not step then break end
step(save)
format = format + 1
save.format = format
end
return save
end
-- ------- validation and quarantine
--
-- Same discipline as src/core/SaveData.lua's validate: a value play would
-- nil-index or wrap on never reaches the game, and whatever had to be dropped
-- is reported rather than vanishing silently. scriptMem is the field that
-- needs it most, because its keys are raw WRAM addresses rather than ids out
-- of a table this port owns, so nothing else can vouch for them.
-- Script_readmem / Script_writemem (engine/overworld/scripting.asm) read a
-- two-byte address and move a single byte through wScriptVar, so an entry
-- outside 0..$ffff / 0..255 cannot have come from a script running here.
-- Keys survive the serializer as strings in some files, hence the tonumber.
local function scrubScriptMem(save, report)
local mem = save.scriptMem
if type(mem) ~= "table" then
if mem ~= nil then
report.lostScriptMem[#report.lostScriptMem + 1] =
{ addr = nil, value = mem }
end
save.scriptMem = {}
return
end
local clean = {}
for key, value in pairs(mem) do
local addr, byte = tonumber(key), tonumber(value)
local okAddr = addr and addr == math.floor(addr)
and addr >= 0 and addr <= 0xFFFF
local okByte = byte and byte == math.floor(byte)
and byte >= 0 and byte <= 255
if okAddr and okByte then
clean[addr] = byte
else
report.lostScriptMem[#report.lostScriptMem + 1] =
{ addr = key, value = value }
end
end
save.scriptMem = clean
end
-- wEventFlags is 256 bytes of bitfield and nothing else in the save vouches
-- for a byte index, so it gets the same treatment scriptMem does: an index
-- past the last byte or a value that is not a byte could not have come from
-- the cart's array, and handing one to Events:restore would put a flag id no
-- object can ever name into the live bitfield. Keys survive the serializer as
-- strings in some files, hence the tonumber.
local function scrubEvents(save, report)
local flags = save.events
if type(flags) ~= "table" then
if flags ~= nil then
report.lostEvents[#report.lostEvents + 1] = { byte = nil, value = flags }
end
save.events = {}
return
end
local clean = {}
for key, value in pairs(flags) do
local index, byte = tonumber(key), tonumber(value)
local okIndex = index and index == math.floor(index)
and index >= 0 and index < Save.EVENT_BYTES
local okByte = byte and byte == math.floor(byte)
and byte >= 0 and byte <= 255
if okIndex and okByte then
clean[index] = byte
else
report.lostEvents[#report.lostEvents + 1] = { byte = key, value = value }
end
end
save.events = clean
end
-- The w<Map>SceneID block: one BYTE per map, keyed here by the map id the
-- cache uses rather than by the WRAM address, because that is what
-- World:mapSceneOf looks up. An id this cache does not know is left alone --
-- nothing ever reads it, the same way an unused scene byte sits in WRAM -- but
-- a key that is not a map id at all, or a scene that is not a byte, is dropped
-- rather than handed to a scene-script lookup that would index past its arms.
local function scrubMapScenes(save, report)
local scenes = save.mapScenes
if type(scenes) ~= "table" then
if scenes ~= nil then
report.lostMapScenes[#report.lostMapScenes + 1] =
{ map = nil, scene = scenes }
end
save.mapScenes = {}
return
end
local clean = {}
for key, value in pairs(scenes) do
local scene = tonumber(value)
local okMap = type(key) == "string" and key ~= ""
local okScene = scene and scene == math.floor(scene)
and scene >= 0 and scene <= 255
if okMap and okScene then
clean[key] = scene
else
report.lostMapScenes[#report.lostMapScenes + 1] =
{ map = key, scene = value }
end
end
save.mapScenes = clean
end
-- wPlayerState is one byte on the cart and one of four names here, so anything
-- else -- a raw byte out of a hand-edited file, a state this port has never
-- had -- is dropped back to PLAYER_NORMAL. Left alone it would give the
-- player a sprite lookup with no row of its own and step rules that belong to
-- nobody: neither isBiking nor isSurfing would answer true, so they would walk
-- at walking pace over land while the save insisted they were somewhere else.
local function scrubPlayerState(save, report)
local state = save.playerState
if Save.PLAYER_STATES[state] then return end
if state ~= nil then
report.lostPlayerState[#report.lostPlayerState + 1] = { state = state }
end
save.playerState = Save.PLAYER_NORMAL
end
function Save.validate(save)
local report = { lostScriptMem = {}, lostMail = {}, lostEvents = {},
lostMapScenes = {}, lostPlayerState = {} }
if type(save) ~= "table" then return report end
scrubScriptMem(save, report)
-- The world state World:loadPlayerData hands back to the live game: the
-- event bitfield, the per-map scene ids and wPlayerState. All three are
-- read on every load, so all three have to be trustworthy before the first
-- map comes up.
scrubEvents(save, report)
scrubMapScenes(save, report)
scrubPlayerState(save, report)
-- The `mailmsg` structs get the same treatment for the same reason: their
-- `type` byte is an item id nothing else in the save vouches for, and a
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
-- cart could not have written. Mail.validate owns the rules; this is only
-- where the ledger is collected (src/core/gen2/Mail.lua).
Mail.validate(save, report)
return report
end
-- True when nothing was quarantined, so a vanilla save loads without a word.
function Save.emptyReport(report)
if type(report) ~= "table" then return true end
return #(report.lostScriptMem or {}) == 0
and #(report.lostMail or {}) == 0
and #(report.lostEvents or {}) == 0
and #(report.lostMapScenes or {}) == 0
and #(report.lostPlayerState or {}) == 0
end
-- Does a Gold save exist? This is what decides whether the intro menu offers
-- CONTINUE (engine/menus/main_menu.asm MainMenu_GetWhichMenu reads
-- wSaveFileExists for exactly this).
function Save.exists(version)
local main, backup = saveNames(version)
local f = fs()
if not f then return false end
return (f.getInfo(main) ~= nil) or (f.getInfo(backup) ~= nil)
end
local function readTable(path)
local f = fs()
if not f or not f.getInfo(path) then return nil, "missing" end
local raw = f.read(path)
if not raw then return nil, "unreadable" end
local ok, value = pcall(SaveSerializer.decode, raw)
if not ok or type(value) ~= "table" then
return nil, "corrupt: " .. tostring(value)
end
return value
end
-- Returns save, recovered ("bak"/"tmp" when a staged or backup copy had to be
-- promoted), err, report (the quarantine ledger from Save.validate; empty for
-- any save this port wrote itself).
function Save.load(version)
local main, backup, tmp = saveNames(version)
local data, err = readTable(main)
local recovered
if not data then
local staged = readTable(tmp)
if staged then
data, recovered = staged, "tmp"
else
local prev = readTable(backup)
if prev then data, recovered = prev, "bak" end
end
end
if not data then return nil, nil, err end
Save.migrate(data)
Save.normalize(data)
local report = Save.validate(data)
if not Save.emptyReport(report) then
-- Gold has no report screen of its own yet; the log is what keeps a
-- quarantine from being invisible, the way Game.lua falls back for Gen 1.
Logger.warn(
"gold load report: %d script memory byte(s), %d MAIL struct(s), " ..
"%d event byte(s), %d map scene(s) and %d player state(s) dropped",
#report.lostScriptMem, #report.lostMail, #report.lostEvents,
#report.lostMapScenes, #report.lostPlayerState)
end
return data, recovered, nil, report
end
-- Atomic-ish write, matching SaveData.save: back the old file up, stage a
-- .tmp, replace, drop the .tmp. love.filesystem has no rename, so the .tmp
-- copy is the witness that survives a crash mid-replace.
function Save.save(save)
if type(save) ~= "table" then return false, "no save" end
if (save.version or "gold") == "gold" then
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
local id = SaveData.createSlot and SaveData.createSlot("gold")
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
end
end
local main, backup, tmp = saveNames(save.version)
local f = fs()
if not f then return false, "no filesystem" end
-- saveNames may now return a saves/<version>/<slot>.lua path, and
-- love.filesystem.write does not create missing parent directories.
local dir = main:match("^(.*)/[^/]+$")
if dir and f.createDirectory then f.createDirectory(dir) end
Save.normalize(save)
save.savedAt = os.time()
local encoded = SaveSerializer.encode(save)
if f.getInfo(main) then
local prev = f.read(main)
if prev then f.write(backup, prev) end
end
local ok, err = f.write(tmp, encoded)
if not ok then
Logger.error("gold save failed: %s", tostring(err))
return false, err
end
f.remove(main)
ok, err = f.write(main, encoded)
if not ok then
Logger.error("gold save failed: %s", tostring(err))
return false, err
end
f.remove(tmp)
Logger.info("saved gold game")
return true
end
-- The three lines Gold's CONTINUE panel shows before you confirm
-- (DisplaySaveInfoOnContinue): who, how many badges, how much of the dex, and
-- how long. Returned as data so the screen can lay it out.
function Save.summary(save)
if type(save) ~= "table" then return nil end
local badges = 0
for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end
end
local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end
end
local time = save.playTime or {}
return {
name = save.player and save.player.name or "?",
badges = badges,
caught = caught,
hours = time.hours or 0,
minutes = time.minutes or 0,
map = save.position and save.position.map or save.spawn,
}
end
-- Advance the play clock one logic tick. Called from the fixed step, so 60
-- calls is one second, the same rate wGameTimeFrames counts at.
function Save.tickPlayTime(save)
local t = save and save.playTime
if not t then return end
t.frames = (t.frames or 0) + 1
if t.frames < 60 then return end
t.frames = 0
t.seconds = (t.seconds or 0) + 1
if t.seconds < 60 then return end
t.seconds = 0
t.minutes = (t.minutes or 0) + 1
if t.minutes < 60 then return end
t.minutes = 0
-- The cart caps at 999:59 and stops counting; do the same rather than
-- letting the trainer card overflow its field.
t.hours = math.min((t.hours or 0) + 1, 999)
end
return Save