How Lighthouse Handles and Emulates the N64 Save Format: A Deep Dive into EEPROM Emulation
Lighthouse emulates the Nintendo 64's EEPROM save system by mapping the console's raw 8-byte block-based save structure to a host filesystem file, using SaveData struct definitions from include/save.h and hardware-accurate eeprom_readBlocks/eeprom_writeBlocks implementations in src/port/OS/libultra.c.
The HarbourMasters/Lighthouse project is a PC port of a classic N64 collectathon platformer. Preserving the original game's save system was essential for accuracy—so the developers built a complete EEPROM emulation layer rather than replacing it with modern alternatives. This article explains how Lighthouse handles and emulates the N64 save format, from binary layout to file I/O operations.
The SaveData Structure: Binary Layout from include/save.h
The foundation of N64 save format emulation in Lighthouse is the SaveData struct defined in include/save.h. This header mirrors the exact binary layout that the original game expected from N64 EEPROM hardware.
The SaveData structure contains:
- Magic byte (
0x11) at a fixedbaseOffset— identifies valid save data - Progress flags — bitfield tracking storyline milestones
- Collectible arrays — separate sections for jiggies, honeycombs, Mumbo tokens, high-note scores, and time scores
- Ability flags — bits indicating unlocked player abilities
All fields are packed to match the original N64 memory layout. This lets the ported game code read and write save data without modification—the same pointer arithmetic and array indexing works on PC as it did on console.
EEPROM Block I/O: libultra.c Hardware Abstraction
Lighthouse implements the N64's 8-byte block EEPROM API in src/port/OS/libultra.c. These functions provide the bridge between the game's original save code and the host filesystem:
s32 eeprom_readBlocks(s32 file, s32 offset, void *buffer, s32 count);
s32 eeprom_writeBlocks(s32 file, s32 offset, void *buffer, s32 count);
Both functions:
- Accept a
fileargument (always0for the main save slot, matching single-EEPROM N64 consoles) - Operate on 8-byte blocks at the specified
offset - Transfer
countblocks between the host save file and the providedbuffer
The implementation uses standard host file I/O (fread/fwrite) on a file path supplied by the launcher. The "EEPROM" is simply a regular file on disk—typically named with an .eeprom or .sav extension.
Core Save Logic: savedata.c Load, Verify, and Create
High-level save operations live in src/core2/savedata.c, which coordinates between the hardware abstraction layer and the game's state management.
Loading Save Data
void saveData_load(void *savedata_);
Called at line 378 of savedata.c during startup, this function:
- Casts
savedata_toSaveData * - Invokes helper functions like
__savedata_load_jiggyScore,__savedata_load_honeycombScore, etc. - Copies each section of raw EEPROM into corresponding in-memory arrays
These helper functions establish pointer variables (jiggy_addr, honeycomb_addr, etc.) that the rest of the game uses to check and update progress.
Verification
s32 savedata_verify(size_t size, SaveData *savedata);
This function validates:
- Data size matches expected
SaveDatastructure size - Magic byte (
0x11) is present atbaseOffset
CRC handling deserves special mention: savedata_update_crc is stubbed out in the PC port. The original N64 game used CRC checks for data integrity, but Lighthouse relies on the host filesystem's built-in guarantees instead.
Creating and Writing Saves
void saveData_create(SaveData *savedata); // Initializes struct, writes magic byte
void savedata_8033CA9C(SaveData *savedata); // Read from EEPROM file
void savedata_8033CA2C(s32 file, SaveData *savedata); // Write to EEPROM file
The naming convention savedata_8033CA9C and savedata_8033CA2C preserves original function addresses from the N64 ROM—common in decompilation projects to maintain traceability to the source binary.
Port-Specific Integration: SaveManager and SaveConverter
Modern conveniences layer on top of the core emulation in src/port/Save/:
SaveManager.cpp
Coordinates save operations with the UI, randomizer, and tracker systems. Handles:
- Save slot selection
- Auto-save triggers
- Integration with port-specific enhancements
SaveConverter.cpp
Provides utilities for importing and exporting saves in external formats (JSON). All conversions ultimately funnel through the core SaveData layout—external formats are translated to and from the binary EEPROM structure, never replacing it.
Integration with Randomizer and Tracker Systems
The N64 save format emulation enables advanced features without breaking compatibility:
-
Randomizer module (
src/port/Rando/*) — Uses standardsave.hinterfaces to read/write custom flags controlling shuffled collectible locations. These flags occupy unused bits in the originalSaveDatastructure or extend into conventionally unused regions. -
Live tracker overlay (
src/port/Enhancements/Trackers/DisplayOverlay.cpp) — Reads the in-memory save arrays (populated from EEPROM viasaveData_load) to display real-time progress without accessing the file directly.
Practical Code Examples
Boot Sequence Save Loading
void init_save_system(void) {
SaveData save;
// Load EEPROM file into the SaveData struct
savedata_8033CA9C(&save); // reads from host file
// Populate in-memory structures from raw data
saveData_load(&save);
}
Save on Exit
void write_save_on_exit(void) {
SaveData save;
// Initialize structure with magic byte
saveData_create(&save);
// ... game updates the various save arrays ...
// Write back to host EEPROM file
savedata_8033CA2C(0, &save);
}
Checking Collectible Status
bool has_jiggy3(void) {
extern u8 *jiggy_addr; // set up by __savedata_load_jiggyScore
// Array is 0-based; value 1 = obtained
return jiggy_addr[2] != 0;
}
Key Implementation Files
| File | Purpose |
|---|---|
include/save.h |
SaveData struct definition — binary EEPROM layout |
src/core2/savedata.c |
Core load/save logic, verification, CRC handling |
src/port/OS/libultra.c |
eeprom_readBlocks / eeprom_writeBlocks host I/O |
src/port/Save/SaveManager.cpp |
High-level save coordination with UI |
src/port/Save/SaveConverter.cpp |
Format conversion utilities (JSON, etc.) |
Summary
- Lighthouse emulates the N64 save format through block-accurate EEPROM abstraction — the original game's 8-byte block operations work unchanged
- The
SaveDatastruct ininclude/save.hpreserves exact binary compatibility with the N64 original eeprom_readBlocksandeeprom_writeBlocksinsrc/port/OS/libultra.cmap hardware operations to host filesystem callssrc/core2/savedata.cprovides high-level save management with verification and helper-populated in-memory arrays- Modern features (randomizer, trackers, JSON export) layer on top without modifying the core binary format
Frequently Asked Questions
What file format does Lighthouse use for saves on PC?
Lighthouse stores saves as binary files that match the original N64 EEPROM layout exactly—typically with .eeprom or .sav extensions. While SaveConverter.cpp can export to JSON for portability, the native format is byte-identical to what the original console produced.
Can I use original N64 save files with Lighthouse?
Yes, provided the SaveData structure layout matches. The magic byte verification in savedata_verify will accept valid original saves. Differences in CRC handling (stubbed in Lighthouse) may require minor adjustments, but the core collectible data is directly compatible.
Why does Lighthouse stub the CRC check?
The original N64 game used CRC checks because EEPROM hardware could corrupt data. On PC, the host filesystem provides its own integrity guarantees, making software CRC redundant. The savedata_update_crc function exists for compatibility but performs no operation.
How does the randomizer store its configuration?
The randomizer uses unused bits within the standard SaveData structure and conventionally unused EEPROM regions. It calls the same eeprom_readBlocks/eeprom_writeBlocks functions as the base game, ensuring shuffled settings persist in the emulated N64 save format alongside original game progress.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →