Skip to content

Function Address Lists

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

Function Address Lists

The translator does not try to discover Halo's functions heuristically from the raw code section. It starts from two checked-in lists of function entry addresses in decompilation/c9acf0c46954/, follows the call graph from them, and uses them to decide where one function ends and the next begins. These lists are the only Halo-specific data in the public source tree: they are plain numbers, contain no code or game data, and are valid only for the one supported halo.exe. This page documents their naming, format, contents, and how the Static Translation Pipeline consumes them.

Source files

File Role
decompilation/c9acf0c46954/function-addresses.txt Primary function entry index (6,495 addresses)
decompilation/c9acf0c46954/extra-function-entries.txt Additional entries (1,627 addresses)
tools/generate_engine_reuse.py Reads the lists (@file arguments, known-function set)
tools/setup_halo.py Passes both lists; fingerprints them to decide when to regenerate
native/EngineHost/Makefile Generation rule using both lists
docs/BUILDING.md User instructions; notes the lists are executable-specific
SOURCE_MANIFEST.json Pins both files by size and SHA-256 for the source release

The directory name

c9acf0c46954 is the first 12 hexadecimal digits of the SHA-256 of the supported executable:

c9acf0c469543283cfed6d7dc04ade976dbdfc7cb4532cf070386de169c19545   halo.exe (Halo PC 1.10 retail)

The full digest is the PE_SHA256 constant in tools/engine_reuse/upstream.py:22, which the generator checks before reading anything else. The same 12-digit prefix is stamped into the comment line of the generated engine_imports.c (Generated from halo.exe c9acf0c46954 ...) and appears in host comments that cite this build (for example directsound_engine_state.inc). Keying the directory by digest makes it explicit that the numbers mean nothing for any other build: BUILDING.md says "Address lists are specific to that executable; Halo Custom Edition, Anniversary, and other builds are not interchangeable."

Format

Both files share one format:

  • one address per line, exactly eight lower-case hexadecimal digits, no 0x prefix (00401000);
  • sorted in ascending order;
  • no duplicates within a file, and no address appears in both files;
  • no comments or blank lines.

The generator parses each file with (VISION/path).read_text().split() and int(x, 16) (line 85). That parser is more permissive than the files: any whitespace separates tokens, upper-case digits and a 0x prefix are accepted, and order does not matter. It does not accept comments; a # token raises ValueError. The path after @ is resolved relative to the repository root, not the shell's working directory.

Contents

function-addresses.txt extra-function-entries.txt Union
Entries 6,495 1,627 8,122
Lowest 00401000 004010e0 00401000
Highest 0063957e 00639563 0063957e
16-byte aligned 4,571 1,296
Not 16-byte aligned 1,924 331

(Counts measured on the tree at commit 9f915af.)

function-addresses.txt

The generator calls this file the index and treats it as a fallback for a richer source (lines 81-82):

index = VISION/'decompilation/c9acf0c46954/function-addresses.txt'
known = {int(p.stem,16) for p in (VISION/'decompilation/c9acf0c46954/functions').glob('*.c')} \
        or {int(line,16) for line in index.read_text().split()}

If a directory decompilation/c9acf0c46954/functions/ of per-function C files exists, the known set is taken from their file names. Host comments cite such files by name, for example "validated against decompilation/c9acf0c46954/functions/" with references like 0048a1a0.c:12-18 in hsc_trace.inc. That directory is not part of the public tree, so a public checkout always uses function-addresses.txt, whose naming (lower-case, eight digits) matches those file stems. The repository does not document how the per-function decompilation was produced.

extra-function-entries.txt

Additional entry points that are not in the index but that the translation needs as separate functions. The repository does not document how each was found. From the code, the categories that need explicit listing are:

  • functions reached only through pointers stored in data (vtables, callback tables), which neither the call-graph walk nor the --discover immediate-operand scan can see;
  • alternate entries into the middle of another function's body. The generator's comment names one: "Alternate entries into this body are listed as functions too (the script interpreter 424C20 has 424C4E)"; both 00424c20 and 00424c4e are in this file;
  • addresses the host hooks. Every address in ENGINE_HOOK_ADDRESSES and ENGINE_TRACE_HOOK_ADDRESSES (engine_hooks.h) is in one of the two lists; 00511f30 (a per-draw render call watched by the HALO_A10_TRACE diagnostics) is in this file and the rest are in the index.

How the lists are consumed

flowchart TD
    F1["function-addresses.txt"] -->|"@file"| E["entries (BFS queue seed)"]
    F2["extra-function-entries.txt"] -->|"@file"| E
    F1 -->|"index fallback"| K["known (function boundaries)"]
    E -->|"known.update(entries)"| K
    K --> D["EngineDisassembler: branch or fallthrough into a<br/>known entry = tail call, not part of this body"]
    K --> J["jump-table plausibility bound:<br/>next known entry after the decoded body"]
    E --> W["closure walk: decode, lift, enqueue callees"]
    W --> DISC["--discover: immediates added to known and queue"]
    W --> T["engine_entries[] (sorted) in engine_bundle.c"]
    DISC --> T
Loading
  1. Seeds. Every listed address is queued for translation. With --max-functions 10000 and 8,122 seeds, the cap leaves room for functions found only as callees or through --discover. The dispatch-table size quoted in engine_hooks.h for one build ("a binary search over 8336 entries") is larger than the seed count for that reason; the exact number for a given run is ENGINE_FN_COUNT in engine_bundle.c and functionsGenerated in generation.json.
  2. Boundaries. The known set tells EngineDisassembler where a body stops. A jump, or a fallthrough after a conditional jump or a plain instruction, into another known entry is recorded as a tail call and lowered to ENGINE_DIRECT(...); return; or engine_dispatch(...); return; instead of being decoded as part of the current function. This is how CRT routines that fall through into a shared epilogue are handled.
  3. Jump-table bounds. While harvesting jmp [idx*4 + table] targets, the generator accepts pointers up to the next known entry after the decoded body; pointers to other known entries are skipped (Static Translation Pipeline).
  4. Hand-checked switches. 005590a0, 005061c0 and 005067b0, all in the index, get their jump-table targets from asserted byte patterns rather than from the generic harvester.

Effect of adding or removing an entry

Because the lists define function boundaries, they change the generated code, not just its coverage:

  • Adding an address that lies inside an existing function's body makes it a separate function. Branches from the old body to it become tail calls, and it gets its own sub_XXXXXXXX and dispatch-table entry. This is required when the host needs to hook that address, because engine_dispatch and ENGINE_DIRECT operate on function entries.
  • Removing an address can merge it back into a neighbor, or drop it from translation if nothing else reaches it, in which case a dispatch to it lands in the preceding function's L_ENTRY switch and fails with dispatch to non-leader address.

test_decode.py demonstrates both behaviors on the same bytes: with 0x4D05CA in the known set, function 0x4D0580 ends with a tail call to it; without it (as in the shipped lists, which do not contain 004d05ca), the epilogue is an internal block.

Regeneration and integrity

  • The setup wizard includes the bytes of every decompilation/**/*.txt in its generation fingerprint (setup_halo.py:363-370), so editing a list triggers regeneration on the next setup run. The macOS Makefile does not track the lists; delete native/build/engine-reuse/whole-exe/engine_bundle.c (or rerun the generator) after editing them.
  • SOURCE_MANIFEST.json records each list's size and SHA-256 (553496156c... for the index, fa60af4788... for the extras) as part of the source release inventory; RELEASING.md describes how that manifest is used. A change to a list must be reflected there for a release.
  • The lists are inputs to every generated file, so generation.json records sourceExecutableSHA256 and per-function generatedSHA256 values that change when boundaries change.

Contributor checklist

When an entry needs to change (for example to hook a new function):

  1. Confirm the address is an instruction boundary in the supported executable and that it is genuinely entered from elsewhere (call, pointer, or jump from another function).
  2. Add it to extra-function-entries.txt, keeping eight lower-case digits and ascending order.
  3. Regenerate with the canonical command and check generation.json for a new undecodable or unsupported record at that address, and for changed tailCalls in its neighbor.
  4. If the address is hooked, list it in ENGINE_HOOK_ADDRESSES in engine_hooks.h and run native/EngineHost/tests/test_dispatch_interest.py (see EngineReuse Runtime).
  5. Update SOURCE_MANIFEST.json for the release.

Related pages

Clone this wiki locally