Skip to content

Engine Overrides and Hooks

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

Engine Overrides and Hooks

Most of the original engine runs exactly as translated. A small, explicit set of guest function entries is intercepted by the host instead: some are replaced outright by native code that produces the same result faster or more safely, some are observed for diagnostics without changing anything, and a few have host behaviour attached at an engine boundary (the campaign unlock, the panorama renderer). All of these go through one function, engine_dispatch_override in overrides.c, which the generated dispatcher consults before running a translated function. This page explains that mechanism, why every hooked address must also be listed in engine_hooks.h, and each override and observer: the CRT replacements, native sort and visibility, native geometry leaves and gathers (summarised; details on Geometry Fast Paths), the campaign unlock, the HSC script trace, the a10 diagnostics, game-time telemetry and the menu pointer.

Source files

File Role
overrides.c engine_dispatch_override, the override table, CRT/FP replacements, native int sort, visible-surface switch, native gather switch and diagnostics, dispatch-interest filter.
native/EngineReuse/engine_hooks.h ENGINE_HOOK_ADDRESSES, ENGINE_TRACE_HOOK_ADDRESSES, engine_hooked(), ENGINE_DIRECT.
campaign_unlock.h halo_unlock_campaign_menu().
hsc_trace.inc HSC script-thread trace at the scheduler.
a10_control.inc, a10_gamepad.inc a10 diagnostics and the optional default gamepad preset.
audio_ownership_trace.inc, model_capture_hooks.inc Default-off observers for sound ownership and model capture.
panorama_hooks.inc, frame_pacing_hooks.inc Panorama renderer and pacing hooks (see Panorama System, Frame Pacing).
native_leaves.h, native_gather.h, native_gather_tree.h, visible_surfaces.h, gather_diagnostics.h Native compute replacements (see Geometry Fast Paths).
core_telemetry.h Read-only game-time tick counter.
pointer.c, pointer.h, pointer_step.inc Menu cursor servo driven by gaze/pinch.
Tests: test_dispatch_interest.py, test_engine_direct_calls.c, test_game_time_telemetry.c, test_pointer_step.c, test_pointer_input.c See Tests.

How a call reaches an override

flowchart TB
    A["translated call site to fixed entry X"] --> B{"engine_hooked(X)? (compile-time constant switch)"}
    B -- no --> C["ENGINE_DIRECT: pc = X, call sub_X(cpu)"]
    B -- yes --> D["engine_dispatch(cpu, X)"]
    E["indirect call / callback / jump table"] --> D
    D --> F{"X >= 0xFE000000"}
    F -- yes --> G["engine_dispatch_external (Win32 shim)"]
    F -- no --> H["engine_dispatch_override(cpu, X)"]
    H --> I{"bit for X set in dispatch_interest?"}
    I -- no --> J["return 0"]
    I -- yes --> K["ordered checks (table below)"]
    K -- "handled: return 1" --> L["caller continues at cpu->pc"]
    K -- "not handled: return 0" --> J
    J --> M["engine_record(X), binary search, run translated sub_X"]
Loading

Why hooks must be listed in engine_hooks.h

Originally every translated call went through engine_dispatch: the override filter, the crash call record, a binary search over 8336 entries and an indirect branch. The comment in engine_hooks.h records that on b30 that was about a tenth of the engine thread, and that 25,835 of the 32,373 translated call sites name a fixed function entry. The generator therefore emits those call sites as ENGINE_DIRECT(cpu, address, sub_X), which calls the C function directly unless engine_hooked(address) is true (engine_hooks.h:65-69). Because the test is on a constant, it folds away at each call site.

Consequences:

  • Adding a hook requires adding its address to ENGINE_HOOK_ADDRESSES and recompiling the translated chunks. Otherwise direct call sites bypass engine_dispatch_override and the hook silently never runs for them (it would still run for indirect calls).
  • ENGINE_TRACE_HOOK_ADDRESSES lists addresses only the HALO_A10_TRACE diagnostics compare against (004C8800, 00477EA0, 005527F0, 005528F0, 00511F30). In release builds these go direct, so the trace sees only their indirect calls; building the chunks with ENGINE_TRACE_HOOKS=1 routes them through dispatch too (engine_hooks.h:39-48).
  • Direct calls skip engine_record, so the crash dump's call ring contains only indirect calls.

The current hook list (engine_hooks.h:22-37):

Addresses Purpose
004C6E80 Main-loop tick: campaign unlock, a10 diagnostics and gamepad preset
00442550, 00544090, 00544120, 0048A1A0 Audio ownership trace; 0048A1A0 is also the HSC scheduler
004D6FC0, 00533850, 00533730 Model capture
00492430, 0052B050, 00518F40, 004924B0, 005154A0, 005537C0, 0050CC40, 00449780, 00494730, 004984C0, 0050BEA0, 0050BFB0, 0050BA80, 0050F740, 0050BDC0 Panorama renderer hooks
00626BA4, 00631930, 00631260 CRT _mbstowcs, _mbtowc, _wctomb
00634D8E, 00634D50, 00634F61, 00634DCB MSVC FP intrinsic dispatchers
00449590 int32 sort
00553920 Visible-triangle marking
004CC0D0, 00554260, 005541B0, 00553380, 00552C20 Native leaves
00552DE0 BSP material walk
005540C0, 00553C40, 00553F10 Native light/shadow gather loops

The dispatch-interest filter

engine_dispatch_override is called for every dispatched address, so it starts with a conservative membership test (overrides.c:214-239): a 65,536-bit table (dispatch_interest[1024] of uint64_t) indexed by the low 16 bits of the address. A constructor sets the bits for every address in both engine_hooks.h lists and in the override table before any guest thread starts; the table is read-only afterwards. A clear bit returns 0 immediately; a set bit (a real hook or a collision) proceeds to the exact checks. Collisions only cost the slow path.

Order of checks

engine_dispatch_override runs these in order. "Consumes" means it returns 1 and the translated function does not run (or, for wrappers, has already been run by the hook).

# Address(es) Handler Consumes?
1 five leaf entries host_native_leaf_dispatch when HALO_NATIVE_LEAVES is not 0 if the native version accepted the inputs
2 005540C0, 00553C40, 00553F10 ov_native_gather when HALO_NATIVE_GATHER=1 if the native loop accepted
3 004C6E80 campaign unlock when HALO_UNLOCK_CAMPAIGN=1 no
4 sound functions host_audio_ownership_dispatch (HALO_AUDIO_OWNERSHIP_TRACE=1) yes when it wraps the call
5 model entries host_model_capture_dispatch (HALO_MODEL_CAPTURE) per its own logic
6 panorama entries host_panorama_dispatch per Panorama System
7 004C6E80 host_a10_gamepad_boundary no
8 004C6E80 and trace addresses host_a10_boundary no
9 0048A1A0 host_hsc_trace_boundary no
10 00449590 ov_int_sort if the callback is 00552C00
11 00553920 ov_visible_surfaces unless HALO_NATIVE_VISIBILITY=0
12 00552DE0 leaf_bsp_walk_dead when native leaves are on if the callback is a bare ret
13 override table entries CRT/FP replacements yes

Native replacements

CRT multibyte functions

Halo's statically linked MSVC CRT converts between multibyte and wide strings through locale tables. The overrides implement the C-locale behaviour directly, one wide character per byte (overrides.c:35-75). All are cdecl.

Entry Function Behaviour
00626BA4 _mbstowcs(dst, src, count) Widen bytes until NUL or count; NULL dst returns the length.
(absent) _wcstombs Implemented but the table address is 0xFFFFFFFF ("absent in this build"), so it never matches.
00631930 _mbtowc(wc, s, n) One byte; returns 1, or 0 for NUL / NULL / n == 0.
00631260 _wctomb(s, wc) One byte (? above 255); returns 1.

MSVC floating-point intrinsic dispatchers

__cintrindisp1 (00634D8E), __cintrindisp2 (00634D50), __ctrandisp1 (00634F61) and __ctrandisp2 (00634DCB) are the CRT's dispatchers for x87 transcendental functions. On entry edx points to a descriptor whose first byte is the name length followed by the name. The overrides read the operand(s) from the emulated x87 stack, compute with the host C library, write the result to ST(0) (popping one operand for the two-argument forms) and return to the caller (overrides.c:13-33).

Arity Names handled
1 exp, log, log10, sin, cos, tan, asin, acos, atan, sinh, cosh, tanh, sqrt
2 pow, fmod, atan2, hypot

An unknown name is logged ([fp] unknown 1-arg intrinsic) and the input is returned unchanged. The CRT's own error handling (__trandisp2 and errno/matherr paths) is skipped; results are those of the host libm in double precision.

Native int32 sort (00449590)

00449590 sorts an int32 array in place (eax = count, ecx = base, [esp+4] = a "greater than" callback): a quicksort with an insertion sort for eight or fewer elements, every comparison a translated callback call (overrides.c:77-97). The render pass calls it with callback 00552C00 (signed a > b), so the result is ascending signed order, and because equal elements are equal values any correct sort leaves the same bytes. The comment notes that under 00552CF0 this sort was about a tenth of each b30 bearing pass on the Mac. The override handles only that callback and counts up to 0x10000, calls qsort, and returns (eax and flags are dead for every caller). The other caller (00413D0F, callback 004127B0) keeps the translated routine.

Visible-surface marking (00553920)

00553920 marks the triangles of every visible subcluster by running the frustum test 0050D5B0 for each subcluster of each portal-visible cluster. visible_surfaces.h reimplements it natively (the header documents the exact float operation order it preserves). Selected by HALO_NATIVE_VISIBILITY (overrides.c:99-164):

Value Mode
unset / anything else native (default)
0 translated routine
verify run the translated routine, then restore the starting state (CPU, the 0x94-byte stack frames, the triangle bitset and its count) and run the native one; compare registers, flags, x87 state, bitset and stack; log the first 16 mismatches and a progress line at calls 1–4 and every 600th; always keep the translated result

Native leaves, BSP walk and gathers

These are bit-exact native versions of hot compute-only functions; their algorithms and exactness argument are on Geometry Fast Paths.

Entries Switch (default) Notes
004CC0D0 (4x3 matrix multiply), 00554260 (bounds planes), 005541B0 (bounds overlap), 00553380 (BSP node bounds), 00552C20 (surface list) HALO_NATIVE_LEAVES (on; 0 disables) Each declines (returns 0, translated code runs) if inputs overlap outputs, the guest is not rounding to nearest, the x87 stack lacks free registers, or the direction flag is set for string copies (native_leaves.h:1-30).
00552DE0 (BSP material walk) HALO_NATIVE_LEAVES Native only when its callback is a bare ret; handled after the a10 trace so the trace still sees every walk.
005540C0 (leaf gather), 00553C40 (cluster gather), 00553F10 (recursive collision-BSP gather) HALO_NATIVE_GATHER (off; only 1 enables; the visionOS app sets 1) Counted in HostGatherDiagnostics (calls, native_calls, fallback_calls, enabled), exposed by host_gather_get_diagnostics (overrides.c:178-203).

Campaign unlock

When HALO_UNLOCK_CAMPAIGN=1, every dispatch of 004C6E80 (called at the beginning of each main-loop iteration, outside Present) applies halo_unlock_campaign_menu to the player profile at guest address 0x00712DD8 (overrides.c:242-248):

/* Retail 1.10 profile_unlock_solo_levels, original instructions 00481400..1C.
 * Only the menu's ten difficulty masks and unlock flag change. The original
 * callback's disk-save and HSC return are deliberately not invoked here. */
static inline void halo_unlock_campaign_menu(uint8_t *profile) {
    profile[0x11c] |= 4;
    for (unsigned i = 0; i < 10; ++i) profile[0x11e + i] |= 15;
}

Because it runs each tick, it applies "after profile selection/replacement on the next main-loop tick". The comment in overrides.c calls it an "in-memory unlock only: existing checkpoints and save files are untouched"; the host itself writes no file. Whether the game later saves the modified profile through its own code is not determined by this function. The environment variable is read with getenv on each call (not cached). The visionOS app sets HALO_UNLOCK_CAMPAIGN=1 by default (Runtime Settings); test_dispatch_interest.py requires 004C6E80 to be in the hook list.

HSC script trace

hsc_trace.inc is a read-only, default-off trace of Halo script (HSC) threads, hooked at the entry of 0048A1A0, the per-tick script scheduler called from the main loop (never inside Present). Guarantees stated in its header: it never writes guest memory, bounds-checks every guest pointer with hsc_ok() (above the 64 KiB guard, no wrap, at most 16 MiB), and emits at most a bounded number of events per snapshot.

Variable Default Effect
HALO_HSC_TRACE unset (completely inert) Positive tick interval between snapshots; 0, empty or non-numeric → 120; 1 samples every tick.
HALO_HSC_TRACE_MAX 128 Maximum thread events per snapshot, capped at 512.
HALO_HSC_TRACE_ALL unset Also emit threads with wake == -1 (otherwise skipped but still counted in total).

The app sets HALO_HSC_TRACE=120 and HALO_HSC_TRACE_MAX=128 by default. Sampling uses 64-bit tick deltas and resamples immediately when the tick goes backwards (map reload).

Log contract (schema 1), one JSON object per line after [hsc-trace] :

