How Lighthouse Manages N64 Memory Mapping on Modern Systems

The Lighthouse PC port reproduces the Nintendo 64's memory-management model by allocating a fixed-size simulated heap that mirrors the original console's RAM layout, with thin compatibility wrappers redirecting N64-specific allocation calls to this host-backed heap.

The HarbourMasters/Lighthouse project is a reverse-engineered PC port of a classic N64 title. Understanding how Lighthouse handles N64 memory mapping on modern systems reveals the engineering trade-offs between accuracy and performance when bringing legacy console code to contemporary hardware.

Simulating the N64 Heap Structure

Lighthouse's memory system begins with a faithful recreation of the N64's heap architecture. The original console's heap size varies by regional build, defined through the HEAP_SIZE macro—approximately 2 MiB for US and PAL versions.

In src/core1/memory.c, the entire heap is implemented as a static array:

static EmptyHeapBlock gHeapBase[HEAP_COUNT];

This declaration at lines 48-51 allocates host memory that structurally mirrors the N64's RAM layout. Each entry in gHeapBase represents a potential allocation block, maintaining the original console's linked-list organization.

Every allocated chunk carries a heap header storing critical metadata:

typedef struct {
    struct HeapHeader *prev;
    struct HeapHeader *next;
    u32 flags;           // Includes HEAP_BLOCK_EMPTY, HEAP_BLOCK_USED, HEAP_BLOCK_PERM
    // ... additional fields
} HeapHeader;

This header structure, defined at lines 33-40, preserves the original N64 block states including empty, used, and permanent allocation markers.

Compatibility Allocation Wrappers

The port exposes three primary memory functions that match the original N64 signatures: bk_malloc, bk_free, and bk_realloc. These wrappers in src/core1/memory.c provide a clean abstraction boundary.

Current PC builds take the practical path—forwarding directly to the host C library:

void *bk_malloc(size_t size) {
    return calloc(1, size);
}

This implementation (lines 58-60) prioritizes performance and stability over cycle-accurate simulation. The calloc call ensures zero-initialized memory, matching N64 development kit behavior.

The original N64 allocation algorithm remains present in the source, preserved within #if 0 blocks for reference and potential reactivation:

#if 0
EmptyHeapBlock *_func_80254B84(s32 requested_size) {
    EmptyHeapBlock *candidate = gHeapBase->next_free;
    
    // Walk free list seeking adequate block
    while (chunkSize(&candidate->hdr) < requested_size &&
           candidate->next_free != &gHeapBase[LAST_HEAP_BLOCK]) {
        candidate = candidate->next_free;
    }
    
    return (chunkSize(&candidate->hdr) < requested_size) ? NULL : candidate;
}
#endif

This disabled code (lines 57-66) demonstrates the original console's free-list search, block splitting, and occupancy tracking logic—features like heap defragmentation and precise byte counting that the simplified PC path omits.

Heap Initialization and Sentinel Structure

The heap_init() function establishes the simulated heap's invariants, reproducing the N64's linked-list structure with sentinel blocks:

void heap_init(void) {
    bzero(gHeapBase, sizeof(gHeapBase));      // Zero entire static array
    func_802546FC();                           // Clear temporary state
    D_80283238.unk40 = &D_80283238.unk0[0];   // Initialize allocation queue
    
    heap_occupiedBytes = 0;                    // Reset statistics
    
    // Link sentinel: start block → permanent block → end block
    gHeapBase[0].hdr.prev = NULL;
    gHeapBase[0].hdr.next = (HeapHeader *)&gHeapBase[1];
    gHeapBase[0].hdr.unkC_7 = HEAP_BLOCK_PERM;  // Permanent marker
    
    gHeapBase[1].hdr.prev = (HeapHeader *)&gHeapBase[0];
    gHeapBase[1].hdr.next = (HeapHeader *)&gHeapBase[LAST_HEAP_BLOCK];
    // ... additional linking
    
    sns_init_base_payloads();                  // N64-specific initialization stub
}

This initialization sequence (lines 86-107) creates the three sentinel nodes that bounded all N64 heap operations: a start marker, a permanent block (holding persistent game state), and an end guard. The structure enables O(1) header access and simplified boundary checks in the original allocator.

Handling Fixed Hardware Addresses

