Skip to content

Haptics

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

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.

Source files

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.

Event flow

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"]
Loading

Why the audio, and why two detectors

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.

Detector 1: per-voice onsets

Envelopes (in the mixer)

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):

  1. 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.
  2. For the player's own voice (self_voice latched, 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.
  3. onset_fast follows level with a 3 ms one-pole; onset_slow follows onset_fast with a 15 ms one-pole (a reference lagging the attack by about a dozen milliseconds). onset_level_max records the loudest fast envelope for the voice trace.
  4. 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
Loading
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).

Decision (halo_haptics_voice_onset)

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).

The player-fire window

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.

Detector 2: low-frequency transients in the mix

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.

Shared spacing

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.

Mailbox

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.

Consumer: EngineHaptics.pump()

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").

Playback on the controller

hostgc_play_haptic (gamecontroller.m:298-318) is safe from any thread:

  1. Ignores intensity ≤ 0; clamps intensity and sharpness to 0..1; ensures hostgc_init.
  2. If the guard is enabled, asks hostgc_guard_admit under g_guard_lock. Anything but PLAY returns (suspension also sets the state to "suspended after controller drops").
  3. 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):

  1. Picks the current extended-gamepad controller (the input pad).
  2. 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:GCHapticsLocalityDefault on first need, with autoShutdownEnabled = 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 start so a reset during start is preserved. A failed start discards the engine.
  3. 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.
  4. On success records last_played_ns, increments hostgc_haptics_played(), and sets the state to ready (<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.

Controller link guard

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).

Strength setting

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.

Environment variables

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

Diagnostics

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".

Threading

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.

Tests

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.

Related pages

Clone this wiki locally