Parsing Nintendo's Binary Save Formats in Python
Animal Crossing save files are binary blobs written by a PowerPC processor. Big-endian byte order, no documentation, region-specific character encodings, and CRC32 checksums that invalidate the file if you change a single byte without recalculating. The original ACToolkit was a Delphi application that only ran on Windows. I rewrote it in Python with PyQt6 to make it cross-platform and added support for game versions the original never handled.
Four Games, Four Formats
The editor handles four distinct save formats:
- Animal Crossing (GameCube, NTSC-U, game ID GAFE)
- Doubutsu no Mori e+ (GameCube, Japanese, game ID GAEJ)
- Animal Crossing: City Folk (Wii, ACCF)
- RVFP Deluxe Edition (a community mod of the GameCube version by Aurum, v1.1.2)
Each has different payload sizes (59,200 bytes for GameCube vanilla, 188,416 for ACCF), different memory offsets for player data, different numbers of villager slots (10-16), and different string encodings. The GameProfile dataclass holds all the offset constants and feature flags for each version:
@dataclass
class GameProfile:
save_payload_size: int
string_encoding: StringEncoding # UTF16_BE, GC_CUSTOM, GC_EPLUS
player_offsets: list[int] # Starting offset per player slot
villager_count: int # 10 (ACCF) to 16 (GC)
town_offset: int
house_offsets: list[int]
# ... 20+ more offset fieldsGame detection is automatic. The handler reads the file size and checks for game ID bytes at known positions. A 59,200-byte file with "GAFE" at the right offset is GameCube vanilla. A 188,416-byte file is ACCF. The Deluxe edition has the same size as vanilla but different magic bytes in the DLC region.
Binary Parsing Strategy
The entire save file loads into a mutable bytearray. All reads and writes happen as in-place operations on this buffer using Python's struct module with big-endian format strings (>H for uint16, >I for uint32, etc.).
This design choice matters for two reasons. First, saves can be large (188KB for ACCF) with thousands of fields, and copying data between structures would waste memory. Second, the checksum calculation needs to run over the exact bytes that will be written to disk. In-place editing means the checksum input is always the current buffer state.
The checksum system divides the save into regions, each with its own CRC32. On load, every region's checksum is verified. If any fails, the editor warns that the file may be corrupted. On save, all checksums are recalculated from the buffer contents before writing.
The Town Grid
The town is an 80x80 tile grid where each tile holds a 16-bit item code. That's 12,800 bytes of raw item data, plus a parallel bit-packed array for buried items (one bit per tile, 800 bytes), plus a grass quality layer (one byte per tile, 6,400 bytes).
The town editor renders this grid using a custom QPainter widget with a back-buffer pixmap. Rendering all 6,400 tiles on every frame would be slow, so the widget only redraws cells that changed. The color palette maps item categories to colors: cyan for general items, yellow for furniture, magenta for flowers, blue for weeds, green for trees.
# Color mapping by item code range
if 0x1000 <= code <= 0x1FFF:
return QColor("#FFEB3B") # Furniture (yellow)
elif 0x2400 <= code <= 0x24FE:
return QColor("#E040FB") # Flowers (magenta)
elif code == 0x0003:
return QColor("#4FC3F7") # Weeds (blue)Editing tools include: inspect (click to see what's at a tile), replace (place selected item), delete (set to 0xFFF1, the "empty" sentinel), bury/unbury, and batch operations (remove all weeds, revive parched flowers, replenish fruit trees, restore grass to max quality).
The Item Database
6,379 GameCube items and 1,600+ ACCF items, each with names in 9 languages (English Americas, English Europe, Spanish Americas, Spanish Europe, French Canada, French Europe, German, Italian, Japanese). The database is a Python dictionary mapping hex item IDs to metadata.
The original item data came from a Delphi source file (items.pas) from the old ACToolkit. I wrote a parser (parse_items.py) that reads Delphi string arrays, handles Delphi escape sequences ('' for literal quotes), and generates the Python dictionary.
Furniture items have orientation variants. A table might have IDs 0x1234 (south-facing), 0x1235 (east), 0x1236 (north), 0x1237 (west). The get_furniture_id() function calculates directional variants from the base ID.
The Deluxe Edition
The RVFP Deluxe mod adds 131 new items and 244 new villagers to the GameCube version. Supporting it required adding a separate item database (deluxe_items.py) with metadata for each new item (price, category, multilingual names) and a villager database with species, personality, birth dates, catchphrases, and appearance IDs.
The Deluxe detection logic checks for specific bytes in the DLC region that the mod writes during installation. If detected, the editor loads the extended databases and enables Deluxe-specific UI elements.
Cross-Game Save Conversion
The eplus_converter.py module handles bidirectional conversion between Doubutsu no Mori e+ (Japanese) and Animal Crossing (US) save formats. This is more complex than it sounds because the games use different string encodings (e+ uses a custom GameCube encoding, GAFE uses UTF-16 BE), different pattern title lengths (16 chars vs 8 chars), and different memory layouts.
The converter copies core data (town name, wallet, bank, debt, inventory, appearance), 8 custom design patterns per player (truncating titles if needed), house furniture across 4 houses with 3 rooms each, and stalk market pricing. Bounds checking prevents buffer overflows when field sizes differ between formats.
The NPC System
The editor reads villager data from either a ROM extraction (pack.bin, parsed by npc_data.py) or a fallback embedded database (vanilla_npcs.py with all 210 vanilla villagers). Each NPC entry is a 408-byte binary record containing: model ID, clothing items, species and personality (packed as bit-fields in a single byte), multilingual names across 8 languages, catchphrases in 10 languages, birthday, furniture preferences, and starter status.
The NPC editor lets you swap villagers in and out of your town's 10-16 resident slots, change their catchphrases and equipped clothing, and edit their house interiors (wallpaper, flooring, furniture layout, K.K. Slider song).
What I'd Do Differently
The item database should be SQLite instead of a Python dict. 1.8MB of Python dictionaries loaded into memory at startup is wasteful. SQLite with indexed columns would use less memory and enable faster searches. The current approach works fine for the ~8K items we have, but it's architecturally wrong.
The town grid renderer should use QGraphicsScene. The custom QPainter approach works but doesn't scale well for features like zoom, pan, and selection rectangles. QGraphicsScene handles all of that natively and would make adding new editing tools much simpler.
The letter editor is read-only because I didn't fully reverse the mail binary format. The format is partially documented but writes could corrupt adjacent data. I should have spent the time to fully map the structure rather than shipping a read-only viewer.