Repository navigation
Frame Pacing
Frame pacing is an optional, experimental, off-by-default controller. It holds each finished panorama frame briefly before committing it to the GPU, so that new world pictures reach the compositor on an even cadence rather than whenever the engine happens to finish. It works on a nominal 90 Hz display grid. Each cadence ("rung") is a whole number of display periods: 2..6, that is 45, 30, 22.5, 18 or 15 fps. Recent busy times choose a sustainable rung with hysteresis, and overload runs free.
The pacer never changes the contents of a bearing, guest game time, or Halo's own frame limiter (frame_pacer.h:3-19). It does feed its cadence back to the panorama bearing budget, so the time a slower cadence leaves over is spent redrawing bearings rather than waiting.
The pacer sits at exactly one point: the zero-copy publication in metalwin_present_gpu, between reading the finished frame's metadata and mr_commit_async (EngineVisionRuntime.m:449-457). Frames on the CPU byte-copy path, dropped frames and incomplete frames are never paced.
| File | Role |
|---|---|
native/EngineHost/frame_pacer.h |
Platform-independent controller (header-only): window, cost model, rung choice, slot computation, budget target; report and display structs. |
native/EngineHost/frame_pacing_hooks.inc |
Host glue: clock, guest-derived limits, blocking wait, idle accounting, budget retargeting, locked report snapshot. Included into overrides.c after panorama_hooks.inc. |
native/EngineHost/halo_settings.c |
HALO_FRAME_PACING seeding; live frame_pacing setting. |
native/EngineHost/shims_kernel32.c |
host_frame_idle_wait: counts the pacer's wait as presenting-thread idle. |
native/EngineVision/Sources/EngineVisionRuntime.m |
Display observations for the pacer (compositor latch times, publish lag), and the paced commit. |
native/EngineVision/Sources/EngineWorldCadence.swift |
Presenter-side measurement of how evenly distinct world epochs were submitted. |
native/EngineVision/Sources/EngineDiagnostics.swift |
framePacing diagnostics block. |
native/EngineHost/tests/test_frame_pacer.c, frame_pacer_traces.inc, test_frame_pacing_host.c
|
Controller arithmetic, trace-derived simulation, host-glue test. |
halo_settings.c says "Aggregate headset traces cannot establish a clear per-frame win. Keep experimental pacing opt-in, including after malformed input" (halo_settings.c:77-81). The test's header makes the same point: the trace simulation is "a regression test, not headset evidence and not sufficient to enable pacing by default".
The settings panel exposes it as "Even frame cadence". Its note warns that it "can lower the frame rate slightly", and that while pacing, "the pace rather than Keep at least sets how much of the sphere behind and beside you is redrawn" (EngineSettingsView.swift:94-97, EngineSettingsView.swift:154-158).
sequenceDiagram
participant E as Engine thread (metalwin_present_gpu)
participant H as host_frame_pacer_present
participant C as Controller (frame_pacer.h)
participant BU as Bearing budget
participant M as Metal / compositor
E->>E: host_panorama_frame(), incomplete? release slot, return (no pacing)
E->>E: display_for_pacer(): latest latch, median interval, median publish lag
E->>H: host_frame_pacer_present(display)
H->>H: scene epoch changed? reset controller
H->>H: mode = timedemo ? OFF : live setting
H->>H: work = (now - last release) - idle since last release
alt mode OFF
H->>BU: if it was pacing: retarget to 1/target_fps
else EVEN
H->>C: halo_frame_pacer_slot(now, work, floor_work, ticks, latch, lead, min/max period)
C-->>H: slot time, shortened flag
H->>BU: if shortened: retarget to the new budget target
H->>H: mach_wait_until(min(slot, now+100 ms)), host_frame_idle_wait(start,end)
end
H->>H: release = now, publish report snapshot (mutex)
H-->>E: release_ns
E->>M: mr_commit_async(gpu_published)
M-->>E: completion: publish lag sample = completion - release
M-->>E: compositor lease attempt: latch sample
HaloFramePacerDisplay (frame_pacer.h:31-36) is filled by display_for_pacer() (EngineVisionRuntime.m:91-115):
| Field | Source |
|---|---|
latch_ns |
The last compositor lease attempt. display_latch_note() runs inside enginevision_panorama_gpu_latest under the runtime lock, "at the instant the compositor can select its picture". It is recorded even when a menu prevents a world lease. |
period_ns |
Median of the last 8 non-zero latch intervals under 100 ms (halo_frame_pacer_median ignores zeros). |
lag_ns |
Median of the last 16 publish-lag samples: GPU completion time minus the pacer's release time. Only accepted publications contribute; failed or superseded ones do not. |
These are "app submission/publication observations, not display scanout timestamps". They are written by the compositor and Metal completion threads through atomic slots and may be one update old.
In host_frame_pacer_present (frame_pacing_hooks.inc:69-150):
| Value | Computation |
|---|---|
work |
Time since the previous release minus host_yield_spin_ns accumulated since then. This is the engine's busy time between publications, excluding the guest limiter's spin/sleep and the pacer's own previous wait. |
floor_work |
halo_panorama_budget_floor_busy(budget, work): the same frame at the lightest tier's floor (1.625 passes per frame). |
ticks |
u16 at [0x006F1D6C] + 0x10, the tick count 00470BF0 left in the time globals. -1 if the globals pointer is null. |
display_period |
Always the nominal 1/90. The observed period is only reported. "A median observed interval of 22 ms can represent missed compositor callbacks, so it must not halve all pacing rungs." |
lag |
Observed median if non-zero and under 100 ms, else 4 ms. Smoothed with an EMA (0.1). |
lead |
lag + 0.5 * (1/90): aim for GPU completion half a display period before the compositor samples. |
locked |
A latch exists, is not in the future, and is under 250 ms old. |
min_period |
1/30 when Halo's framerate_throttle byte 0x006894BA is set or the cinematic flag ([0x006F187C] + 9) is set; otherwise 1/45. "Keep at least" is the budget's goal, "not a speed cap. A goal of 27 Hz must not forbid the 30 Hz cadence" (frame_pacing_hooks.inc:18-27). |
max_period |
1/15, Halo's maximum step. Frames slower than this slow the game, since those steps are clamped. |
mode |
HALO_PACER_OFF while timedemo is active (0x007196D8 non-zero): "Leave benchmark timing alone even when the user has enabled pacing". Otherwise the live setting. |
HaloFramePacer (frame_pacer.h:91-111) holds:
-
rungand the lastslot; - a 48-frame ring of
(work, floor_work, ticks); -
tick_cost; -
work_emaandfloor_ema(for the report); -
late_ema; - the
wantrung and how long it has been wanted; - time on the current rung;
- cumulative counters.
halo_frame_pacer_reset forgets timing but keeps the counters. It is used for a load, a menu, or pacing switched off.
| Constant | Value | Meaning |
|---|---|---|
HALO_PACER_RUNGS |
7 | Report slots: 0 = free-running, 2..6 = allowed rungs (1 is never used). |
HALO_PACER_WINDOW |
48 | Frames a rung is judged by ("two to three seconds of play"). |
HALO_PACER_ALLOWANCE |
0.15 | Maximum modelled period overhead versus running free (about 13% fps loss). "A selection estimate, not a bound on scheduler/GPU stalls." |
HALO_PACER_SLOW_SHARE |
0.1 | Maximum share of frames that would exceed Halo's 1/15 s step. |
HALO_PACER_ENGAGE_SECONDS |
0.5 | How long a rung must be wanted before pacing engages. |
HALO_PACER_LEAVE_SECONDS |
0.75 | How long before leaving a rung that costs too much. |
HALO_PACER_SMOOTHER_SECONDS |
2.0 | How long before moving to a slower, smoother rung. |
HALO_PACER_MARGIN |
0.0005 s | How early a frame must reach its slot to make it. |
| guard | 0.0002 s | Wake-up allowance in slot computation. |
halo_frame_pacer_allowed(n) requires 2 <= n < 7, n * D >= 0.99 * min_period and n * D <= max_period. With Halo's limiter or a cinematic active, rung 2 (45 Hz) is excluded.
For a candidate rung n with period P = n * D (frame_pacer.h:156-195):
-
Tick counts. A rung determines how many 30 Hz ticks each frame runs:
ticks = P * 30. 22.5 fps alternates 1, 1, 2. Each windowed frame is evaluated at the low tick count with weight1 - fracand at the high count with weightfrac. -
Adjusting each frame. Each frame's floor work is moved to that tick count at the measured
tick_costper tick:w = floor + (target_ticks - observed_ticks) * cost + MARGIN. Whentick_costis unknown it is taken as 15% of the mean floor work, and frames with unknown ticks are not adjusted. -
Paced time.
max(n, ceil(w / D)) * D. A frame that overruns takes as many display periods as it needs. -
Free time.
max(w, min_period). Running free is never faster than Halo's cap. -
Outputs.
-
loss = paced_total / free_total - 1; -
slow: the share of frames withw > max_period; -
late: the share withw > P.
-
tick_cost is estimated in halo_frame_pacer_note from the two most common tick counts in the window (each needing at least 6 samples), as the difference of their mean floor work divided by the tick difference. It is clamped at 0 and smoothed with weight 0.1. "A rung that runs one count only (30 or 15 fps) keeps the last estimate."
The search goes from rung 6 down to 2 and returns the first allowed rung with loss <= 0.15 and slow <= 0.1, or 0 (free-running) if none qualifies (frame_pacer.h:197-208). This picks the slowest cadence whose throughput cost is within the allowance. That is the smoothest affordable one, since every picture is held equally long.
The test fixture gives concrete results (constant work, Halo cap on, phase locked):
| Steady work | Chosen rung | Cadence |
|---|---|---|
| 20 ms | 3 | 30 fps |
| 39 ms | 4 | 22.5 fps |
| 49 ms | 5 | 18 fps |
| 60 ms | 6 | 15 fps |
| 75 ms | 0 | free (over 1/15 s) |
Source: frame_pacer.h:247-333.
stateDiagram-v2
[*] --> Free
Free --> Paced: a rung wanted for 0.5 s
Paced --> Paced: slower rung wanted 2 s, on rung 2 s, its loss at most 0.12
Paced --> Paced: current loss above 0.20 or slow share above 0.2, other rung wanted 0.75 s
Paced --> Paced: current rung no longer allowed, switch at once
Paced --> Free: free running wanted, same leave rules
Paced --> Free: reset on gap over 1 s, work of 0.25 s or more, invalid work, or mode OFF
Free --> Free: no measured work yet
-
Reset. A gap over 1 s since the last call,
work >= 0.25 s, negative or non-finite work, ormode == OFFresets the controller. If a rung was active,rung_changes++andshortenedis set. "A load, severe hitch, disabled mode or invalid sample must not keep pacing from an old low-cost window." -
Measure. Frames with
0 < work < 0.25enter the window and EMAs. -
Free. With no measured work, or mode OFF, the frame is released immediately (
rung_frames[0]++). -
Choose. Compute
want. Track how long it has been wanted.- Engage from free after 0.5 s.
- Switch at once if the current rung became disallowed.
- Leave quickly (after 0.75 s) if the current rung's loss exceeds 0.20 or its slow share exceeds 0.2.
- Go smoother only after 2 s of both wanting and being on the current rung, and only if the new rung's loss is at most 0.12.
"The margins either side keep a scene on the edge where it is."
-
Shortened. Set when the new rung is faster (smaller
n) or free. The host then retargets the budget immediately. -
Slot.
-
target = previous slot + n * D. - When phase-locked,
phase = latch - leadand the target is snapped to the nearest grid pointphase + k * D. - If
target < now + guard, the frame is late (if a previous slot existed). It goes at the next grid point,phase + ceil((now + guard - phase) / D) * D, or immediately when not locked. "Missed deadlines slip a display period rather than an entire engine frame." The cadence continues from there. - If the target is more than
P + Din the future, it is replaced bynow: "a clock that moved, not a frame that is early". -
late_emais updated with weight 0.05.
-
halo_frame_pacer_budget_target(p, D, fallback) (frame_pacer.h:210-239) returns the busy-time ceiling the bearing budget should keep:
-
Not pacing: the fallback,
1 / panorama_target_fps. -
Pacing:
P - 2 * MARGIN - excess, whereexcessis the 88th percentile of windowed work minus its mean (computed once at least 8 samples exist). This accounts for "the tick that every third frame runs at 22.5 fps, the extra pass of the heavy tiers' alternate frames". - If
late_ema > 0.1, it subtracts a further0.5 * P * (late_ema - 0.1). - The result is clamped to
[0.6 P, 0.9 P].
host_frame_pacer_budget_target(fallback) returns the fallback when the pacer has never run, the live setting is off, or the budget's scene epoch has moved on since the pacer last saw it (frame_pacing_hooks.inc:152-156).
The budget calls it twice per panorama frame: for observe_timed and for motion_fill. In return, the pacer chooses its rung from the budget's floor_busy estimate. That way a rung is not abandoned only because the budget filled spare time with extra bearings, and the budget is retargeted to a heavier tier the moment the rung gets faster (halo_panorama_budget_retarget). The test budget_checks shows that the same 30 ms busy time earns more extra bearings under a paced 22.5 fps target than under the plain 1/30 target.
-
Scene entry. When
panorama_budget.scene_epochdiffers from the pacer's copy, the pacer resets (counting a rung change if one was active), and clears its release time and idle baseline. This happens at the front end closing or a >1 s load gap. Old menu/load timings "must not hold this publication to an old slot" (frame_pacing_hooks.inc:72-81). -
Turning off. If the setting goes off while pacing, the controller resets and the budget is retargeted to
1/target_fps. Counters continue (free_frames,rung_frames[0]). -
Waiting. The deadline is clamped to 100 ms ahead and converted to
mach_absolute_timeunits with 128-bit arithmetic.mach_wait_untilis retried up to 4 times, re-checking the clock between attempts. "There is no spin" (frame_pacing_hooks.inc:29-40). -
Idle accounting. Only the blocking interval (measured after the controller's own work) is passed to
host_frame_idle_wait(start, end). On the presenting thread this adds it tohost_yield_spin_nsandhost_present_sleep_ns, and clears theSleep(0)timestamp so the next yield does not time the same gap again. With pacing off nothing calls it, andSleep/SleepExaccounting is unchanged (shims_kernel32.c:269-284). -
Logging. Rung changes are logged as
[pacer] rung A -> B (fps) work= floor= period= lead= locked=, up to 64 times. -
Threading. All controller state belongs to the engine thread. The report is copied into
frame_pacer_snapshotunderframe_pacer_report_lock.host_frame_pacer_reportcopies it out under the same mutex and overwritesmodewith the live setting, so "a live toggle is visible before the next engine frame publishes". The bridge holds no lock while the pacer waits;PanoramaPublicationValidation.masserts thatruntime_lockis free during the call. -
No guest writes.
test_frame_pacing_host.ccompares all 8 MiB of guest memory before and after settling and requires them to be identical.
Reported in diagnostics as framePacing (EngineDiagnostics.swift:111-130).
| Field | Diagnostics key | Meaning |
|---|---|---|
mode |
mode |
0 off, 1 even (live setting). |
rung |
rung, cadenceHz
|
Display frames per engine frame; 0 free. cadenceHz = 1000 / (rung * display_period_ms). |
latch_locked |
latchLocked |
Pacing and phase-locked to recent compositor latches. |
display_period_ms |
displayPeriodMilliseconds |
Nominal 11.111 ms. |
observed_display_period_ms |
observedDisplayPeriodMilliseconds |
Median latch interval (informational). |
target_period_ms |
targetPeriodMilliseconds |
rung * display_period. |
lead_ms, publish_lag_ms
|
leadMilliseconds, publishLagMilliseconds
|
Phase lead and smoothed commit-to-completion lag. |
work_ms, floor_work_ms
|
workMilliseconds, floorWorkMilliseconds
|
Busy-time EMAs. |
budget_target_ms |
budgetTargetMilliseconds |
Ceiling handed to the bearing budget. |
late_share |
lateShare |
EMA of missed slots. |
frames, paced_frames, late_frames, free_frames, rung_changes, rung_frames[7]
|
same / framesByRung
|
Cumulative counts. frames = paced + free = sum(rung_frames). |
wait_seconds |
waitSeconds |
Actual measured blocking time. |
requested_wait_seconds |
requestedWaitSeconds |
Sum of slot - now the controller asked for. |
EngineWorldCadence (EngineWorldCadence.swift) is the presenter-side check on evenness. The diagnostics comment says "Judge evenness against worldSubmissionCadence".
For every compositor submission, the presenter records (scene_epoch, source_epoch, uptime). A submission is eligible only when tracking is present and the mode is panorama or retainedPanorama (EngineImmersive.swift:139-146). New epochs within one scene produce an interval sample (ring of 4096) and the number of submissions that showed the previous epoch. A scene change or ineligible submission restarts tracking.
Statistics keys:
-
uniqueSubmittedWorldFrames,repeatedSubmittedWorldFrames,intervalSamples; -
uniqueIntervalP50/P95/P99Milliseconds; -
uniqueIntervalModal90HzPeriods(mode ofround(ms * 0.09)) and its share; -
uniqueIntervalModalSubmissionFramesand its share; -
uniqueIntervalStdDevMilliseconds,stallsOver100ms,maximumRetainedMilliseconds.
These measure "submission, not proof that the display scanned out a particular frame".
The per-pass timer in the panorama hook feeds host_pass_profile_add, which is always enabled (d3d9_render.inc:406-412). It accumulates two counters:
- an interval counter, reset by the
HALO_DRAW_PROFILElog every 120 frames; - a lifetime total,
host_pass_profile_total_ns().
engine_thread_sample() reads the lifetime total at every Present for the core-telemetry frame split (EngineVisionRuntime.m:67-88). The split separates time inside bearing passes from time outside them. See Diagnostics and Telemetry.
| Variable | Default | Effect | Read at |
|---|---|---|---|
HALO_FRAME_PACING |
off |
1, even or on enables. Anything else (0, off, 2, empty, malformed) is off. Seeds the live "Even frame cadence" toggle. |
halo_settings.c:79-81 |
HALO_PANORAMA_TARGET_FPS |
30 (10..60) | The budget's fallback target when not pacing. Does not cap the pacer's cadence. | halo_settings.c:63 |
HALO_PANORAMA_GPU |
on (visionOS) / off (Mac) | Pacing runs only on the zero-copy publication path. | EngineVisionRuntime.m:704-710 |
HALO_CORE_TELEMETRY |
on |
0 disables the frame-split sampling at each Present (pass-time totals still accumulate). |
EngineVisionRuntime.m:657-659 |
HALO_DRAW_PROFILE |
unset | Any value: per-draw timers and the 120-frame [draw-profile] log, which resets the interval pass counter. |
d3d9_render.inc:413-414 |
frame_pacer_traces.inc holds two anonymised headset traces of aggregate observations in active panorama play. They are not per-frame timings. Each row is {end_s, seconds, frames, busy_ms (EMA snapshot), ppf, pass_ms, segment}. A new segment starts after any excluded interval (menu, transition, no frames, invalid counters, or an interval outside (0, 2] s).
| Array | Source summary in file |
|---|---|
build75_b30 (trace A) |
248 source records; 214 retained intervals in 4 segments; 6739 frames / 238.6 s. SHA-256 of the source given. |
build74_b30 (trace B) |
223 source records; 205 retained intervals in 1 segment; 3066 frames / 229.4 s. |
test_frame_pacer.c has three parts.
controller_checks covers:
- the work-to-rung table above, with exact spacing and phase alignment;
- a single late frame slips by at most one display period and the cadence resumes;
- relative cadence without phase;
- rung 2 is chosen when uncapped;
- a load gap, OFF and a 300 ms hitch release immediately and clear the window;
- a slow scene returns to 30 Hz after sustained headroom;
- at most 4 rung changes over 1800 frames of borderline load;
- the median ignores zeros.
budget_checks covers the budget-target bounds, floor_busy, and that paced targets earn more extras.
simulation_checks replays both traces through a synthetic per-frame reconstruction:
- The native limiter is modelled on simulation samples.
- Log-normal jitter with sigma 0, 0.1 or 0.2, over 3 seeds.
- GPU latency is 4 ms +/- 20%.
- Each run is compared against itself with pacing off.
Assertions per model:
- the same frame count;
- paced fps and displayed fps at least 90% of free;
- for trace A, adjacent hold changes at most 75% of free;
- for trace B, fps at least 97% of free;
- game speed within 0.03;
- hold SD at most 110% + 0.5 ms;
- at most 12 rung changes per minute.
The file states plainly that these are synthetic reconstructions, not headset evidence.
test_frame_pacing_host.c builds the real frame_pacing_hooks.inc with fake clocks and waits, plus the real halo_settings.c and budget. It checks:
-
HALO_FRAME_PACINGparsing in fresh child processes; - off by default, with no waits;
- the nominal 90 Hz grid even when callbacks arrive every 22.2 ms;
- the guest throttle and cinematic caps;
- 20, 27 and 30 Hz "Keep at least" goals do not forbid 30 Hz;
- timedemo bypass;
- scene-entry reset without a wait;
- the live toggle;
- stale and future latches unlock;
- only the actual blocking interval is charged as idle (7 ms limiter idle, 2 ms controller cost, 1 ms oversleep injected);
- interrupted waits retry with one accounting interval;
- guest memory unchanged;
- concurrent report snapshots are coherent.
| Test | What it asserts |
|---|---|
PanoramaPublicationValidation.m |
The bridge calls the pacer with the latest latch and without holding runtime_lock, then commits with the paced release time. Incomplete frames neither pace nor commit. Publish-lag samples come only from accepted completions. Latch intervals ignore repeated timestamps and gaps over 100 ms. |
WorldCadenceValidation.swift |
Even 30 fps gives 3 periods, share 1, SD 0. Holds of 5,6,7,6 give a mode of 6 at share 0.5. Half-rate submission is distinguished from display holds. Stalls and maximum retention are counted. The ring caps at 4096. |
test_pass_time_telemetry.c |
The interval pass counter resets with the draw-profile log, but the lifetime total survives (19 ms), so the frame split's outside-pass time (31 ms) is not inflated. Mac only. |
All of these except the Swift/ObjC ones run in tools/run_source_checks.py. frame_pacing_host and pass_time_telemetry run on macOS only. The ObjC publication test and WorldCadenceValidation are also built there on macOS.
Documents master-chef at commit 9f915af (v1.0.3). Unofficial project, not affiliated with Microsoft, Bungie, Gearbox or Apple. Original code is MIT licensed; game content is not included.
Overview
- Architecture Overview
- Repository Layout
- Glossary
- Environment Variables
- Contributing Guide
- Open Questions
Translation
- Static Translation Pipeline
- XWA Decoder and Lifter
- Function Address Lists
- EngineReuse Runtime
- x87 Floating Point
Host runtime
- EngineHost Overview
- Win32 Compatibility Layer
- Threading and Synchronization
- Guest Memory and Heap
- Engine Overrides and Hooks
- Runtime Settings
Graphics
- Direct3D9 Bridge
- Metal Renderer
- Shader Translation
- Textures and Texture Packs
- Geometry Fast Paths
- Radial Fog
Panorama and presentation
- Panorama System
- Panorama Budget and LOD
- Frame Pacing
- visionOS App
- Immersive Presenter
- Layer Alignment
Audio and input
Tooling and process