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 fixed baseOffset — 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:

  1. Accept a file argument (always 0 for the main save slot, matching single-EEPROM N64 consoles)
  2. Operate on 8-byte blocks at the specified offset
  3. Transfer count blocks between the host save file and the provided buffer

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:

  1. Casts savedata_ to SaveData *
  2. Invokes helper functions like __savedata_load_jiggyScore, __savedata_load_honeycombScore, etc.
  3. 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 SaveData structure size
  • Magic byte (0x11) is present at baseOffset

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 standard save.h interfaces to read/write custom flags controlling shuffled collectible locations. These flags occupy unused bits in the original SaveData structure or extend into conventionally unused regions.

  • Live tracker overlay (src/port/Enhancements/Trackers/DisplayOverlay.cpp) — Reads the in-memory save arrays (populated from EEPROM via saveData_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 SaveData struct in include/save.h preserves exact binary compatibility with the N64 original
  • eeprom_readBlocks and eeprom_writeBlocks in src/port/OS/libultra.c map hardware operations to host filesystem calls
  • src/core2/savedata.c provides 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →