-
Notifications
You must be signed in to change notification settings - Fork 5
Haptics
Halo PC 1.10 contains no force-feedback code: no DirectInput effect API is imported or referenced, so there is no rumble intent to forward. The port therefore synthesises controller haptics from the audio the engine is already producing. Two detectors run on the audio thread inside the mixer (Audio System): a per-voice onset detector that watches each positional (3D) voice for a sharp rise, and a low-frequency transient detector on the final mix. Each publishes at most one pending pulse into a lock-free mailbox. Once per rendered immersive frame the visionOS presenter takes the pulse and hands it to the host's GameController layer, which plays it with Core Haptics on the same controller the game is reading for input, subject to a pacing guard that protects the controller's Bluetooth link.
| File | Role |
|---|---|
native/EngineHost/haptics.h |
API and design rationale: observe, voice onset, fire note, take, enable. |
native/EngineHost/haptics.c |
Both detectors' decision logic, the player-fire window and the one-slot mailbox. |
native/EngineHost/directsound_mixer.c |
Per-voice onset envelopes inside ds_mixer_render; calls halo_haptics_voice_onset and halo_haptics_observe. |
native/EngineHost/d3d9.c |
Calls halo_haptics_note_fire() from the pad-to-key bridge while the right trigger is held. |
native/EngineHost/gamecontroller.m |
hostgc_play_haptic: guard, serial haptics queue, CHHapticEngine lifecycle, state string, link statistics. |
native/EngineHost/gamecontroller_guard.h |
Pure pacing, connect-grace and drop-backoff logic. |
native/EngineVision/Sources/EngineHaptics.swift |
pump() drains the mailbox each rendered frame; diagnostics and the test pulse. |
native/EngineVision/Sources/EngineImmersive.swift |
Calls haptics.pump() in the immersive render loop. |
native/EngineHost/halo_settings.c |
haptics_strength (and the HALO_HAPTICS seed), self_gain_db. |
native/EngineVision/Sources/EngineSettingsView.swift |
"Haptics" section: Strength slider, Test pulse, detected/played counts, state text. |
flowchart LR
subgraph AT["AudioQueue callback thread"]
RV["ds_mixer_render: positional voices"]
ENV["Per-voice fast/slow envelopes"]
VO["halo_haptics_voice_onset"]
MIX["Mixed stereo output"]
OBS["halo_haptics_observe (160 Hz low-pass)"]
RV --> ENV -->|"rise detected, peak taken"| VO
RV --> MIX --> OBS
end
subgraph ET["Engine thread"]
TRIG["Present: R2 held"] --> NF["halo_haptics_note_fire"]
end
NF -.->|"fire window"| VO
VO -->|"publish_event"| MB["pending_event (one atomic word)"]
OBS -->|"publish_event"| MB
subgraph RT["Immersive render thread"]
PUMP["EngineHaptics.pump() each frame"]
end
MB -->|"halo_haptics_take"| PUMP
PUMP --> PLAY["hostgc_play_haptic"]
PLAY --> GUARD{"Link guard admits?"}
GUARD -- no --> DROP["Dropped (counted)"]
GUARD -- yes --> Q["Serial queue halo.controller.haptics"]
Q --> CH["CHHapticEngine on the pad: transient event"]
From haptics.h:4-10: a sharp rise in low-frequency energy is what gunfire, explosions, impacts and heavy footfalls have in common, and that was the first detector. The header and test_haptics_onset.c then record its limits on the headset: it fired on a cutscene explosion but "never once through four minutes of Silent Cartographer firefights", because a rifle's crack lives above the 160 Hz band and under the music's low end. Halo also never starts a sound with a fresh Play in the common case; it streams sounds through a fixed pool of looping ring buffers, so a new sound is a rise inside an already playing voice. The onset detector watches each 3D voice's own rendered level for that rise. Weapons, hits and explosions are 3D voices in Halo; dialogue and music are not.
For every playing voice with has_3d and 3D mode not DS3DMODE_DISABLE, each output frame in ds_mixer_render (directsound_mixer.c:233-241, 300-328):
-
level = max(|left*gain_l|, |right*gain_r|): the voice's rendered level after its gain, so it already includes Halo's own distance attenuation and the mixer's rolloff. - For the player's own voice (
self_voicelatched, see Audio System) the level is divided by the own-weapon lift, so it is judged at the level Halo gave it, not after the lift. -
onset_fastfollowslevelwith a 3 ms one-pole;onset_slowfollowsonset_fastwith a 15 ms one-pole (a reference lagging the attack by about a dozen milliseconds).onset_level_maxrecords the loudest fast envelope for the voice trace. - State machine:
stateDiagram-v2
[*] --> Armed: Play (ds_mixer_voice_restart)
Armed --> Measuring: fast > 2 x slow, fast > 0.004, gap >= rate/22
Measuring --> Measuring: track peak for rate/200 frames
Measuring --> Disarmed: report onset (peak, floor), gap = 0
Disarmed --> Armed: fast < 1.2 x slow + 0.005
| Parameter | Value at 48 kHz | Source |
|---|---|---|
| Fast attack | 3 ms time constant | onset_fast_alpha |
| Reference | 15 ms time constant | onset_ref_alpha |
| Trigger |
fast > 2 x slow and fast > 0.004
|
|
| Re-arm | fast < 1.2 x slow + 0.005 |
|
| Peak window |
rate/200 = 240 frames (5 ms) |
onset_peak_frames |
| Per-voice minimum spacing |
rate/22 = 2181 frames (about 45 ms), "lets an assault rifle's shots each land" |
onset_gap_frames |
The detector arms only after the attack has settled back to the reference, so a sustained sound pulses once. ds_mixer_voice_restart (called by Play) resets the envelopes, arms the detector and sets the per-voice gap to "elapsed" so a sound's own first attack can pulse; with the gap cleared instead, the first 45 ms of every sound started with Play (all 133 in the Build73 session) could never pulse (directsound_mixer.c:167-176).
haptics.c:102-137, called with level = the measured peak, floor = the reference at the trigger, at_listener = the voice is the player's own.
| Case | Threshold | Full scale |
|---|---|---|
| World sound | 0.18 | 0.30 |
| Voice on the listener (player's own weapon) | 0.006 | 0.02 |
| Trigger held (fire window open), any positional voice | 0.008 | 0.05 |
if level < threshold: ignore
intensity = (0.35 + 0.65 * (level - threshold) / (full - threshold)) * strength, capped at 1
if intensity < 0.12: ignore
rise = level / floor (12 if floor <= 0.001)
sharpness = 0.3 + 0.6 * clamp((rise - 2) / 8, 0, 1)
The comments explain the numbers: a full-scale crack reads about 0.3 on the 3 ms envelope, so a world sound must be loud to count and "a rifle across the beach, a tenth of that, does not buzz". Halo plays the player's own weapon on the listener twenty decibels down; the assault rifle's material reads about 0.015 there on the desktop trace, hence the separate scale. A shot out of silence stands many times above its floor, so the rise ratio sets sharpness (crack versus thud).
Every accepted onset increments onset_count (halo_haptics_onset_count, reported as haptic_onsets). HALO_HAPTICS_ONSET=0 disables this detector (cached on first use).
On the headset the weapon voice is "never on the listener the way it is on the Mac" (haptics.h:25-29), so the trigger is used as a witness. The pad-to-key bridge in Present calls halo_haptics_note_fire() every engine frame while rt > 0.5 or the R2 button is down, a pad is connected, no menu is active and HALO_PAD2KEY is not 0 (d3d9.c:556-559).
halo_haptics_note_fire (haptics.c:72-87) stores the time and a window length: 150 ms, or 1.5 times the spacing between the last two samples if that is larger and the spacing was under 400 ms, capped at 500 ms. The comment: below about seven engine frames a second the fixed 150 ms window lapsed between samples while the trigger was still held (11 pulses lost in the Build73 session's 4-20 fps stretch). While the window is open, the trigger-held thresholds apply to any positional voice.
halo_haptics_observe (haptics.c:37-68) runs after every ds_mixer_render on the final stereo output (after the mixer lock is released):
| Stage | Parameter |
|---|---|
| Mono | (L + R) / 2 |
| Low-pass | One-pole at 160 Hz ("the band that carries impact weight") |
| Envelope | Attack 5 ms, release 250 ms |
| Baseline | 1.5 s average of the envelope |
| Event condition | excess = envelope - (1.9 x baseline + 0.02) > 0 |
| Intensity |
excess x 3.2 x strength, capped at 1; ignored below 0.12 |
| Sharpness |
0.35 + 0.45 x excess / envelope, capped at 1 |
The check runs once per callback buffer, after the whole buffer has been filtered, so its timing resolution is one buffer.
Both detectors share frames_since_event, which only advances in halo_haptics_observe (by every output frame) and is reset to 0 by any published event. Neither detector publishes while it is below HAPTICS_MIN_GAP_FRAMES = 2000 (about 42 ms at the 48 kHz output; the source comment says "~45 ms at 44.1 kHz"). It starts at the gap so the first impact after launch is not swallowed. Both detectors return early when halo_haptics_enabled() is 0 or the strength is ≤ 0.
pending_event is one _Atomic uint32_t (haptics.c:15-24): low 16 bits intensity x 1000 + 1 (0 means empty), high 16 bits sharpness x 1000. One atomic store publishes, one atomic exchange takes (halo_haptics_take, haptics.c:139-145), so the consumer can never pair one event's intensity with another's sharpness. Semantics are deliberately latest wins: an event not taken before the next one is overwritten. Values are quantised to 1/1000.
EngineImmersiveRenderer owns an EngineHaptics and calls haptics.pump() once per rendered frame (EngineImmersive.swift:693). pump() (EngineHaptics.swift:40-55) takes the pending event, counts it (eventsTaken) and calls hostgc_play_haptic(intensity, sharpness) clamped to 0..1. This is the only production consumer in the tree: events are drained only while the immersive renderer runs.
The Swift type deliberately does not look the controller up itself. The comment records why: a separate Swift-side lookup "reported fourteen impacts detected, none played, and no controller seen, while the game was taking input from one". Playing through the host means the same object that answers "is there a pad for input" also answers "is there a pad to buzz" (gamecontroller.h:21-31).
EngineHaptics.testPulse() calls hostgc_play_haptic(1, 0.5) directly, bypassing detection, so a user can tell "a detector that never fires from a controller that cannot buzz" (Settings, "Test pulse").
hostgc_play_haptic (gamecontroller.m:298-318) is safe from any thread:
- Ignores intensity ≤ 0; clamps intensity and sharpness to 0..1; ensures
hostgc_init. - If the guard is enabled, asks
hostgc_guard_admitunderg_guard_lock. Anything butPLAYreturns (suspension also sets the state to "suspended after controller drops"). - Dispatches the pulse asynchronously to the serial queue
halo.controller.haptics(QoS user-initiated). The presenter used to call Core Haptics on its 90 Hz thread, and a Start could stall its frame.
On that queue, under g_haptic_play_lock, haptic_play_locked (gamecontroller.m:264-296):
- Picks the current extended-gamepad controller (the input pad).
-
haptic_engine_for(pad)(gamecontroller.m:212-262):- A different pad than the cached one discards the cached engine.
- Creates an engine with
pad.haptics createEngineWithLocality:GCHapticsLocalityDefaulton first need, withautoShutdownEnabled = YES(an idle engine stops instead of holding a haptics stream open). Its reset and stopped handlers only mark "needs start" for that engine's generation, so a late callback from an old engine cannot affect its replacement. - Starts the engine only when a pulse is requested and a start is needed; the flag is cleared before
startso a reset during start is preserved. A failed start discards the engine.
- Builds a one-event
CHHapticPattern(CHHapticEventTypeHapticTransient, intensity and sharpness parameters), creates a player and starts it immediately. A failed player or start discards the engine so the next pulse gets a fresh one. - On success records
last_played_ns, incrementshostgc_haptics_played(), and sets the state toready (<vendor>).
On a controller disconnect notification the engine is released at once on the same queue (haptic_release_on_disconnect), since a kept engine "was still there when the pad came back".
hostgc_haptic_state() returns a per-thread copy of a short status string, for the settings panel and the report:
| State | Meaning |
|---|---|
not started |
No pulse requested yet. |
no controller |
No extended gamepad. |
<vendor> has no haptics, no haptic engine for this pad
|
pad.haptics nil, or engine creation failed. |
engine reset; awaiting pulse, engine stopped (<reason>); awaiting pulse
|
Will restart on the next pulse. |
engine start failed: ..., pattern build failed, play failed; retrying next pulse
|
Failures (also counted in haptic_failures). |
ready (<vendor>) |
Last pulse played. |
suspended after controller drops |
Guard suspension in effect. |
released: controller disconnected |
Engine released after a disconnect. |
EngineHaptics.state reports disabled only when halo_haptics_enabled() is 0; otherwise it shows the host state.
gamecontroller_guard.h exists because haptics were destabilising the DualSense's Bluetooth link. Its header (gamecontroller_guard.h:1-23) records: Build74 lost the controller six times in four minutes, Build75 fifteen times (every five to seven seconds at the end), Build73 with the same controller code none; Build73 asked for about 0.6 pulses a second, Build74 for five to eight, and each Build74 drop came after 20 to 44 pulses in the preceding five seconds.
Rules (hostgc_guard_admit, checked in this order):
| Verdict | Condition |
|---|---|
DISCONNECTED |
No pad connected. |
SUSPENDED |
Inside a suspension. |
GRACE |
Less than grace since the last connect. |
RATE |
Less than min_interval since the last admitted pulse. |
PLAY |
Otherwise; records the time. |
| Parameter | Default | Source |
|---|---|---|
| Minimum interval | 250 ms (four pulses a second) |
HALO_HAPTIC_MIN_INTERVAL_MS, clamped 0..5000 |
| Connect grace | 3000 ms after every connect |
HALO_HAPTIC_CONNECT_GRACE_MS, clamped 0..60000 |
| Link window | 10 s: a drop within 10 s of a played pulse counts against haptics | fixed |
| Strike window | 2 minutes: two linked drops within it trigger a suspension | fixed |
| Suspension | 60 s, doubling each time (60, 120, 240 s ...) | fixed |
hostgc_guard_disconnected uses the time the hardware last actually played a pulse, not the last admitted one. On disconnect, if another extended pad is still present, it starts its own grace period. The guard is initialised in hostgc_init (gamecontroller.m:110-113); HALO_HAPTICS_GUARD=0 disables pacing and backoff (pulses still leave the render thread through the queue). Connect handling also stops wireless discovery so a scan does not take radio time from the connected pad (see Input and Controllers).
| Setting | Default | Range | Effect |
|---|---|---|---|
HaloSettings.haptics_strength |
1.0; HALO_HAPTICS=0 seeds 0 (halo_settings.c:58-59) |
accepted 0..4 by halo_settings_set/accessor; the UI slider offers 0..2 in steps of 0.05 |
Multiplies both detectors' intensity; 0 disables both. Because the 0.12 minimum is applied after scaling, strength also changes how many weak events pass. |
Changes take effect on the next mixer callback. halo_haptics_set_enabled() exists in the API but nothing in the production sources calls it; HALO_HAPTICS=0 works through the strength instead.
| Variable | Default | Effect | Read at |
|---|---|---|---|
HALO_HAPTICS |
strength 1 | A leading 0 seeds strength 0 (no synthesis). |
halo_settings.c:58 |
HALO_HAPTICS_ONSET |
on | A leading 0 disables the per-voice onset detector. |
haptics.c:93-97 |
HALO_HAPTICS_GUARD |
on | A leading 0 disables the link guard. |
gamecontroller.m:110-111 |
HALO_HAPTIC_MIN_INTERVAL_MS |
250 | Minimum spacing of admitted pulses (0..5000). | gamecontroller.m:112 |
HALO_HAPTIC_CONNECT_GRACE_MS |
3000 | No pulses for this long after each connect (0..60000). | gamecontroller.m:113 |
HALO_SELF_GAIN_DB |
18 | Own-weapon lift; the onset detector divides it back out. | halo_settings.c:64 |
HALO_PAD2KEY |
on |
0 also disables halo_haptics_note_fire (it lives in the bridge). |
d3d9.c:551 |
HALO_AUDIO_CAPTURE_WAV, HALO_AUDIO_CAPTURE_SECONDS
|
unset, 30 | Record a mix to replay through the detector offline. | directsound.c:309-315 |
| Field | Source |
|---|---|
hapticsTaken / hapticsEventsTaken
|
EngineHaptics.eventsTaken (events drained from the mailbox). |
hapticsPlayed / hapticsEventsPlayed
|
hostgc_haptics_played() (pulses Core Haptics accepted). |
hapticsState |
EngineHaptics.state. |
haptic_onsets |
halo_haptics_onset_count() in the draw profile (EngineVisionRuntime.m:258). |
controllerLink |
hostgc_link_stats: connects, disconnects, disconnects after pulses, suspensions and remaining suspension, pulses played and held back by reason (rate, grace, suspended, disconnected), haptic failures/resets/stops, last connect/disconnect/played times, guard enabled, battery (EngineDiagnostics.swift:88-106). |
onset-max, onsets in [audio-voice] lines |
Per-voice loudest fast envelope and reported rises (voice trace). |
The settings panel refreshes "N detected · M played" and the state text every 0.5 s (EngineSettingsView.swift:132-135). Comparing the three counters separates "nothing detected", "detected but not played (guard or no pad)" and "played".
| Thread | Work |
|---|---|
| AudioQueue callback | Envelopes, halo_haptics_voice_onset (under mixer.lock), halo_haptics_observe, publish_event. The detector state (frames_since_event, filter state) is touched only here. |
| Engine thread |
halo_haptics_note_fire (atomics). |
| Immersive render thread |
pump(), halo_haptics_take, guard admission. |
halo.controller.haptics queue |
All Core Haptics engine and player calls. |
| Main/notification threads | Connect/disconnect updates to the guard; engine release is queued to the haptics queue. |
| Test | Runner | What it asserts |
|---|---|---|
test_haptics_onset.c |
Mac source check | With 6 kHz bursts (where the low-pass detector is deaf): one shot in a looping 3D voice at the listener fires once, hard (≥ 0.9) and sharp (≥ 0.6); a shot right after Play pulses; a -25 dB shot twenty units away, a 2D voice and a slow swell in the world do not, and a steady full-scale tone pulses at most once; twelve shots 70 ms apart pulse ≥ 10 times, also over a firing loop's lull; the rifle at -2000 mB on the listener (and at half that material level) pulses ≥ 10 times at ≥ 0.5; a quiet rifle 2 units away pulses while the trigger is held and a marine's rifle 3 units away does not once the window has lapsed; two simultaneous shots are one pulse; the window spans a 5 fps trigger spacing; onset counter ≥ 44. |
test_haptics_detector.c |
Mac source check | Low-frequency detector in 512-frame 48 kHz buffers: six 55 Hz thumps over a 70 Hz bed each produce an event within 50 ms; steady music alone gives ≤ 1 event; silence gives none. |
test_haptics_mailbox.c |
Mac source check | Empty take returns 0; latest event wins; 1,000,000 concurrent publishes never yield a mixed intensity/sharpness pair. |
test_controller_link_guard.c |
portable source check | At Build74's 130 ms pulse rate the guard admits 36-40 in ten seconds (four a second); grace after connect; nothing while disconnected; suspensions of 60, 120 and 240 s after pairs of pulse-linked drops; drops with no pulse in the previous 10 s, or linked drops more than two minutes apart, never suspend. |
test_controller_haptics.m |
Mac source check | Includes the production gamecontroller.m with mocked GameController/Core Haptics: engine started once and reused; restart after stop/reset (including a reset during start); failed start/player/play drop the engine and recover on the next pulse; stale callbacks from a replaced engine are ignored; disconnect releases the engine; 200 concurrent callers; stable per-thread state snapshot; with the guard on, five rapid pulses play once and none after a disconnect. Hardware discovery is forbidden. |
haptics_from_capture.c |
Manual diagnostic | Replays a 16-bit stereo 48 kHz capture (HALO_AUDIO_CAPTURE_WAV) through halo_haptics_observe in 512-frame chunks and prints the event count per 10-second bucket. Only the low-frequency detector is exercised (the capture has no per-voice information). Not pass/fail. |
test_directsound_mixer.c additionally checks that ds_mixer_voice_restart clears self_voice, sets the onset gap to UINT32_MAX and arms the detector.
-
Audio System: the mixer,
self_voice, own-weapon gain, WAV capture. - Input and Controllers: controller selection, the pad-to-key bridge that notes the trigger, discovery.
- Immersive Presenter: the render loop that pumps the mailbox.
-
Runtime Settings:
haptics_strength. - visionOS App: the settings window.
- Diagnostics and Telemetry
- Environment Variables
- Testing and Source Checks
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