Files
gen1recomp/tools/gen2_sram_offsets.py
T
Colson Rice ebd315b01e Import Gen 2 cart saves
Gold, Silver and Crystal battery saves import now. Export is still refused.

GenSave.lua is pokered's SRAM window and nothing else, which is why the
guard refusing Gen 2 was right to be there. This adds Gen2Save.lua beside
it, covering pokegold and pokecrystal.

Every offset is generated, not transcribed. tools/gen2_sram_offsets.py
reads pokegold.sym and pokecrystal.sym from a pret build and emits
Gen2Layout.lua, including the text table from constants/charmap.asm and
Crystal's backup-save layout. Gen 2 copies a contiguous WRAM block into
SRAM bank 1, so a field's file offset is sPlayerData + (wField -
wPlayerData); the generator asserts that relation against sPokemonData
rather than assuming it, and range-guards anything outside
sGameData..sGameDataEnd.

Gold and Crystal are separate tables because they disagree about nearly
every field. Reading a Crystal save with Gold's numbers gives a party
count of 133 and 13113 hours played, with a checksum that validates.

The cart stores numbers and the engine is keyed by name, so the codec
crosswalks species, moves and items through the generated tables the same
way GenSave.crosswalks does for Gen 1. Without that, an import looks
perfect and the engine cannot read a byte of it.

Shapes that have to match what the engine reads:
  * events is byte index -> packed byte, which Save.scrubEvents validates
    with tonumber. A set of booleans is silently emptied.
  * the bag is one flat save.inventory keyed by item id, which PackMenu
    buckets by each item's pocket. Nothing reads save.keyItems or
    save.balls, and the TM/HM pocket lands here too.
  * position carries the map id, or Save.summary falls through to
    save.spawn and the player resumes somewhere else at their old
    coordinates.
  * mon.status is an ItemEffects.STATUS_CLASS key with statusTurns beside
    it, nil when healthy. 0 is truthy in Lua.

A save the real cartridge would open is not refused: TryLoadSaveFile falls
back to VerifyBackupChecksum, so this does too. Crystal's backup is
contiguous and laid out like the primary; Gold and Silver split theirs
across three sections and have none to offer.

Three suites that pinned Gen 2 import being refused now pin what refuses
instead. Tests live in tests/engine so the ROM-free tier actually runs
them.

./scripts/test.sh passes end to end, and luacheck is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:32:40 -04:00

186 lines
7.6 KiB
Python

#!/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<PK>" 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()