Skip to content

Guest Memory and Heap

M T edited this page Oct 4, 2026 · 1 revision

Guest Memory and Heap

The translated engine addresses memory exactly as the 32-bit original did: every pointer is a 32-bit guest address. The host gives the guest one flat 4 GiB address space, reserved with a single mmap at startup, so a guest address is converted to a host pointer by adding it to engine_flat_base. Inside that space the host lays out the PE image, the main stack, the TEB/PEB, a general-purpose heap for HeapAlloc/GlobalAlloc/LocalAlloc/malloc-style allocation, and a page allocator for VirtualAlloc, guest thread stacks and D3D resources. This page documents the address map, both allocators, the ownership rules that keep a guest bug from corrupting allocator state, the DirectSound ownership pattern built on them, and the tests.

Source files

File Role
host.c Address map constants, engine_flat_base, the guest heap (guest_alloc, guest_free, guest_alloc_size, host_heap_stats), the page allocator (guest_page_alloc, guest_page_free, guest_page_reserve_explicit, host_page_stats), TEB/PEB setup, PE loading.
host.h GPTR, GSTR, G8/G16/G32/G64, S8/S16/S32/S64 accessors and allocator prototypes.
shims_kernel32.c Win32 memory APIs on top of the allocators.
directsound.c Example owner: DirectSound objects whose guest storage lives on the guest heap.
d3d9.c, panorama_render.inc Owners of page allocations (resource data, HUD surface).
tests/test_guest_heap.c, test_guest_pages.c, test_win32_heap_reuse.c, test_guest_heap_sound_owners.c Allocator tests (each #include "../host.c").

The flat address space

host_run reserves the space with mmap(NULL, 0x100000000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0) and then makes the first 64 KiB PROT_NONE so a guest null-pointer dereference faults (host.c:629-631). Because the mapping is anonymous and private, untouched pages cost nothing until written; the allocators are designed to keep reused memory lazy as well.

Access helpers in host.h:

Helper Meaning
GPTR(a) / GSTR(a) engine_flat_base + (uint32_t)a as void * / const char *
G8/G16/G32/G64(a) unaligned little-endian loads via memcpy
S8/S16/S32/S64(a, v) unaligned stores

These helpers do no bounds checking: every 32-bit address is inside the 4 GiB reservation, and the only inaccessible range is the null guard. Translated code itself goes through the EngineCPU address hooks in engine_cpu.h, which fail cleanly on address wrap (see EngineReuse Runtime).

Address map

Range Size Contents Defined at
0x00000000–0x0000FFFF 64 KiB PROT_NONE null guard host.c:631
0x00100000–0x001FFFFF 1 MiB Engine thread's guest stack (STACK_BASE, STACK_SIZE); initial esp = 0x001FFF00 host.c:60-61
0x00400000– engine_pe_size_of_image PE image: headers (first 0x1000 bytes) and sections at their virtual addresses. Code is in 0x00401000–0x00639596 (the range the backtracer accepts as return addresses). load_image, host.c:513-525
0x02000000–0x3FFFFFFF 992 MiB Guest heap (HEAP_START, HEAP_END) host.c:62-63
0x40000000–0x4FFFFFFF 256 MiB Explicit-address VirtualAlloc reservations. The game hardcodes 0x40000000 for its pools. host.c:352-388
0x50000000–0x7EFFFFFF 752 MiB Anonymous page space (PAGE_START, PAGE_END): VirtualAlloc(NULL, ...), guest thread stacks and TEBs, MapViewOfFile snapshots, D3D9 resource storage. Explicit reservations may also extend into it. host.c:64-65
0x7FFDE000 4 KiB Engine thread TEB (MAIN_TEB_BASE, also ENGINE_DEFAULT_FS_BASE) host.c:66
0x7FFDF000 4 KiB PEB (image base at +0x08) host.c:68
0xFE000000–0xFEFFFFFF tokens Magic module handles (MAGIC_MODULE); never dereferenced host.c:70
0xFF000000–0xFFFFFFFF tokens Magic import/COM procedure addresses (MAGIC_PROC); dispatch targets only host.c:69

TEB fields written by setup_thread_teb (host.c:535-557): +0x00 SEH chain end (0xFFFFFFFF), +0x04 stack base (high), +0x08 stack limit (low), +0x18 self, +0x20 process id 4660, +0x24 thread id, +0x2C pointer to a 64-entry static TLS array (heap), +0x30 PEB; +0x34 receives SetLastError values; +0xE10 holds the 64 TlsAlloc slots.

flowchart LR
    A["0x00000000 null guard"] --> B["0x00100000 main guest stack"]
    B --> C["0x00400000 halo.exe image"]
    C --> D["0x02000000 guest heap (992 MiB)"]
    D --> E["0x40000000 explicit pools"]
    E --> F["0x50000000 page space (752 MiB)"]
    F --> G["0x7FFDE000 TEB / 0x7FFDF000 PEB"]
    G --> H["0xFE000000+ magic handles and procs"]
Loading

The guest heap

The heap serves HeapAlloc, GlobalAlloc, LocalAlloc and the *ReAlloc functions (see Win32 Compatibility Layer), the host's own guest-visible objects (COM objects, strings via guest_strdup, TLS blocks, resource copies), and nothing else. Its design is stated in the comment at host.c:81-92:

Preserve the original window and 16-byte prefix/alignment. The size dword at payload-4 is a compatibility mirror only: ownership, sizes and links are native metadata, never trusted guest bytes. Free ranges are size-binned and linked in address order for splitting/coalescing. Exact live bases are hashed separately, so interior/fabricated frees cannot damage the allocator.

Data structures

Item Definition
HeapBlock {base, span, requested, live, prev, next (physical neighbours), free_prev, free_next (size bin), hash_next (live hash or spare chain)} (host.c:98-105)
HeapSlab 256 HeapBlock nodes allocated with calloc and never freed; nodes are recycled through heap_spare
heap_initial static block covering the whole window [0x02000000, 0x40000000)
heap_bins[32] free blocks binned by floor(log2(span))
heap_hash[16384] live blocks keyed by payload address (payload >> 4, then a multiply/xor-shift mix)
Statistics heap_live_bytes, heap_peak_bytes, heap_total_bytes, heap_live_blocks, heap_slab_count, heap_ptr (address high-water, never a cursor)
heap_lock one pthread_mutex_t for all of it

Constants (host.c:93-97): HEAP_HEADER_SIZE 16, HEAP_MIN_SPAN 32 ("even a zero-byte allocation owns a distinct payload"), HEAP_BIN_COUNT 32, HEAP_HASH_COUNT 16384, HEAP_NODES_PER_SLAB 256.

Allocation (guest_alloc, host.c:169-216)

  1. Requests above 512 MiB (0x20000000) are fatal ("request too large").
  2. Round the size up to 16 (0 becomes 16) and add the 16-byte header: span.
  3. Under heap_lock, search bins from bin(span) upward and take the first block in a bin whose span is large enough (first fit within the size class).
  4. If none: unlock, log guest heap exhausted, backtrace, host_exit(3).
  5. If the remainder would be at least 32 bytes, split it into a new node (from the spare list or a new slab; failure to get a node is fatal "metadata exhausted") and bin it.
  6. Mark the block live, record the requested size, insert it into the live hash, update statistics and the high-water mark.
  7. Zero the entire span, including the 16-byte prefix, the rounding padding and any unsplittable tail ("including recycled bytes and tiny tails that cannot be split"), then write the requested size to payload - 4 as a compatibility mirror.
  8. Return base + 16, which is always 16-byte aligned.

Every allocation is therefore zero-filled, regardless of HEAP_ZERO_MEMORY/GMEM_ZEROINIT flags.

Free (guest_free, host.c:225-249)

  • Pointers outside the heap window, below HEAP_START + 16, or not 16-byte aligned are ignored.
  • The pointer must be found in the live hash as an exact payload address; otherwise (interior pointer, fabricated pointer, double free) the call is ignored.
  • The block is removed from the hash, marked free, and coalesced with free physical neighbours (recycling their nodes), then binned.

guest_alloc_size(ptr) (host.c:217-224) returns the requested size of a live exact payload, else 0. HeapSize, HeapReAlloc and GlobalReAlloc use it, so a guest overwrite of the size mirror cannot make a realloc over-copy (test_win32_heap_reuse.c writes 0xFFFFFFFF there and checks).

Statistics

host_heap_stats(out[6]) (host.c:250-261) takes the lock and returns: live requested bytes, peak live requested bytes, cumulative requested bytes, live allocation count, native metadata bytes (the static block, bins and hash plus all slabs), and address high-water measured from HEAP_START (including prefixes and alignment). EngineDiagnosticsBridge.m copies it into the device report.

Aliases

Raw guest pointers cannot distinguish a stale alias after the same address is allocated again; the allocator guarantees only that an alias stays valid until its owner frees the allocation. The DirectSound objects below rely on exactly that.

The page allocator

guest_page_alloc provides VirtualAlloc-style memory with 64 KiB granularity from [0x50000000, 0x7F000000). Its rules are in the comment at host.c:262-266: "Page allocations are reclaimed only by their owning host resource. Keep the allocation map outside guest memory, so a guest write cannot corrupt it. A bitmap search naturally joins adjacent freed allocations without metadata allocation or an ever-growing free list. Explicit game pool reservations are excluded from this allocator and cannot be freed through this API."

Data structures (host.c:267-275)

Item Meaning
PAGE_GRANULARITY 0x10000
PAGE_COUNT (0x7F000000 - 0x50000000) / 0x10000 = 12032 granules
page_allocations[PAGE_COUNT] per granule: 0 = free; n = first granule of an n-granule allocation; PAGE_CONTINUATION (UINT32_MAX) = interior granule; PAGE_EXPLICIT (UINT32_MAX - 1) = part of an explicit reservation
page_used[PAGE_COUNT] granule has been handed out before (may hold non-zero bytes)
page_top high-water diagnostic
page_live_granules, page_logged_step live anonymous use and the last logged 32 MiB step
page_lock one mutex

Operations

Function Behaviour
guest_page_alloc(size) (host.c:296-330) Size 0 returns 0. Rounds up to 64 KiB; a request larger than the whole space is fatal. First-fit linear scan for count consecutive free granules (fatal guest page space exhausted if none). Marks the run, raises the high-water, logs on each 32 MiB step of live use, and zeroes any granule that was used before; never-used granules are left as the lazily zero mmap pages.
guest_page_free(base) (host.c:331-351) Only an exact allocation base inside the page space succeeds (interior, unaligned, continuation, explicit, duplicate frees return 0). Clears the run and then remaps it with `mmap(MAP_FIXED
guest_page_reserve_explicit(addr, size) (host.c:352-388) Honours a fixed-address VirtualAlloc in [0x40000000, 0x7F000000). Fails for size 0, an address below 0x40000000, or a range ending past PAGE_END. Where the range overlaps the page space: if it lies entirely inside an existing anonymous allocation (including from an interior address) it is a recommit and returns addr without changing ownership; if it straddles or subsumes an independent live allocation it fails; otherwise every free granule in it is zeroed if previously used and marked PAGE_EXPLICIT. Explicit granules are never reclaimable.
host_page_stats(out[3]) live anonymous bytes, high-water offset, region size.

The growth log ([pages] live=... MiB high-water=... MiB of 752 MiB, host.c:276-287) is printed whenever live use crosses a 32 MiB boundary in either direction, so "a leak across level loads shows as a staircase, reclamation as a rise and fall".

Owners

Only host code that created a page allocation frees it:

Owner Allocates Frees
host_initialize_guest_thread guest thread stack and a granule for its TEB never
VirtualAlloc shim guest_page_alloc or guest_page_reserve_explicit never (VirtualFree is a no-op)
MapViewOfFile shim a private snapshot of the file range never (UnmapViewOfFile is a no-op)
d3d9.c texture levels and cube faces, vertex/index buffers, surfaces, volumes on resource release and when a surface is resized (d3d9.c:168-180, d3d9.c:372-373)
panorama_render.inc the panorama HUD surface when it is replaced (panorama_render.inc:263-271)

D3D resources are the reason reclamation exists: each level load creates and destroys many textures and buffers, and before reclamation the 752 MiB space was a lifetime limit ("Exceed the old 752 MiB lifetime limit many times with a bounded live set" in the test). See Direct3D9 Bridge.

Example owner: DirectSound objects

DirectSound COM objects live on the guest heap and show how host-side ownership and guest aliases combine (directsound.c:481-600):

  • object_new takes the lowest free slot in a native objects[] table, allocates native PCM storage and a mixer voice, then guest_allocs the guest-visible lock buffer (guest_data) and a 16-byte guest COM object whose dword 0 is the vtable and dword 1 the native slot index. On any failure everything allocated so far is released.
  • object_from_guest trusts the guest only for the slot index and then checks that the slot is alive and that the pointer is exactly that slot's primary or 3D interface object.
  • QueryInterface for IDirectSound3DBuffer/IDirectSound3DListener lazily allocates a second 16-byte guest object (guest_3d) with the same slot index and adds a reference.
  • object_release frees guest_data, guest_3d and guest only when the shared reference count reaches zero, after removing the voice from the mixer under the mixer's lock, so "the callback can no longer see its voice" and a creator cannot reuse the slot during removal.

tests/test_guest_heap_sound_owners.c runs 4096 create/QueryInterface/release cycles with the real mixer rendering concurrently on another thread, asserting that the same guest address is reused each cycle, that releasing the primary interface while the 3D alias holds a reference keeps all guest storage live (a scratch allocation must not land on it), that the final release frees all three blocks and returns the heap to a single free block, and that only one metadata slab is ever needed. See Audio System.

Failure modes

Condition Result
guest_alloc larger than 512 MiB, heap exhausted, or slab calloc failure Logs reason, high-water and current import; guest backtrace; host_exit(3). The lock is released first ("Never escape while holding heap_lock: main-context failures longjmp").
guest_page_alloc too large or page space exhausted Log and host_exit(3).
Invalid guest_free / guest_page_free Ignored / returns 0; allocator state unchanged.
Guest corrupts the 16-byte prefix or size mirror No effect on the allocator; sizes come from native metadata.
Guest touches [0, 0x10000) SIGSEGV, caught by the host's handler, which logs the guest PC and backtrace and exits 4.

Tests

Test What it asserts
test_guest_heap.c Failed metadata allocation leaves state unchanged and unlocks; zero-size blocks are distinct (a == HEAP_START + 16, 32-byte spacing); a corrupted prefix and forged interior header do not change sizes; invalid and duplicate frees are ignored; freed storage is reused and fully zeroed; split and coalesce restore the single initial block; oversize requests (UINT32_MAX, 0x20000001) exit with status 3 without wrapping; more than 8 GiB of mixed-size turnover keeps one slab; four threads churning 12000 allocations each leave at most two slabs; exact window exhaustion; statistics consistency. Can be built with ASan/UBSan or TSan per its header.
test_guest_pages.c Exact-base ownership; interior, unaligned, out-of-range and duplicate frees rejected; reuse returns zeroed memory; adjacent freed runs coalesce; explicit pools spanning the page space zero earlier contents, cannot be freed, and recommitting inside an existing allocation keeps its contents; wrap-proof size rounding; 4096 allocate/free cycles of 1 MiB each (4 GiB of turnover, far beyond the old 752 MiB lifetime limit); four threads of concurrent churn; nothing left allocated.
test_win32_heap_reuse.c Win32 heap shims over the real allocator: sizes from native metadata despite a forged mirror, realloc grow/shrink copy and zero-fill, double HeapFree harmless, GlobalLock returns an alias, LocalAlloc lands at HEAP_START + 16 after everything is freed, more than 2 GiB of HeapReAlloc turnover returns to one free block and one slab.
test_guest_heap_sound_owners.c DirectSound ownership and alias lifetime on the real heap with a concurrent mixer, as described above.

All four are compiled by tools/run_source_checks.py on macOS with -ffunction-sections -fdata-sections -Wl,-dead_strip so the rest of host.c is stripped and no translated code or game data is needed.

Related pages

Clone this wiki locally