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.