#!/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 \ > 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", "wGameTimeHours", "wGameTimeMinutes", ] GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] # 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 # The backup copy the game falls back to when the primary checksum fails # (TryLoadSaveFile -> VerifyBackupChecksum). Crystal's is contiguous and laid # out exactly like the primary, so it is the same table shifted. Gold and # Silver split theirs across three sections and are not derivable this way, # which is why only Crystal gets one. def backup_table(sym, rows, label): need = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1", "sBackupCheckValue2", "sBackupChecksum", "sGameData"] if any(n not in sym for n in need): return None off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000) # File offsets, not raw addresses: the backup lives in SRAM bank 0 and the # primary in bank 1, so an address-only delta is off by a bank. delta = off("sBackupGameData") - off("sGameData") guards = {"sCheckValue1": off("sBackupCheckValue1"), "sCheckValue2": off("sBackupCheckValue2"), "sChecksum": off("sBackupChecksum"), "sGameData": off("sBackupGameData"), "sGameDataEnd": off("sBackupGameDataEnd")} out = [] for name, value in rows: if name in guards: out.append((name, guards[name])) else: out.append((name, value + delta)) return out 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))) 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 # 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") 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") for key, path in (("goldSilver", a.gold), ("crystal", a.crystal)): sym = load(path) rows, skipped, boxes = table(sym, key) backup = backup_table(sym, rows, key) 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) + " },") if backup: print(" -- The backup copy the game falls back to when the primary") print(" -- checksum fails. Same shape, shifted.") print(" backup = {") for n, off in backup: print(f" {n} = 0x{off:04X},") print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },") 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") main()