{"schema":1,"event":"config","interval":I,"max":M,"all":0|1}
{"schema":1,"event":"thread","tick":T,"thread":i,"script":s,"kind":k,"wake":w,"state":"runnable|sleeping|dormant|indefinite","node":n|null,"opcode":o|null,"name":"..."|null}
{"schema":1,"event":"scheduler","tick":T,"enabled":0|1,"total":N,"emitted":M,"truncated":0|1}

state is runnable for 0 <= wake <= tick, sleeping for wake > tick, dormant for wake == -2, indefinite for other negative values ("Negative wake values alone do not prove completion or a deadlock"). The guest layouts it reads, each validated against named decompiled functions in the header (hsc_trace.inc:30-46):

Global Layout used
0087A470 script-thread datum array +0x2E last index (s16), +0x34 element base; element stride 0x218: +0 salt (0 = free), +4 script index, +8 wake tick, +0x10 current frame pointer, +0x18 stack start
frame +4 node datum (must lie inside the thread's own element)
0087A474 node datum array stride 0x14; +2 function index reported as opcode
006F1D6C game time +0x0C current tick
006B15E8 scheduler enabled byte
00746F8C scenario tag +0x49C script count, +0x4A0 script table (stride 0x5C, name at +0, kind at +0x20); a name is emitted only if its 32 bytes are printable ASCII and NUL-terminated

The header notes that it fixes two errors of an older scanner in d3d9.c (HALO_SCAN_THREADS): wake == -2 was labelled runnable, and the thread type byte was mislabelled as the script kind.

a10 diagnostics and gamepad preset

Both run at 004C6E80 and never consume the call.

  • HALO_A10_TRACE (any value) (a10_control.inc): logs [a10], [a10-cinematic] and [a10-view] lines on the first three main-loop iterations and every 60th (tick, game mode, profile count, player/unit/controlled handles, cinematic and input-block flags, camera and unit positions), plus [a10-call], [a10-bsp], [a10-material] and [a10-effects] lines at the trace-only addresses. Campaign mode is game mode 0.
  • HALO_A10_GAMEPAD=1 (a10_gamepad.inc): on the levels\a10\a10 campaign with a connected controller and a player, assigns DirectInput device 0 to slot 0 if both are unassigned and fills only empty (0x7FFF) axis and button bindings with a session preset (left stick move, right stick look, standard button actions), plus accept/back buttons. "No poses, player commands, script gates or profile files are changed here." With HALO_A10_TRACE also set it logs [a10-pad] state. See Input and Controllers.

Other observers

Hook Switch What it does
host_audio_ownership_dispatch (audio_ownership_trace.inc) HALO_AUDIO_OWNERSHIP_TRACE=1 Logs [audio-ownership] snapshots of the sound tag table around 00442550, 00544090, 00544120 (each wrapped call executes exactly once with its own ABI) and table changes at 0048A1A0; at most 512 reports. See Audio System.
host_model_capture_dispatch (model_capture_hooks.inc) HALO_MODEL_CAPTURE Records the normal-world and deferred model draw boundaries (004D6FC0, 00533850, 00533730) for the one-frame model vertex capture; "No original function is skipped". See Diagnostics and Telemetry.
host_panorama_dispatch (panorama_hooks.inc) HALO_PANORAMA and related Renders several directions at one simulation time by driving the original renderer. See Panorama System; pacing is in Frame Pacing.

Game-time telemetry

The core telemetry measures how many game ticks each presented frame ran, without any hook. core_telemetry.h documents, verified against the generated sub_00470BF0.c (the tick driver): the pointer at 006F1D6C names game_time_globals; after each returned call to 0045B780 the driver increments the dword at +0x0C; it stores the last call's tick count (a word) at +0x10, which the inactive-game branch zeroes. "Reading this counter introduces no intercepted address or guest write."

  • halo_game_time_read(flat, sample) (core_telemetry.h:83-97) reads the counter and last-call word if the globals pointer is in [0x10000, 0xFFFFFF00] (zero until the game allocates them; the first 64 KiB is the null guard).
  • halo_frame_ticks(before, now, &resync) returns the counter delta, or 0 with resync set when either sample is invalid, the globals moved, the counter went backwards, or it jumped by more than HALO_TICK_JUMP (120) ticks. +0x10 is only a comparison ("Never guess using last_call").
  • The engine thread samples once per Present in engine_thread_sample (EngineVisionRuntime.m); frames are bucketed by 0, 1, 2, 3 and 4+ ticks. See Diagnostics and Telemetry.

tests/test_game_time_telemetry.c compiles the real generated sub_00470BF0.c (it prints SKIP if the translation is not on the include path), stubs its callees, and for 0–120 requested ticks in all three game modes plus inactive and forced-single-tick cases asserts that the counter delta equals the number of game_tick calls, that +0x10 matches, and that reading the counters changes neither guest memory nor any CPU/x87 state.

Menu pointer (pointer.c)

On the headset there is no mouse, but Halo's front-end and pause menus follow a DirectInput mouse cursor. pointer.c makes the engine's own cursor converge on where the player is looking and turns a pinch into a click, without patching the menu code (pointer.h:4-15).

Constant Value Source
Cursor X / Y globals 0x00718F84, 0x00718F88 (int32) located by the desktop probe moving a mouse and watching guest memory
UI extent 640 x 480 the clamps the probe read
Tap queue capacity 16
Arrival tolerance Manhattan distance ≤ 6 px

Producer (app threads): host_pointer_set(u, v, on_panel, action) (pointer.c:38-57), reached through enginevision_pointer from EngineImmersive.swift. u, v are normalized (0,0 top-left) and mapped to UI pixels; NaN/infinite or off-panel targets are invalid. Actions:

Action Value Effect
HOST_POINTER_HOVER 0 Replace the hover target (the pinch's gaze preview, or the head ray when the "Head pointer" setting is on).
HOST_POINTER_TAP 2 Also queue a click at this target (once per successful pinch); a queued tap keeps its own target until the cursor arrives, so later hover samples cannot redirect it.
HOST_POINTER_CANCEL -1 Drop pending taps and hover; release on the next servo. Sent on session start/end.

Consumer (engine thread): host_pointer_servo() runs once per Present, before DirectInput is read (d3d9.c:543, pointer.c:59-92):

  1. If no menu is active (host_dinput_menu_active()), clear all pending input and release a held button, then return. "Never carry pending menu input into gameplay or the next menu"; the controller keeps owning aim and fire in play.
  2. Read the engine's cursor from guest memory (published for the report via host_pointer_stats).
  3. If the button was pressed last frame, release it this frame and do nothing else: each click is exactly one engine frame down and one up.
  4. Target the oldest queued tap, else the hover target. If a tap's target is reached, press button 0 (host_mouse_buttons[0] = 0x80) and dequeue; otherwise add a servo step to host_mouse_dx/dy.

Servo step (pointer_step.inc): the engine accelerates mouse input; the desktop probe measured that d counts move the cursor d pixels up to about 5, and d + 0.047 d^2 beyond (8 → 11, 16 → 28, 32 → 83, 64 → 268, 96 → 520). A proportional step in counts would therefore overshoot and ring. host_pointer_step plans 0.7 of the remaining distance in pixels, caps it at 300 px per axis, and converts pixels to counts through the inverse curve (-1 + sqrt(1 + 4kp)) / 2k with k = 0.047 (identity up to 5 px). "A glance across the panel lands in three frames, a small correction at once."

host_pointer_enabled() reports the live "Head pointer" setting (halo_settings_gaze_pointer, seeded from HALO_GAZE_POINTER, default off); system taps work regardless. See Runtime Settings and Input and Controllers.

Tests

Test Asserts
test_dispatch_interest.py Parses engine_hooks.h, the hook fragments and overrides.c: every address any hook compares against (plus 004C6E80) and every override-table entry is in ENGINE_HOOK_ADDRESSES; only a10_control.inc addresses may be trace-only; behavioural hooks are never in the trace list; overrides.c feeds both lists and the table into dispatch_interest.
test_engine_direct_calls.c ENGINE_DIRECT sets pc and calls the function for unhooked entries; hooked entries (0050BEA0, 00449590, 00553920, 00552DE0) go through engine_dispatch; 00511F30 goes direct unless ENGINE_TRACE_HOOKS; every listed address is hooked and its neighbour is not.
test_game_time_telemetry.c Described above.
test_pointer_step.c Against the measured curve: a glance across the panel settles within 6 frames with ≤ 6 px overshoot in both directions; a small correction within 3; still converges within 10 frames on a steeper curve (k = 0.060, overshoot ≤ 40) and a gentler one (k = 0.035); no motion on target; a full jump is at most 75 counts.
test_pointer_input.c Includes pointer.c with a simulated engine cursor: a fast tap survives later hover and clicks at its own target; three rapid taps produce three separate down/up pairs at their targets; cancel drops pending taps and releases a delivered click; leaving the menu clears input and does not overwrite the controller's button state; invalid targets never click; the queue is bounded at 16.

Other native replacements have their own differential tests (test_native_leaves.c, test_visible_surfaces.c, test_native_gather.c); see Geometry Fast Paths.

Related pages

Clone this wiki locally