#!/usr/bin/env python3 """Emit src/save_convert/Gen2Layout.lua from a pret build's symbol files. Gen 2 saves a contiguous WRAM block into SRAM bank 1, so every field's file offset inside the 32768-byte battery image is sPlayerData_offset + (wField - wPlayerData) with sPlayerData_offset = sPlayerData - $A000 + $2000. That relation is asserted below against sPokemonData, which appears on both sides. Nothing here is transcribed. Build pret/pokegold and pret/pokecrystal, then: python3 tools/gen2_sram_offsets.py \ --gold path/to/pokegold.sym \ --crystal path/to/pokecrystal.sym \ --charmap path/to/pokegold/constants/charmap.asm \ --gold-scenes path/to/pokegold/data/maps/scenes.asm \ --crystal-scenes path/to/pokecrystal/data/maps/scenes.asm \ > src/save_convert/Gen2Layout.lua Gold and Silver share a layout (pokesilver.sym agrees byte-exact); Crystal does not, and that is the whole reason this file emits two tables. """ import argparse, re, sys FIELDS = [ "wPlayerName", "wPlayerID", "wMoney", "wCoins", "wBadges", "wKantoBadges", "wRivalName", "wMomsName", "wPartyCount", "wPartySpecies", "wPartyMons", "wPartyMonNicknames", "wPartyMonOTs", "wNumItems", "wItems", "wNumKeyItems", "wKeyItems", "wNumBalls", "wBalls", "wTMsHMs", "wPokedexCaught", "wPokedexSeen", "wCurBox", "wBoxNames", "wMapGroup", "wMapNumber", "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", "wStatusFlags", "wStatusFlags2", "wPokegearFlags", "wVisitedSpawns", "wVariableSprites", "wGameTimeHours", "wGameTimeMinutes", "wGameTimeSeconds", "wGameTimeFrames", # engine/menus/intro_menu.asm:28 _ResetWRAM, engine/overworld/player_object.asm:19 "wRedsName", "wGreensName", "wSavedAtLeastOnce", "wSpawnAfterChampion", "wCenteredObject", "wPlayerStruct", "wMapObjects", "wNumPCItems", "wMomItemTriggerBalance", "wRoamMon1MapGroup", "wRoamMon2MapGroup", "wRoamMon3MapGroup", "wBestMagikarpLengthFeet", "wBestMagikarpLengthInches", "wMagikarpRecordHoldersName", "wDecoBed", "wDecoPoster", "wScreenSave", # ram/wram.asm wPlayerData "wMomsMoney", "wMomSavingMoney", "wWhichMomItem", "wPCItems", "wLastDexMode", "wWhichRegisteredItem", "wRegisteredItem", "wUnownDex", "wUnlockedUnowns", "wFirstUnownSeen", ] GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] # ram/sram.asm -- bank 0 labels, outside the copied WRAM block: file offset is # bank * 0x2000 + (addr - $A000). RAW_SRAM = ["sOptions", "sMysteryGiftItem", "sMysteryGiftUnlocked", "sBackupMysteryGiftItem", "sNumDailyMysteryGiftPartnerIDs", "sPartyMail", "sPartyMailBackup", "sMailboxCount", "sMailboxCountBackup", "sBox"] SRAM_EXTRA = ["sHallOfFame", "sHallOfFameEnd", "sLinkBattleStats", "sLinkBattleStatsEnd", "sRTCStatusFlags", "sLuckyNumberDay", "sLuckyIDNumber", "sMysteryGiftData", "sBackupMysteryGiftItemEnd", "sGSBallFlag", "sGSBallFlagBackup", "sCrystalData", "sBattleTowerChallengeState", "sNrOfBeatenBattleTowerTrainers", "sBTChoiceOfLevelGroup", "sBTTrainers", "sBattleTowerSaveFileFlags", "sBattleTowerReward", "sBTMonOfTrainers", "sMailboxes", "sMailboxesBackup", "sStackTop", "sDailyMysteryGiftPartnerIDs", "sMysteryGiftDecorationsReceived", "sMysteryGiftTimer", "sMysteryGiftTrainerHouseFlag", "sMysteryGiftPartnerName", "sMysteryGiftUnusedFlag", "sMysteryGiftTrainer", "sLinkBattleRecord"] WRAM_NAME = re.compile(r"^w[A-Za-z0-9_]+$") # ram/sram.asm:138-144 SRAM_FIELDS = [("wPlayerGender", "sCrystalData", "wCrystalData")] # The 14 archived PC boxes. Emitted as real per-box offsets, never a stride: # boxes 1-7 live in SRAM bank 2 and 8-14 in bank 3, so the step from box 7 to # box 8 is 0x620 rather than the 0x450 every other pair uses. Computing them # from a uniform stride puts boxes 8-14 in the wrong place, and a real save # then reports box counts like 243 and 196. BOX_COUNT = 14 def load(path): out = {} for line in open(path): m = re.match(r"^(\w\w):(\w{4})\s+(\S+)\s*$", line) if m: out.setdefault(m.group(3), (int(m.group(1), 16), int(m.group(2), 16))) return out # engine/menus/save.asm:538 TryLoadSaveFile; pokegold ram/sram.asm Backup Save 1-3 GS_BACKUP = [ ("sPlayerData1", "sBackupPlayerData1", "wPlayerData1", "wPlayerData1End"), ("sPlayerData2", "sBackupPlayerData2", "wPlayerData2", "wPlayerData2End"), ("sPlayerData3", "sBackupPlayerData3", "wPlayerData3", "wPlayerData3End"), ("sCurMapData", "sBackupCurMapData", "wCurMapData", "wCurMapDataEnd"), ("sPokemonData", "sBackupPokemonData", "wPokemonData", "wPokemonDataEnd"), ] BACKUP_GUARDS = ["sBackupOptions", "sBackupCheckValue1", "sBackupCheckValue2", "sBackupChecksum"] def file_off(sym, n): return sym[n][0] * 0x2000 + (sym[n][1] - 0xA000) def backup_save(sym, label): missing = [n for n in BACKUP_GUARDS if n not in sym] if missing: sys.exit(f"{label}: symbol file is missing {missing}") segs = [] if "sBackupGameData" in sym: segs.append((file_off(sym, "sGameData"), file_off(sym, "sBackupGameData"), file_off(sym, "sGameDataEnd") - file_off(sym, "sGameData"))) else: for prim, back, w0, w1 in GS_BACKUP: need = [prim, back, w0, w1] if any(n not in sym for n in need): sys.exit(f"{label}: symbol file is missing one of {need}") segs.append((file_off(sym, prim), file_off(sym, back), sym[w1][1] - sym[w0][1])) lo, hi = file_off(sym, "sGameData"), file_off(sym, "sGameDataEnd") covered = sorted((a, a + n) for a, _, n in segs) at = lo for a, b in covered: if a != at: sys.exit(f"{label}: backup segments do not tile sGameData at 0x{at:04X}") at = b if at != hi: sys.exit(f"{label}: backup segments end at 0x{at:04X}, not 0x{hi:04X}") return { "options": file_off(sym, "sBackupOptions"), "checkValue1": file_off(sym, "sBackupCheckValue1"), "checkValue2": file_off(sym, "sBackupCheckValue2"), "checksum": file_off(sym, "sBackupChecksum"), "segments": segs, } def table(sym, label): need = ["sPlayerData", "wPlayerData", "sPokemonData", "wPokemonData"] + GUARDS missing = [n for n in need if n not in sym] if missing: sys.exit(f"{label}: symbol file is missing {missing}") base = sym["sPlayerData"][1] - 0xA000 + 0x2000 anchor = sym["wPlayerData"][1] # The block relation, asserted rather than assumed. if sym["sPokemonData"][1] - sym["sPlayerData"][1] != \ sym["wPokemonData"][1] - sym["wPlayerData"][1]: sys.exit(f"{label}: the WRAM block is not copied contiguously; " "the offset relation this generator rests on does not hold") lo, hi = sym["sGameData"][1], sym["sGameDataEnd"][1] rows, skipped = [], [] for g in GUARDS: rows.append((g, sym[g][1] - 0xA000 + 0x2000)) for f in FIELDS: w = sym.get(f) if not w: skipped.append(f + " (absent)") continue # Only fields INSIDE the saved block are addressable this way. Crystal's # wPlayerGender sits before wPlayerData and belongs to sCrystalData, and # the naive subtraction gives a confident wrong answer for it. if not (lo <= w[1] - anchor + sym["sPlayerData"][1] < hi): skipped.append(f + " (outside sGameData..sGameDataEnd)") continue rows.append((f, base + (w[1] - anchor))) for name, sbase, wbase in SRAM_FIELDS: if name in sym and sbase in sym and wbase in sym: at = sym[sbase][0] * 0x2000 + (sym[sbase][1] - 0xA000) rows.append((name, at + (sym[name][1] - sym[wbase][1]))) for name in RAW_SRAM: s = sym.get(name) if not s: sys.exit(f"{label}: {name} is missing from the symbol file") rows.append((name, s[0] * 0x2000 + (s[1] - 0xA000))) syms = {} for n, (bank, addr) in sym.items(): if not WRAM_NAME.match(n) or not (0xC000 <= addr < 0xE000): continue if lo <= addr - anchor + sym["sPlayerData"][1] < hi: syms[n] = base + (addr - anchor) for name in SRAM_EXTRA: s = sym.get(name) if s: syms[name] = s[0] * 0x2000 + (s[1] - 0xA000) boxes = [] for i in range(1, BOX_COUNT + 1): b = sym.get("sBox%d" % i) if not b: sys.exit("%s: sBox%d is missing" % (label, i)) # General SRAM form, which the bank-1 arithmetic above is a case of: # file offset = bank * 0x2000 + (addr - $A000). boxes.append(b[0] * 0x2000 + (b[1] - 0xA000)) return rows, skipped, boxes, syms SCENE_RE = re.compile(r"^\s*scene_var\s+(\w+),\s*(\w+)") def scene_rows(sym, path, label): base = sym["sPlayerData"][1] - 0xA000 + 0x2000 anchor = sym["wPlayerData"][1] lo, hi = sym["sGameData"][1], sym["sGameDataEnd"][1] out = [] for line in open(path): m = SCENE_RE.match(line) if not m: continue mapId, var = m.group(1), m.group(2) w = sym.get(var) if not w: sys.exit(f"{label}: {var} for {mapId} is missing from the symbol file") if not (lo <= w[1] - anchor + sym["sPlayerData"][1] < hi): sys.exit(f"{label}: {var} is outside sGameData..sGameDataEnd") out.append((mapId, base + (w[1] - anchor))) if not out: sys.exit(f"{label}: no scene_var rows in {path}") return out def print_scenes(rows, indent): print(f"{indent}sceneVars = {{") for mapId, off in rows: print(f"{indent} {mapId} = 0x{off:04X},") print(f"{indent}}},") # The cart's own text table, so a name with an apostrophe, an accent or the PK # glyph in it survives the round trip. Hand-keeping this list is how a player # called "Mattia" comes back as "Mattia?". CHARMAP_RE = re.compile(r'^\s*charmap\s+"(.+?)",\s*\$([0-9a-fA-F]{2})\s*(?:;.*)?$') def emit_charmap(path): rows = {} for line in open(path, encoding="utf-8"): m = CHARMAP_RE.match(line) if not m: continue glyph, code = m.group(1), int(m.group(2), 16) # Control tokens are not text; the name fields never contain them. if glyph.startswith("<") and glyph.endswith(">"): inner = glyph[1:-1] if inner in ("PK", "MN", "PO", "KE"): rows.setdefault(code, inner) continue rows.setdefault(code, glyph) print("Gen2Layout.charmap = {") for code in sorted(rows): glyph = rows[code].replace("\\", "\\\\").replace('"', '\\"') print(f' [0x{code:02X}] = "{glyph}",') print("}") print() def main(): ap = argparse.ArgumentParser() ap.add_argument("--gold", required=True) ap.add_argument("--crystal", required=True) ap.add_argument("--charmap", required=False, help="path to pokegold constants/charmap.asm; emits the " "text table too when given") ap.add_argument("--gold-scenes", required=False, help="path to pokegold data/maps/scenes.asm; emits the " "wSceneID offsets when given") ap.add_argument("--crystal-scenes", required=False, help="path to pokecrystal data/maps/scenes.asm") ap.add_argument("--syms-out", required=False, help="write src/save_convert/Gen2Syms.lua here: every label " "inside the saved block plus SRAM_EXTRA, as file offsets") a = ap.parse_args() print("-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.") print("-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that") print("-- script's header for the derivation and the assertion behind it.") print("local Gen2Layout = {}\n") all_syms = {} for key, path, scenesPath in (("goldSilver", a.gold, a.gold_scenes), ("crystal", a.crystal, a.crystal_scenes)): sym = load(path) rows, skipped, boxes, syms = table(sym, key) backup = backup_save(sym, key) scenes = scene_rows(sym, scenesPath, key) if scenesPath else None print(f"Gen2Layout.{key} = {{") for n, off in rows: print(f" {n} = 0x{off:04X},") print(" -- The 14 archived boxes, listed rather than strided (see BOX_COUNT).") print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },") all_syms[key] = syms if scenes: print_scenes(scenes, " ") print(" -- engine/menus/save.asm:538 TryLoadSaveFile") print(" backupSave = {") for n in ("options", "checkValue1", "checkValue2", "checksum"): print(f" {n} = 0x{backup[n]:04X},") print(" segments = {") for prim, back, size in backup["segments"]: print(f" {{ 0x{prim:04X}, 0x{back:04X}, 0x{size:03X} }},") print(" },") print(" },") print("}") for s2 in skipped: print(f"-- not addressable via the block: {s2}") print() if a.charmap: emit_charmap(a.charmap) print("return Gen2Layout") if a.syms_out: with open(a.syms_out, "w") as out: out.write("-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.\n") out.write("local Gen2Syms = {}\n\n") for key in ("goldSilver", "crystal"): out.write(f"Gen2Syms.{key} = {{\n") for n, off in sorted(all_syms[key].items(), key=lambda kv: (kv[1], kv[0])): out.write(f" {n} = 0x{off:04X},\n") out.write("}\n\n") out.write("return Gen2Syms\n") main()