‹ projects

reverb-m1

Native wired VR on Apple silicon: Reverb G1 display bring-up and stereo demo
git clone https://umhau.org/code/reverb-m1.git
Log | Files | Refs

SETUP.md (13879B)


      1 # HP Reverb G1 on an M1 Mac: how we got the display working
      2 
      3 First lit successfully 2026-10-20: macOS desktop visible inside the headset, panel stays lit, no replugging.
      4 
      5 Machine: Apple M1 MacBook Air, macOS 15.6.1 (24G90). Headset: HP Reverb G1 (VR1000-2xxx).
      6 Display adapter: Cable Matters USB-C-to-DisplayPort adapter, model **201086**.
      7 
      8 ## The problem
      9 
     10 The Mac detected the headset as a 4320x2160 display, but the panel never stayed lit. On each connection the
     11 headset turned the panel on, turned it off ~300 ms later, dropped the DisplayPort link and retried every ~8 s.
     12 
     13 **Cause: HDCP.** A couple of seconds after hotplug, macOS's display coprocessor (DCP) starts HDCP 2.x
     14 encryption on every external DisplayPort link. The headset can't handle it and blanks. Linux never does this,
     15 which is why the same headset and hub worked on a Linux box.
     16 
     17 Ruled out along the way: power/hub, refresh rate and resolution, color bit depth (macOS already sends 8-bit RGB),
     18 the proximity sensor, and our own HID commands (the headset blanks even when we send nothing).
     19 
     20 ## The fix (exact steps)
     21 
     22 ### 1. System security prerequisites
     23 - SIP disabled (`csrutil disable` from Recovery). Check: `csrutil status`.
     24 - Boot-arg `amfi_get_out_of_my_way=1` allows the ad-hoc entitled helper used for recovery. It does not disable HDCP or fix panel blanking.
     25 
     26 ### 2. Disable HDCP with a boot-arg
     27 ```sh
     28 sudo nvram boot-args="amfi_get_out_of_my_way=1 hdcp-fuse-mask=0"
     29 nvram boot-args          # must print both flags
     30 # reboot
     31 ```
     32 Note that `boot-args` is one string, so writing it again **replaces** the previous value. Always write the
     33 full set of flags. (Our first reboot failed for exactly this reason: only the AMFI flag was saved.)
     34 
     35 After the reboot, verify (no headset needed):
     36 ```sh
     37 sysctl -n kern.bootargs                        # -> amfi_get_out_of_my_way=1 hdcp-fuse-mask=0
     38 ioreg -l -w0 | grep '"HDCPTXCapabilities"'     # every line should show {"Protocols"=()}
     39 # (one-off setup check only; never run ioreg while the headset is live or in a loop)
     40 ```
     41 Before the fix these show `{"Protocols"=(1,2)}` (HDCP 1.x and 2.x). The `HDCPCapabilityMask` field does
     42 NOT change (still 2/3); that's expected and harmless.
     43 
     44 **Revert:** `sudo nvram boot-args="amfi_get_out_of_my_way=1"` and reboot.
     45 Side effect: HDCP-protected content (some streaming video to external displays) won't play on external screens.
     46 
     47 ### 3. Connect the headset
     48 1. Headset USB-A into the (powered) Anker hub; hub into the Mac.
     49 2. Cable Matters USB-C-to-DisplayPort adapter (model 201086) into the Mac's other USB-C port, then the headset's DP plug into the adapter.
     50 3. macOS asks **"Allow accessory to connect?"**. Click **Allow**. Until you do, all HID calls fail with
     51    `0xe00002cd` (kIOReturnNotOpen).
     52 
     53 ### 4. Build the two demonstrations
     54 ```sh
     55 zsh tools/build.sh
     56 ```
     57 Run this from the repository root. It builds the stereo application, recovery helper,
     58 USB wake tool, and passive display observer using Apple's Command Line Tools.
     59 
     60 ### 5. Native macOS desktop output
     61 
     62 Quit the stereo application first, then run:
     63 
     64 ```sh
     65 .build/debug/hmdwake          # leave running; Ctrl-C to quit
     66 ```
     67 
     68 macOS treats the headset as an external display. In System Settings > Displays,
     69 choose the headset and use it as an extended display or select mirroring. Any
     70 windows placed on that display are sent directly to the headset by macOS.
     71 
     72 This reproduces the original desktop-output breakthrough. The operating system
     73 sends one desktop image across the headset's display surface: it does not duplicate
     74 the desktop for each eye or apply the demo renderer's lens correction. This is
     75 display bring-up, rather than a comfortable stereo desktop workspace.
     76 
     77 For a passive connection/mode check, run this in another terminal:
     78 
     79 ```sh
     80 .build/debug/displaywatch     # Ctrl-C to quit
     81 ```
     82 
     83 It reports display connections and pixel modes without capturing desktop pixels.
     84 Its output includes display metadata and timestamps; review it before sharing logs.
     85 Seeing a connected display in software is separate from confirming illumination
     86 and the desktop image inside the headset. The standalone wake tool does not run
     87 the stereo application's automatic link recovery.
     88 
     89 The wake tool sends (ported from Monado's `wmr_hmd_activate_reverb`):
     90 - To the QHMD control device `03f0:0c6a` (feature reports):
     91   4x set `[0x50, 0x01]` + get `0x50`, then get `0x09`, `0x08`, `0x06`, then set `[0x04, 0x01]` (screen on).
     92 - To the HoloLens Sensors device `045e:0659` (output report): `[0x02, 0x07]` + zero pad to 64 bytes (IMU on).
     93 
     94 Within ~1.5 s the DP link trains (HBR3, 8.1 Gbps x 4 lanes), macOS adds the 4320x2160 display, and the headset
     95 reports `05 01 01 01 01 ...` (panel on). (Drops can still occur later; see NOTES.md "Link reliability".)
     96 Stop `hmdwake` with Ctrl-C before starting the stereo demo. The application sends
     97 the wake sequence itself; the two programs must not own the HID devices together.
     98 
     99 ### 6. Stereo 3D demo (owns the headset, recovers it automatically)
    100 ```sh
    101 .build/debug/VRWorkstation --run-seconds 600        # run for 10 minutes; omit the option for indefinite
    102 ```
    103 Look for a rotating colored cube, a floor grid, and reference objects at several
    104 depths. Each eye receives its own rendered view through the factory lens correction.
    105 The Headset optics window on the Mac provides pattern/tuning controls and Quit.
    106 This demo uses a fixed viewpoint; head tracking is not connected to this scene.
    107 
    108 - `tools/build.sh` builds the app with `swift build`, then compiles `tools/hpmctl.c` into the same directory as the app
    109   executable (`.build/debug`) and ad-hoc signs it with `tools/hpmctl.entitlements`
    110   (`com.apple.USBCEntitlement`).
    111 - The app runs as a normal non-root user. When the headset display is lost and does not return within a 2 s
    112   grace period, it runs that helper directly to send a USB-PD Data Reset to port 2's controller. No sudo, no password:
    113   the kernel admits root OR that entitlement, and AMFI being off (`amfi_get_out_of_my_way=1`) lets an ad-hoc signature
    114   carry it. Limits: 3 s helper timeout, at most 3 resets per minute.
    115 - Recovery runs on its own serial queue, fed directly from the CoreGraphics display callback (before any main-thread
    116   work), because macOS's SkyLight blocks the main thread for ~10 s during a display reconfiguration
    117   (`SLSWaitThroughReconfigurationCycleForcingNotificationHandling`). Any headset display ADD cancels a pending reset.
    118 - The helper reports failure (exit 1) unless the controller's DATA1 result is 0. A result of 0x04 has been seen after
    119   an accepted DRST that still restored the display; the app treats ADD, not the helper's exit code, as proof.
    120 - First launch reads the headset's factory optical calibration before enabling the rendering session, then caches it at
    121   `~/Library/Application Support/ReverbM1Demo/factory.json`. Later launches validate and reuse that file.
    122   `--calibration /path/to/factory.json` explicitly selects another saved calibration; invalid calibration stops startup.
    123 - Each eye renders with its calibrated asymmetric field of view to a 3024x3024 target. A final pass applies the factory
    124   Polynomial-3K lens correction separately to red, green, and blue before native 4320x2160 output. There is no
    125   uncalibrated rendering fallback. Eye spacing remains 63 mm; head tracking is not connected to the scene yet.
    126 - `--optics-grid` shows a stationary angular grid, with its green cross at straight ahead (tangent 0,0).
    127   `--snapshot /tmp/calibrated.png` renders through the same correction without opening the headset.
    128 - Calibrated rendering passed a 180 s trial: 89.996 presented fps over the settled health interval, no display drops,
    129   0 GPU errors, approximately 5.97 ms GPU/frame, clean shutdown. See `evidence/calibrated-90hz-trial.json`.
    130   Software validation is complete; physical optical validation remains separate from software/model checks.
    131 - The app covers the headset with a non-activating borderless panel, without exclusive display capture, at native unscaled 4320x2160 @ 90 Hz.
    132   If macOS changes the mode by itself, the app restores 90 Hz (also checked every 10 s, CoreGraphics only).
    133 - Rendering runs on a dedicated "Headset Metal rendering" thread: a `CAMetalDisplayLink` on that thread's run loop
    134   hands each frame a ready drawable (no `nextDrawable` calls, nothing drawn on the main thread). The main thread only
    135   handles AppKit and display connect/disconnect callbacks, so a stalled frame can no longer delay drop detection
    136   (it previously did, by up to ~11 s). The depth buffer is created once and reused.
    137 - Health line every 10 s in the app log: `submitted`, `presented` (only frames Metal reports with a real
    138   presentation time), `skipped`, `fps` (presented per second), `presentAge`, `GPUms` per frame, `GPUerrors`, IMU sample
    139   count. Healthy looks like `fps=90.00 presentAge=0.01s GPUerrors=0` with `skipped` not increasing after startup.
    140 - Status (2026-10-20, ~[time redacted]):
    141   - Stereo 3D output was confirmed in hardware testing.
    142   - Shutdown crash (exit -11, double CAMetalDisplayLink invalidate) fixed; 2 clean-exit/restart trials exit 0.
    143   - Fault soak (`evidence/final-metal-worker-soak.txt`, build before the recovery queue): 3 forced outages all
    144     recovered; after them ~7 min untouched with zero drops, 90.00 presented fps, GPU ~1.5 ms/frame, 0 GPU errors.
    145   - Root cause of the ~10 s main-thread SkyLight wait after a REMOVE: unknown (see NOTES.md).
    146   - Forced-outage duration (display lost -> back): 14.4 s before the recovery queue, 4.4 s with it
    147     (= 2 s grace + ~2.2 s reset/retrain/re-add). Releasing the capture inside the callback did not help
    148     (CGDisplayRelease returns 1001 = kCGErrorIllegalArgument there; outages 4.6 s) and was removed.
    149   - Every DRST is followed ~5.7 s later by a brief lane-sync blip (IRQ_HPD/INTERLANE retrain) that the app handles automatically.
    150   - **Reliability milestone PASSED** (`evidence/verified-90hz-soak.json`, `evidence/verified-90hz-soak.txt`):
    151     no-fault soak at native 4320x2160 @ 90 Hz, exit 0, 649.965 s settled interval, 58,496 presented frames =
    152     89.9987 fps, skipped +3, 0 GPU errors, no render-worker join timeout, zero DP resets. One 0.637 s self-heal at
    153     startup, then no drops.
    154 - Factory calibration: `.build/debug/VRWorkstation --read-calibration <path>` reads the headset's config store over
    155   HID (10,737 bytes, under 1 s; do it before the app enables the IMU). The app caches it at
    156   `~/Library/Application Support/ReverbM1Demo/factory.json`. Lens correction using it is in progress.
    157 
    158 ## How the boot-arg was found
    159 `amfi_get_out_of_my_way` controls code signing, not HDCP, so we searched this Mac's own kernel:
    160 1. Copied the arm64 kernelcache from
    161    `/System/Volumes/Preboot/<uuid>/boot/<hash>/System/Library/Caches/com.apple.kernelcaches/kernelcache`
    162    and decompressed it with `pyimg4 img4 extract` + `pyimg4 im4p extract`.
    163 2. Searched its strings for HDCP names, then disassembled the code that uses them (capstone, plus a small
    164    ADRP/ADD cross-reference scanner).
    165 3. Findings:
    166    - `AppleDCPDPTXProxy` calls `PE_parse_boot_argn("hdcp-fuse-mask", &mask, 4)` (default = hardware fuse & 3).
    167      Apple's own "HDCP suppressed per disable-av-content-protection" path (a device-tree flag set by iBoot,
    168      not settable by us) simply sets that mask to 0.
    169    - `AppleSEPHDCPManager` also parses `hdcp-fuse-mask` and **replaces every HDCP channel's protocol bits**
    170      with it (`caps = (caps & ~0x18) | (mask & 3) << 3`) before handing them to the Secure Enclave.
    171    - The external display coprocessor `dcpext` (USB-C DisplayPort) owns HDCP channels 5 and 6.
    172    - So `hdcp-fuse-mask=0` leaves the transmitter with no HDCP protocols, and encryption never starts.
    173 
    174 ## Gotchas
    175 - Keep `IOHIDManager` objects alive as long as you use their devices; freeing the manager closes the device.
    176 - In zsh, `log` is a builtin; use `/usr/bin/log`.
    177 - The headset's status report `0x05`: byte 1 appears to track panel-on (unreliable on the G2 per Wintch/reverb-g2). Byte 5 is
    178   NOT a reliable refresh measurement: it read 0x59/0x3b in some sessions but 0 while video was visibly running in others.
    179   Judge the display by the native mode in use and actual Metal presentation counts, not by this byte.
    180 
    181 ## Tools (`tools/`)
    182 - `hmdwake` (`.build/debug/hmdwake`): wake sequence + IMU on, exposing native desktop output. Feature/control payloads are omitted from logs. `passive` arg = listen only. SIGUSR1 = screen on.
    183 - `displaywatch` (`.build/debug/displaywatch`): passively logs display add/remove (CoreGraphics callbacks); does not capture screen pixels.
    184 - Never poll `ioreg -c IOPortTransportStateDisplayPort` (the old `linkwatch.sh` did; removed).
    185 - `testpattern`: red LEFT / blue RIGHT fullscreen pattern on the headset.
    186 - `verify.sh`: passive one-shot check (boot-args + summaries of existing logs). No ioreg/system_profiler/HID. Launches nothing.
    187 - `build.sh`: builds the app, signed `hpmctl` helper, `hmdwake`, and `displaywatch` together (step 4).
    188 - `hpmctl` / `recovery_trials.py` / the app's LinkRecovery helper: see NOTES.md "Link reliability" (vr0 owns these).
    189 
    190 ### Optical tuning window
    191 
    192 The **Headset optics** window on the Mac switches between the 3D scene, a stationary white grid, and a green-only
    193 sharpness grid. Red correction and separate blue horizontal/vertical correction range from -100% to 400%; 100% is
    194 factory compensation. Changes save automatically to `~/Library/Application Support/ReverbM1Demo/optics-adjustments.json`.
    195 The factory reset button restores 100% for all three controls. Color controls are disabled for the green-only pattern.
    196 The floor now uses a filtered procedural grid to avoid subpixel line aliasing at distance.
    197 
    198 The headset uses a non-activating panel and is placed to the right of other displays for this session. The Mac controls remain a normal window with a Quit button. No Dock preferences are changed.