The N64 mapped critical hardware resources to specific physical addresses. Lighthouse addresses this through documented host buffers with inline comments explaining the original mapping.

In src/port/OS/libultra.c, the depth buffer exemplifies this approach:

// On N64 this was a fixed-address depth buffer at 0x8000E800 
// (naturally 0x40-aligned for cache coherency)
static u16 gDepthBuffer[DEPTH_BUFFER_WIDTH * DEPTH_BUFFER_HEIGHT];

The comment at lines 26-27 explicitly records the original 0x8000E800 address while the actual allocation uses standard host memory. This pattern—preserve knowledge, redirect implementation—appears throughout the port's hardware emulation layer.

Stubbing Direct RDRAM Access

Certain N64 features required direct physical RAM manipulation. The "Stop-N-Swap" system, which scanned specific memory regions for hidden data, demonstrates how Lighthouse handles impossible hardware behaviors.

From src/core1/stopnswop.c (lines 103-108):

#if defined(PLATFORM_PC)
    // On PC, 0x80000000-0x80400080 is not valid RDRAM—this is a no-op
    return;
#else
    // Original N64: scan physical address range for magic payload signatures
    u32 *scan = (u32 *)0x80000000;
    // ... payload detection logic
#endif

The PC build recognizes that the address range 0x80000000-0x80400080 has no meaning on modern systems and safely neutralizes the operation. The N64-specific pointer cast and hardware scan remain available for console builds.

Version-Conditional Compilation

The N64 memory mapping system adapts to multiple build targets through the VER_SELECT macro. Found in src/core1/pimanager.c (lines 23-34), this conditional compilation chooses appropriate constants for US, PAL, or PC builds without modifying core logic:

#define VER_SELECT(us, pal, pc) (pc)  // PC build selector

// Used for DMA-aligned buffer sizes, cache line calculations, etc.
#define DMA_BUFFER_SIZE VER_SELECT(0x1000, 0x1000, 0x4000)

This abstraction allows the same source to compile across fundamentally different memory architectures while preserving the original code's structural assumptions.

Architectural Trade-offs

Lighthouse's approach to managing N64 memory mapping on modern systems represents a pragmatic middle ground:

  • Structure fidelity: Heap headers, sentinel blocks, and linked-list organization match the original console
  • Performance pragmatism: Host allocators replace cycle-accurate simulation for daily use
  • Documentation priority: Comments and disabled code preserve implementation knowledge
  • Conditional compilation: Build-time selection enables multiple targets from one codebase

Summary

  • Lighthouse allocates a static array gHeapBase that mirrors the N64's fixed-size heap structure in host memory
  • Compatibility wrappers (bk_malloc, bk_free, bk_realloc) redirect to host allocators while preserving original function signatures
  • The original allocation algorithm remains in disabled source blocks for reference and potential reactivation
  • Hardware-specific addresses are emulated through documented host buffers with inline mapping comments
  • Direct RDRAM operations are stubbed or neutralized for PC builds where physical addresses have no meaning
  • VER_SELECT macros enable version-specific values without duplicating memory-management logic

Frequently Asked Questions

Does Lighthouse use the original N64 memory allocator or the host system's?

Lighthouse uses the host system's allocator for PC builds. The bk_malloc wrapper forwards to calloc rather than walking the simulated free list. However, the original N64 allocation algorithm is preserved in #if 0 blocks, allowing developers to re-enable more accurate simulation if needed for debugging or preservation purposes.

How does Lighthouse handle N64 hardware addresses that don't exist on PC?

Lighthouse employs two strategies: (1) fixed hardware resources like the depth buffer are allocated as standard host buffers with comments documenting their original N64 addresses, and (2) direct memory scans or physical RDRAM accesses are stubbed out with platform-conditional compilation. The Stop-N-Swap scanner, for example, becomes a no-op on PC builds.

What is the purpose of the simulated heap if it's not used for allocation?

The simulated heap structure serves multiple purposes: it maintains compatibility with code expecting specific heap header layouts, preserves the original console's memory organization for debug visualization, and allows selective re-enabling of accurate simulation. The gHeapBase array and its sentinel-linked structure also initialize certain game state that the original code assumes exists, even when individual allocations bypass the simulated allocator.

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 →