commit bd4f569e3845be6bb2b7f208cea8dd3c28d4cdad
Author: umhau <umhau@example.invalid>
Date: Wed, 21 Oct 2026 12:00:00 +0000
Reverb G1 on M1: HDCP fix docs, HID tools, stereo renderer scaffold
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat:
20 files changed, 780 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,14 @@
+.build/
+tools/kernel/venv/
+tools/kernel/kernelcache.macho
+*.log
+tools/hmdwake
+tools/hidcmd
+tools/displaywatch
+tools/testpattern
+
+# Generated artifacts can contain private device identifiers or local paths.
+__pycache__/
+*.pyc
+/factory*.json
+/factory*.bin
diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md
@@ -0,0 +1,18 @@
+# Dependencies
+
+## Installed by us
+- Python venv (scratch, analysis only, not needed at runtime): `pyimg4` (decompress kernelcache), `capstone`, `numpy`
+ (disassembly / xref search). Recreate: `python3 -m venv venv && venv/bin/pip install pyimg4 capstone numpy`.
+
+## System configuration changes
+- Boot-arg `amfi_get_out_of_my_way=1` (recovery helper prerequisite; SIP disabled for this configuration).
+- APPLIED 2026-10-20, confirmed working: boot-arg `hdcp-fuse-mask=0` (disables HDCP so the headset stops blanking). See SETUP.md.
+ Apply: `sudo nvram boot-args="amfi_get_out_of_my_way=1 hdcp-fuse-mask=0"` + reboot.
+ Revert: `sudo nvram boot-args="amfi_get_out_of_my_way=1"` + reboot.
+
+## Pre-existing, relied upon
+- macOS 15.6.1, Apple M1 MacBook Air; Xcode Command Line Tools (Swift 6.1.2). No full Xcode, so Metal
+ shaders are compiled at runtime from source.
+
+## Drivers
+- None. Headset HID devices are driven from user space via IOKit (IOHIDManager).
diff --git a/NOTES.md b/NOTES.md
@@ -0,0 +1,24 @@
+# HP Reverb G1 on M1 Mac: findings
+
+## Root cause of the dark panel: HDCP
+When the DP link comes up, the headset turns the panel on (status `05 01 ...`). macOS's DCP then enables
+HDCP 2.x encryption about 2-3 s after hotplug; the headset blanks within ~300 ms, drops HPD, and retries every
+~8 s forever. Ruled out: power (same hub works on Linux), refresh rate/mode, bit depth (8-bit RGB), our HID commands.
+
+## Fix (CONFIRMED 2026-10-20): boot-arg `hdcp-fuse-mask=0`. Desktop output confirmed in hardware testing; full recipe in SETUP.md
+From disassembling this machine's arm64 kernelcache (macOS 15.6.1):
+- DPTX init: `PE_parse_boot_argn("hdcp-fuse-mask", &mask, 4)`, default = fuse & 3.
+- Then checks device-tree `/chosen/disable-av-content-protection` (iBoot-set, not a boot-arg). If true it logs
+ "HDCP suppressed per disable-av-content-protection" and sets mask = 0. `-restore` does the same.
+- So `hdcp-fuse-mask=0` reproduces Apple's own HDCP-suppressed state. Parsed at 2 call sites.
+- Other HDCP boot-args (semantics not decoded): av_hdcp_protocol_mask, av_hdcp_auth_policy,
+ av_protection_delay_ms, av_cp_failure_mitigation_enabled.
+- `amfi_get_out_of_my_way=1` affects code signing, not HDCP.
+
+## HID (works)
+- Control 03f0:0c6a: 4x feature [0x50,0x01]+get, get 0x09/0x08/0x06, feature [0x04,0x01] = screen on.
+- IMU 045e:0659: output [0x02,0x07] starts 1000 Hz IMU (381-byte reports).
+- Status 0x05 (11 bytes): [1],[4] panel on; [5] measured refresh-1 (0 = no video).
+
+## After reboot
+Run `tools/verify.sh`. Success = no REMOVE/ADD loop and the panel stays lit (check physically).
diff --git a/Package.swift b/Package.swift
@@ -0,0 +1,16 @@
+// swift-tools-version:5.9
+import PackageDescription
+
+let package = Package(
+ name: "VRWorkstation",
+ platforms: [.macOS(.v14)],
+ targets: [
+ .executableTarget(
+ name: "VRWorkstation",
+ linkerSettings: [
+ .linkedFramework("IOKit"), .linkedFramework("Metal"),
+ .linkedFramework("MetalKit"), .linkedFramework("AppKit"),
+ ]
+ ),
+ ]
+)
diff --git a/SETUP.md b/SETUP.md
@@ -0,0 +1,88 @@
+# HP Reverb G1 on an M1 Mac: how we got the display working
+
+First lit successfully 2026-10-20: macOS desktop visible inside the headset, panel stays lit, no replugging.
+
+Machine: Apple M1 MacBook Air, macOS 15.6.1 (24G90). Headset: HP Reverb G1 (VR1000-2xxx).
+
+## The problem
+
+The Mac detected the headset as a 4320x2160 display, but the panel never stayed lit. On each connection the
+headset turned the panel on, turned it off ~300 ms later, dropped the DisplayPort link and retried every ~8 s.
+
+**Cause: HDCP.** A couple of seconds after hotplug, macOS's display coprocessor (DCP) starts HDCP 2.x
+encryption on every external DisplayPort link. The headset can't handle it and blanks. Linux never does this,
+which is why the same headset and hub worked on a Linux box.
+
+Ruled out along the way: power/hub, refresh rate and resolution, color bit depth (macOS already sends 8-bit RGB),
+the proximity sensor, and our own HID commands (the headset blanks even when we send nothing).
+
+## The fix (exact steps)
+
+### 1. System security prerequisites
+- SIP disabled (`csrutil disable` from Recovery). Check: `csrutil status`.
+- 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.
+
+### 2. Disable HDCP with a boot-arg
+```sh
+sudo nvram boot-args="amfi_get_out_of_my_way=1 hdcp-fuse-mask=0"
+nvram boot-args # must print both flags
+# reboot
+```
+Note that `boot-args` is one string, so writing it again **replaces** the previous value. Always write the
+full set of flags. (Our first reboot failed for exactly this reason: only the AMFI flag was saved.)
+
+After the reboot, verify (no headset needed):
+```sh
+sysctl -n kern.bootargs # -> amfi_get_out_of_my_way=1 hdcp-fuse-mask=0
+ioreg -l -w0 | grep '"HDCPTXCapabilities"' # every line should show {"Protocols"=()}
+```
+Before the fix these show `{"Protocols"=(1,2)}` (HDCP 1.x and 2.x). The `HDCPCapabilityMask` field does
+NOT change (still 2/3); that's expected and harmless.
+
+**Revert:** `sudo nvram boot-args="amfi_get_out_of_my_way=1"` and reboot.
+Side effect: HDCP-protected content (some streaming video to external displays) won't play on external screens.
+
+### 3. Connect the headset
+1. Headset USB-A into the (powered) Anker hub; hub into the Mac.
+2. DP-to-USB-C adapter into the Mac's other USB-C port, then the headset's DP plug into the adapter.
+3. macOS asks **"Allow accessory to connect?"**. Click **Allow**. Until you do, all HID calls fail with
+ `0xe00002cd` (kIOReturnNotOpen).
+
+### 4. Wake the headset over USB HID
+```sh
+cd tools && ./hmdwake # leave running; Ctrl-C to quit
+```
+It sends (ported from Monado's `wmr_hmd_activate_reverb`):
+- To the QHMD control device `03f0:0c6a` (feature reports):
+ 4x set `[0x50, 0x01]` + get `0x50`, then get `0x09`, `0x08`, `0x06`, then set `[0x04, 0x01]` (screen on).
+- To the HoloLens Sensors device `045e:0659` (output report): `[0x02, 0x07]` + zero pad to 64 bytes (IMU on).
+
+Within ~1.5 s the DP link trains (HBR3, 8.1 Gbps x 4 lanes), macOS adds the 4320x2160 display, and the headset
+reports `05 01 01 01 01 ...` (panel on), with no further `05 00` and no drops.
+
+## How the boot-arg was found
+`amfi_get_out_of_my_way` controls code signing, not HDCP, so we searched this Mac's own kernel:
+1. Copied the arm64 kernelcache from
+ `/System/Volumes/Preboot/<uuid>/boot/<hash>/System/Library/Caches/com.apple.kernelcaches/kernelcache`
+ and decompressed it with `pyimg4 img4 extract` + `pyimg4 im4p extract`.
+2. Searched its strings for HDCP names, then disassembled the code that uses them (capstone, plus a small
+ ADRP/ADD cross-reference scanner).
+3. Findings:
+ - `AppleDCPDPTXProxy` calls `PE_parse_boot_argn("hdcp-fuse-mask", &mask, 4)` (default = hardware fuse & 3).
+ Apple's own "HDCP suppressed per disable-av-content-protection" path (a device-tree flag set by iBoot,
+ not settable by us) simply sets that mask to 0.
+ - `AppleSEPHDCPManager` also parses `hdcp-fuse-mask` and **replaces every HDCP channel's protocol bits**
+ with it (`caps = (caps & ~0x18) | (mask & 3) << 3`) before handing them to the Secure Enclave.
+ - The external display coprocessor `dcpext` (USB-C DisplayPort) owns HDCP channels 5 and 6.
+ - So `hdcp-fuse-mask=0` leaves the transmitter with no HDCP protocols, and encryption never starts.
+
+## Gotchas
+- Keep `IOHIDManager` objects alive as long as you use their devices; freeing the manager closes the device.
+- In zsh, `log` is a builtin; use `/usr/bin/log`.
+- The headset's status report `0x05`: byte 1 = panel on, byte 5 = measured refresh rate minus 1 (0 = no video).
+
+## Tools (`tools/`)
+- `hmdwake`: wake sequence + IMU on; logs status reports. `passive` arg = listen only. SIGUSR1 = screen on.
+- `displaywatch`, `linkwatch.sh`: log display add/remove and DP link rate/lanes.
+- `testpattern`: red LEFT / blue RIGHT fullscreen pattern on the headset.
+- `verify.sh`: boot-args check + 60 s of the above, summarized.
diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift
@@ -0,0 +1,54 @@
+// Fullscreen window on the headset display running the stereo renderer.
+import AppKit
+import MetalKit
+
+final class HeadsetWindow: NSWindow {
+ override var canBecomeKey: Bool { true }
+ override func keyDown(with e: NSEvent) {
+ if e.charactersIgnoringModifiers == "q" || e.keyCode == 53 { NSApp.terminate(nil) }
+ }
+}
+
+/// The headset shows up as an external display from vendor 0x220E (8718).
+func findHeadsetScreen() -> NSScreen? {
+ NSScreen.screens.first { s in
+ let id = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
+ return CGDisplayVendorNumber(id) == 8718
+ }
+}
+
+func runApp(windowed: Bool) {
+ let app = NSApplication.shared
+ app.setActivationPolicy(.regular)
+
+ // Wake the headset only if its display isn't up yet (e.g. tools/hmdwake already running).
+ var hmd: HMD?
+ if !windowed && findHeadsetScreen() == nil {
+ let h = HMD()
+ h.onStatus = { print("[hmd]", $0) }
+ do { try h.start(); hmd = h; print("[hmd] woke headset, waiting for its display...") }
+ catch { print("[hmd] not available:", error) }
+ let deadline = Date().addingTimeInterval(10)
+ while findHeadsetScreen() == nil && Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.25)) }
+ }
+
+ let screen = windowed ? nil : findHeadsetScreen()
+ if screen == nil && !windowed { print("Headset display not found; showing a preview window instead.") }
+ let frame = screen?.frame ?? NSRect(x: 100, y: 100, width: 1080, height: 540)
+ let win = HeadsetWindow(contentRect: frame, styleMask: screen == nil ? [.titled, .closable, .resizable] : .borderless,
+ backing: .buffered, defer: false, screen: screen)
+ win.title = "VR Workstation preview"
+ let view = MTKView(frame: NSRect(origin: .zero, size: frame.size), device: MTLCreateSystemDefaultDevice())
+ view.colorPixelFormat = .bgra8Unorm
+ view.depthStencilPixelFormat = .depth32Float
+ view.preferredFramesPerSecond = 90
+ let renderer: Renderer
+ do { renderer = try Renderer(view: view) } catch { print("Renderer failed:", error); exit(1) }
+ view.delegate = renderer
+ win.contentView = view
+ if screen != nil { win.level = .screenSaver; win.setFrame(frame, display: true) }
+ win.makeKeyAndOrderFront(nil)
+ app.activate(ignoringOtherApps: true)
+ print("Rendering on \(screen?.localizedName ?? "preview window") \(frame.size), drawable \(view.drawableSize)")
+ withExtendedLifetime((hmd, renderer)) { app.run() }
+}
diff --git a/Sources/VRWorkstation/HMD.swift b/Sources/VRWorkstation/HMD.swift
@@ -0,0 +1,96 @@
+// HP Reverb G1 over plain IOKit HID: wakes the display and streams the IMU.
+// Protocol ported from Monado (src/xrt/drivers/wmr/wmr_hmd.c, wmr_protocol.c).
+// Requires boot-arg hdcp-fuse-mask=0, otherwise macOS starts HDCP and the headset blanks.
+import Foundation
+import IOKit.hid
+import simd
+
+struct IMUSample {
+ var time: Double // seconds, device clock
+ var gyro: SIMD3<Float> // rad/s, raw device axes
+ var accel: SIMD3<Float> // m/s^2, raw device axes
+}
+
+final class HMD {
+ var onIMU: ((IMUSample) -> Void)?
+ var onStatus: ((String) -> Void)?
+
+ private var managers: [IOHIDManager] = [] // must outlive the devices, or they get closed
+ private var control: IOHIDDevice?
+ private var sensors: IOHIDDevice?
+ private let controlBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: 64)
+ private let sensorsBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: 512)
+
+ struct NotFound: Error, CustomStringConvertible {
+ let name: String
+ var description: String { "\(name) not found (plugged in, and accessory allowed?)" }
+ }
+
+ /// Opens both HID devices, runs the wake sequence and starts the IMU. Callbacks fire on the main run loop.
+ func start() throws {
+ control = try open(0x03f0, 0x0c6a, "QHMD control device")
+ sensors = try open(0x045e, 0x0659, "HoloLens Sensors")
+ let me = Unmanaged.passUnretained(self).toOpaque()
+ IOHIDDeviceRegisterInputReportCallback(control!, controlBuf, 64, { ctx, _, _, _, _, r, n in
+ Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue().handleControl(r, n)
+ }, me)
+ IOHIDDeviceRegisterInputReportCallback(sensors!, sensorsBuf, 512, { ctx, _, _, _, _, r, n in
+ Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue().handleSensors(r, n)
+ }, me)
+ for d in [control!, sensors!] { IOHIDDeviceScheduleWithRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) }
+
+ usleep(300_000)
+ for _ in 0..<4 {
+ var c = [UInt8](repeating: 0, count: 64); c[0] = 0x50; c[1] = 0x01
+ setFeature(c); _ = getFeature(0x50); usleep(10_000)
+ }
+ _ = getFeature(0x09) // Preserve wake sequence; do not log device serials.
+ _ = getFeature(0x08); _ = getFeature(0x06)
+ setFeature([0x04, 0x01]) // screen on
+ var imuOn = [UInt8](repeating: 0, count: 64); imuOn[0] = 0x02; imuOn[1] = 0x07
+ IOHIDDeviceSetReport(sensors!, kIOHIDReportTypeOutput, 0x02, imuOn, imuOn.count)
+ }
+
+ private func open(_ vid: Int, _ pid: Int, _ name: String) throws -> IOHIDDevice {
+ let m = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
+ IOHIDManagerSetDeviceMatching(m, [kIOHIDVendorIDKey: vid, kIOHIDProductIDKey: pid] as CFDictionary)
+ guard IOHIDManagerOpen(m, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess,
+ let d = (IOHIDManagerCopyDevices(m) as? Set<IOHIDDevice>)?.first else { throw NotFound(name: name) }
+ managers.append(m)
+ return d
+ }
+
+ private func setFeature(_ b: [UInt8]) {
+ let r = IOHIDDeviceSetReport(control!, kIOHIDReportTypeFeature, CFIndex(b[0]), b, b.count)
+ if r != kIOReturnSuccess { onStatus?(String(format: "HID set 0x%02x failed: 0x%08x", b[0], r)) }
+ }
+
+ private func getFeature(_ id: UInt8) -> [UInt8]? {
+ var b = [UInt8](repeating: 0, count: 64); b[0] = id; var n = CFIndex(64)
+ return IOHIDDeviceGetReport(control!, kIOHIDReportTypeFeature, CFIndex(id), &b, &n) == kIOReturnSuccess ? Array(b[0..<n]) : nil
+ }
+
+ private func handleControl(_ p: UnsafeMutablePointer<UInt8>, _ n: Int) {
+ if n >= 6 && p[0] == 0x05 { onStatus?("panel \(p[1] == 1 ? "on" : "off"), measured refresh \(p[5] == 0 ? 0 : Int(p[5]) + 1) Hz") }
+ }
+
+ private func handleSensors(_ p: UnsafeMutablePointer<UInt8>, _ n: Int) {
+ guard p[0] == 0x01, n == 381 || n == 497 else { return }
+ func i16(_ o: Int) -> Int32 { Int32(Int16(bitPattern: UInt16(p[o]) | UInt16(p[o + 1]) << 8)) }
+ func i32(_ o: Int) -> Int32 { Int32(bitPattern: UInt32(p[o]) | UInt32(p[o + 1]) << 8 | UInt32(p[o + 2]) << 16 | UInt32(p[o + 3]) << 24) }
+ func u64(_ o: Int) -> UInt64 { (0..<8).reduce(0) { $0 | UInt64(p[o + $1]) << (8 * UInt64($1)) } }
+ // Layout: id(1) temp[4](8) gyro_ts[4](32) gyro[3][32](192) accel_ts[4](32) accel[3][4](48) video_ts[4](32)
+ let gyroTS = 9, gyro = 41, accel = 41 + 192 + 32
+ for i in 0..<4 {
+ var g = SIMD3<Float>()
+ var acc = SIMD3<Float>()
+ for a in 0..<3 {
+ var sum: Int32 = 0
+ for k in 0..<8 { sum += i16(gyro + (a * 32 + 8 * i + k) * 2) } // 8 sub-samples per sample
+ g[a] = Float(sum) * 0.001 / 8
+ acc[a] = Float(i32(accel + (a * 4 + i) * 4)) * 0.001
+ }
+ onIMU?(IMUSample(time: Double(u64(gyroTS + i * 8)) * 100e-9, gyro: g, accel: acc))
+ }
+ }
+}
diff --git a/Sources/VRWorkstation/Renderer.swift b/Sources/VRWorkstation/Renderer.swift
@@ -0,0 +1,143 @@
+// Side-by-side stereo renderer: one viewport per eye on the 4320x2160 headset panel.
+import AppKit
+import Metal
+import MetalKit
+import simd
+
+struct Vertex { var pos: SIMD3<Float>; var normal: SIMD3<Float>; var color: SIMD3<Float> }
+struct Uniforms { var mvp: simd_float4x4; var model: simd_float4x4 }
+
+final class Renderer: NSObject, MTKViewDelegate {
+ let device: MTLDevice
+ let queue: MTLCommandQueue
+ let pipeline: MTLRenderPipelineState
+ let depth: MTLDepthStencilState
+ let cube: MTLBuffer
+ let cubeCount: Int
+ let start = CACurrentMediaTime()
+
+ /// Head orientation (world-from-head). Identity when head tracking is off.
+ var headOrientation: () -> simd_quatf = { simd_quatf(angle: 0, axis: [0, 1, 0]) }
+ var ipd: Float = 0.063
+ var fovY: Float = 100 * .pi / 180
+
+ init(view: MTKView) throws {
+ guard let dev = view.device ?? MTLCreateSystemDefaultDevice(), let q = dev.makeCommandQueue() else {
+ throw NSError(domain: "Renderer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No Metal device"])
+ }
+ device = dev; queue = q
+ let lib = try dev.makeLibrary(source: shaderSource, options: nil)
+ let pd = MTLRenderPipelineDescriptor()
+ pd.vertexFunction = lib.makeFunction(name: "vmain")
+ pd.fragmentFunction = lib.makeFunction(name: "fmain")
+ pd.colorAttachments[0].pixelFormat = view.colorPixelFormat
+ pd.depthAttachmentPixelFormat = view.depthStencilPixelFormat
+ pipeline = try dev.makeRenderPipelineState(descriptor: pd)
+ let dd = MTLDepthStencilDescriptor()
+ dd.depthCompareFunction = .less
+ dd.isDepthWriteEnabled = true
+ depth = dev.makeDepthStencilState(descriptor: dd)!
+ let verts = Renderer.makeCube()
+ cubeCount = verts.count
+ cube = dev.makeBuffer(bytes: verts, length: MemoryLayout<Vertex>.stride * verts.count)!
+ }
+
+ static func makeCube() -> [Vertex] {
+ // 6 faces, each a distinct color, 2 triangles per face.
+ let faces: [(n: SIMD3<Float>, u: SIMD3<Float>, v: SIMD3<Float>, c: SIMD3<Float>)] = [
+ ([1, 0, 0], [0, 0, -1], [0, 1, 0], [0.95, 0.30, 0.25]),
+ ([-1, 0, 0], [0, 0, 1], [0, 1, 0], [0.25, 0.80, 0.95]),
+ ([0, 1, 0], [1, 0, 0], [0, 0, -1], [0.40, 0.90, 0.35]),
+ ([0, -1, 0], [1, 0, 0], [0, 0, 1], [0.95, 0.85, 0.30]),
+ ([0, 0, 1], [1, 0, 0], [0, 1, 0], [0.75, 0.40, 0.95]),
+ ([0, 0, -1], [-1, 0, 0], [0, 1, 0], [0.95, 0.60, 0.20]),
+ ]
+ var out: [Vertex] = []
+ for f in faces {
+ let c: SIMD3<Float> = f.n * 0.5
+ let du: SIMD3<Float> = f.u * 0.5, dv: SIMD3<Float> = f.v * 0.5
+ let p: [SIMD3<Float>] = [c - du - dv, c + du - dv, c + du + dv, c - du + dv]
+ for i in [0, 1, 2, 0, 2, 3] { out.append(Vertex(pos: p[i], normal: f.n, color: f.c)) }
+ }
+ return out
+ }
+
+ func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
+
+ var frames = 0
+ func draw(in view: MTKView) {
+ frames += 1
+ if frames == 1 || frames % 300 == 0 { print("frame \(frames) drawable \(view.drawableSize)") }
+ guard let rpd = view.currentRenderPassDescriptor, let drawable = view.currentDrawable,
+ let cb = queue.makeCommandBuffer() else { return }
+ encode(cb, rpd, size: view.drawableSize, time: Float(CACurrentMediaTime() - start))
+ cb.present(drawable)
+ cb.commit()
+ }
+
+ /// Draws both eyes side by side into the render pass.
+ func encode(_ cb: MTLCommandBuffer, _ rpd: MTLRenderPassDescriptor, size: CGSize, time t: Float) {
+ rpd.colorAttachments[0].clearColor = MTLClearColor(red: 0.05, green: 0.05, blue: 0.08, alpha: 1)
+ guard let enc = cb.makeRenderCommandEncoder(descriptor: rpd) else { return }
+ enc.setRenderPipelineState(pipeline)
+ enc.setDepthStencilState(depth)
+ enc.setCullMode(.back)
+ enc.setFrontFacing(.counterClockwise)
+ enc.setVertexBuffer(cube, offset: 0, index: 0)
+
+ let eyeW = size.width / 2
+ let model = translation([0, 0, -1.5])
+ * simd_float4x4(simd_quatf(angle: t * 0.9, axis: normalize(SIMD3<Float>(0.3, 1, 0.2))))
+ * scale(0.4)
+ let head = simd_float4x4(headOrientation())
+ let proj = perspective(fovY: fovY, aspect: Float(eyeW / size.height), near: 0.05, far: 100)
+
+ for eye in 0..<2 {
+ enc.setViewport(MTLViewport(originX: Double(eyeW) * Double(eye), originY: 0,
+ width: Double(eyeW), height: Double(size.height), znear: 0, zfar: 1))
+ let eyeOffset: Float = (eye == 0 ? -0.5 : 0.5) * ipd
+ let view = (head * translation([eyeOffset, 0, 0])).inverse
+ var u = Uniforms(mvp: proj * view * model, model: model)
+ enc.setVertexBytes(&u, length: MemoryLayout<Uniforms>.stride, index: 1)
+ enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: cubeCount)
+ }
+ enc.endEncoding()
+ }
+
+ /// Renders one frame off-screen and writes it as a PNG (for checking output without a window).
+ func snapshot(width: Int, height: Int, time: Float, to path: String) throws {
+ func tex(_ f: MTLPixelFormat, _ usage: MTLTextureUsage, _ mode: MTLStorageMode) -> MTLTexture {
+ let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: f, width: width, height: height, mipmapped: false)
+ d.usage = usage; d.storageMode = mode
+ return device.makeTexture(descriptor: d)!
+ }
+ let color = tex(.bgra8Unorm, .renderTarget, .shared), depthTex = tex(.depth32Float, .renderTarget, .private)
+ let rpd = MTLRenderPassDescriptor()
+ rpd.colorAttachments[0].texture = color
+ rpd.colorAttachments[0].loadAction = .clear
+ rpd.colorAttachments[0].storeAction = .store
+ rpd.depthAttachment.texture = depthTex
+ rpd.depthAttachment.loadAction = .clear
+ rpd.depthAttachment.clearDepth = 1
+ let cb = queue.makeCommandBuffer()!
+ encode(cb, rpd, size: CGSize(width: width, height: height), time: time)
+ cb.commit(); cb.waitUntilCompleted()
+ var px = [UInt8](repeating: 0, count: width * height * 4)
+ color.getBytes(&px, bytesPerRow: width * 4, from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0)
+ let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, bitsPerSample: 8,
+ samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
+ bytesPerRow: width * 4, bitsPerPixel: 32)!
+ for i in stride(from: 0, to: px.count, by: 4) { px.swapAt(i, i + 2) } // BGRA -> RGBA
+ memcpy(rep.bitmapData!, px, px.count)
+ try rep.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: path))
+ }
+}
+
+func translation(_ t: SIMD3<Float>) -> simd_float4x4 {
+ var m = matrix_identity_float4x4; m.columns.3 = SIMD4<Float>(t, 1); return m
+}
+func scale(_ s: Float) -> simd_float4x4 { simd_float4x4(diagonal: [s, s, s, 1]) }
+func perspective(fovY: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4 {
+ let y = 1 / tan(fovY / 2), x = y / aspect, z = far / (near - far)
+ return simd_float4x4(columns: ([x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, -1], [0, 0, z * near, 0]))
+}
diff --git a/Sources/VRWorkstation/Shaders.swift b/Sources/VRWorkstation/Shaders.swift
@@ -0,0 +1,23 @@
+// Metal shaders, compiled at runtime (the Command Line Tools have no offline `metal` compiler).
+let shaderSource = """
+#include <metal_stdlib>
+using namespace metal;
+
+struct Vertex { float3 pos; float3 normal; float3 color; }; // 16-byte aligned, matches Swift SIMD3<Float>
+struct Uniforms { float4x4 mvp; float4x4 model; };
+struct VOut { float4 pos [[position]]; float3 normal; float3 color; };
+
+vertex VOut vmain(uint vid [[vertex_id]], const device Vertex *v [[buffer(0)]], constant Uniforms &u [[buffer(1)]]) {
+ VOut o;
+ o.pos = u.mvp * float4(v[vid].pos, 1);
+ o.normal = (u.model * float4(v[vid].normal, 0)).xyz;
+ o.color = v[vid].color;
+ return o;
+}
+
+fragment float4 fmain(VOut in [[stage_in]]) {
+ float3 l = normalize(float3(0.4, 0.8, 0.6));
+ float d = max(dot(normalize(in.normal), l), 0.0);
+ return float4(in.color * (0.25 + 0.75 * d), 1);
+}
+"""
diff --git a/Sources/VRWorkstation/main.swift b/Sources/VRWorkstation/main.swift
@@ -0,0 +1,13 @@
+import Foundation
+import MetalKit
+setvbuf(stdout, nil, _IOLBF, 0)
+// Usage: VRWorkstation [--windowed] | --snapshot out.png
+if let i = CommandLine.arguments.firstIndex(of: "--snapshot") {
+ let path = CommandLine.arguments.count > i + 1 ? CommandLine.arguments[i + 1] : "snapshot.png"
+ let view = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice())
+ view.colorPixelFormat = .bgra8Unorm; view.depthStencilPixelFormat = .depth32Float
+ do { try Renderer(view: view).snapshot(width: 2160, height: 1080, time: 1.0, to: path); print("wrote", path) }
+ catch { print("snapshot failed:", error) }
+ exit(0)
+}
+runApp(windowed: CommandLine.arguments.contains("--windowed"))
diff --git a/tools/autowake.sh b/tools/autowake.sh
@@ -0,0 +1,7 @@
+#!/bin/zsh
+# Waits for the headset's QHMD control device, then runs hmdwake (logging to hmdwake.log).
+cd "$(dirname "$0")"
+until ioreg -p IOUSB -w0 | grep -q "QHMD"; do sleep 0.5; done
+echo "$(date +%s) QHMD appeared" >> hmdwake.log
+sleep 1
+exec ./hmdwake >> hmdwake.log 2>&1
diff --git a/tools/displaywatch.swift b/tools/displaywatch.swift
@@ -0,0 +1,23 @@
+// Logs display add/remove/mode events with timestamps.
+import AppKit
+import CoreGraphics
+setvbuf(stdout, nil, _IOLBF, 0)
+func dump(_ tag: String) {
+ var ids = [CGDirectDisplayID](repeating: 0, count: 8); var n: UInt32 = 0
+ CGGetOnlineDisplayList(8, &ids, &n)
+ let d = ids.prefix(Int(n)).map { id -> String in
+ let m = CGDisplayCopyDisplayMode(id)
+ return "\(id):\(CGDisplayVendorNumber(id)) \(m?.pixelWidth ?? 0)x\(m?.pixelHeight ?? 0)@\(m?.refreshRate ?? 0)"
+ }
+ print(String(format: "%.3f ", Date().timeIntervalSince1970) + "\(tag): \(d.joined(separator: " | "))")
+}
+CGDisplayRegisterReconfigurationCallback({ id, flags, _ in
+ if flags.contains(.beginConfigurationFlag) { return }
+ var f: [String] = []
+ if flags.contains(.addFlag) { f.append("ADD") }
+ if flags.contains(.removeFlag) { f.append("REMOVE") }
+ if flags.contains(.setModeFlag) { f.append("MODE") }
+ dump("display \(id) [\(f.joined(separator: ","))]")
+}, nil)
+dump("start")
+NSApplication.shared.run()
diff --git a/tools/hidcmd.swift b/tools/hidcmd.swift
@@ -0,0 +1,21 @@
+// One-shot headset commands: hidcmd on | off | wake
+import Foundation
+import IOKit.hid
+let m = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
+IOHIDManagerSetDeviceMatching(m, [kIOHIDVendorIDKey: 0x03f0, kIOHIDProductIDKey: 0x0c6a] as CFDictionary)
+IOHIDManagerOpen(m, IOOptionBits(kIOHIDOptionsTypeNone))
+guard let d = (IOHIDManagerCopyDevices(m) as? Set<IOHIDDevice>)?.first else { print("QHMD not found"); exit(1) }
+func set(_ b: [UInt8]) -> IOReturn { IOHIDDeviceSetReport(d, kIOHIDReportTypeFeature, CFIndex(b[0]), b, b.count) }
+switch CommandLine.arguments.dropFirst().first ?? "" {
+case "on": print("screen_on", set([0x04, 0x01]))
+case "off": print("screen_off", set([0x04, 0x00]))
+case "wake":
+ for _ in 0..<4 {
+ var c = [UInt8](repeating: 0, count: 64); c[0] = 0x50; c[1] = 0x01; _ = set(c)
+ var g = [UInt8](repeating: 0, count: 64); g[0] = 0x50; var n = CFIndex(64)
+ _ = IOHIDDeviceGetReport(d, kIOHIDReportTypeFeature, 0x50, &g, &n); usleep(10_000)
+ }
+ print("activate+screen_on", set([0x04, 0x01]))
+default: print("usage: hidcmd on|off|wake")
+}
+withExtendedLifetime(m) {}
diff --git a/tools/hmdwake.swift b/tools/hmdwake.swift
@@ -0,0 +1,61 @@
+// Wakes the HP Reverb G1 (sequence from Monado wmr_hmd_activate_reverb), starts the IMU, and logs
+// every control-device report with timestamps. Args: `passive` = send nothing, just listen.
+// SIGUSR1 = resend screen_on, SIGUSR2 = screen off then on.
+import Foundation
+import IOKit.hid
+setvbuf(stdout, nil, _IOLBF, 0)
+
+func hex(_ b: [UInt8]) -> String { b.map { String(format: "%02x", $0) }.joined(separator: " ") }
+func log(_ s: String) { print(String(format: "%.3f ", Date().timeIntervalSince1970) + s) }
+var managers: [IOHIDManager] = [] // must outlive the devices, or they get closed
+func open(_ vid: Int, _ pid: Int) -> IOHIDDevice? {
+ let m = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
+ IOHIDManagerSetDeviceMatching(m, [kIOHIDVendorIDKey: vid, kIOHIDProductIDKey: pid] as CFDictionary)
+ guard IOHIDManagerOpen(m, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else { return nil }
+ managers.append(m)
+ return (IOHIDManagerCopyDevices(m) as? Set<IOHIDDevice>)?.first
+}
+guard let ctl = open(0x03f0, 0x0c6a) else { log("QHMD control device not found"); exit(1) }
+log("opened QHMD control device")
+
+func setFeature(_ b: [UInt8], _ what: String) {
+ let r = IOHIDDeviceSetReport(ctl, kIOHIDReportTypeFeature, CFIndex(b[0]), b, b.count)
+ log("set \(what): " + (r == kIOReturnSuccess ? "ok" : String(format: "err 0x%08x", r)))
+}
+func getFeature(_ id: UInt8) {
+ var b = [UInt8](repeating: 0, count: 64); b[0] = id; var n = CFIndex(64)
+ let r = IOHIDDeviceGetReport(ctl, kIOHIDReportTypeFeature, CFIndex(id), &b, &n)
+ log("get 0x\(String(format: "%02x", id)): " + (r == kIOReturnSuccess ? "ok (feature payload omitted for privacy)" : String(format: "err 0x%08x", r)))
+}
+
+let buf = UnsafeMutablePointer<UInt8>.allocate(capacity: 64)
+IOHIDDeviceRegisterInputReportCallback(ctl, buf, 64, { _, _, _, _, _, r, n in
+ log("control report received (payload omitted for privacy)")
+}, nil)
+IOHIDDeviceScheduleWithRunLoop(ctl, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
+
+if CommandLine.arguments.contains("passive") {
+ log("PASSIVE: listening only")
+} else {
+ usleep(300_000)
+ for _ in 0..<4 {
+ var c = [UInt8](repeating: 0, count: 64); c[0] = 0x50; c[1] = 0x01
+ setFeature(c, "activate 0x50"); getFeature(0x50); usleep(10_000)
+ }
+ getFeature(0x09); getFeature(0x08); getFeature(0x06)
+ setFeature([0x04, 0x01], "screen_on")
+ if let sens = open(0x045e, 0x0659) {
+ var c = [UInt8](repeating: 0, count: 64); c[0] = 0x02; c[1] = 0x07
+ let r = IOHIDDeviceSetReport(sens, kIOHIDReportTypeOutput, 0x02, c, c.count)
+ log("imu_on: " + (r == kIOReturnSuccess ? "ok" : String(format: "err 0x%08x", r)))
+ let sb = UnsafeMutablePointer<UInt8>.allocate(capacity: 512)
+ IOHIDDeviceRegisterInputReportCallback(sens, sb, 512, { _, _, _, _, _, _, _ in }, nil) // keep stream drained
+ IOHIDDeviceScheduleWithRunLoop(sens, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
+ } else { log("HoloLens Sensors not found") }
+}
+signal(SIGUSR1, SIG_IGN); signal(SIGUSR2, SIG_IGN)
+let s1 = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main)
+s1.setEventHandler { setFeature([0x04, 0x01], "screen_on (USR1)") }; s1.resume()
+let s2 = DispatchSource.makeSignalSource(signal: SIGUSR2, queue: .main)
+s2.setEventHandler { setFeature([0x04, 0x00], "screen_off (USR2)"); usleep(500_000); setFeature([0x04, 0x01], "screen_on (USR2)") }; s2.resume()
+CFRunLoopRun()
diff --git a/tools/kernel/.gitignore b/tools/kernel/.gitignore
@@ -0,0 +1,2 @@
+venv/
+kernelcache.macho
diff --git a/tools/kernel/xref.py b/tools/kernel/xref.py
@@ -0,0 +1,120 @@
+# Cross-reference scanner for the decompressed arm64e kernelcache.
+# Finds code that loads a given C string (ADRP + ADD) and says whether it's passed to PE_parse_boot_argn,
+# i.e. whether the string is a boot-arg.
+#
+# Usage: venv/bin/python xref.py kernelcache.macho <string> [<string> ...]
+# Rebuild kernelcache.macho: see SETUP.md ("How the boot-arg was found").
+import struct
+import sys
+
+import numpy as np
+from capstone import CS_ARCH_ARM64, CS_MODE_ARM, Cs
+
+data = open(sys.argv[1] if len(sys.argv) > 1 else 'kernelcache.macho', 'rb').read()
+md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
+
+# Top-level LC_SEGMENT_64s of the fileset map every byte of the file.
+segs = []
+ncmds, off = struct.unpack_from('<I', data, 16)[0], 32
+for _ in range(ncmds):
+ cmd, sz = struct.unpack_from('<II', data, off)
+ if cmd == 0x19:
+ vm, vms, fo, fs = struct.unpack_from('<QQQQ', data, off + 24)
+ segs.append((vm, fo, fs))
+ off += sz
+
+
+def f2v(fo):
+ for vm, sfo, fs in segs:
+ if sfo <= fo < sfo + fs:
+ return vm + fo - sfo
+
+
+def v2f(va):
+ for vm, sfo, fs in segs:
+ if vm <= va < vm + fs:
+ return sfo + va - vm
+
+
+def cstr(va):
+ fo = v2f(va)
+ if fo is None:
+ return None
+ e = data.find(b'\0', fo, fo + 100)
+ s = data[fo:e]
+ return s.decode() if s and all(32 <= c < 127 for c in s) else None
+
+
+# Every ADRP's target page, computed once (vectorized).
+words = np.frombuffer(data[:len(data) // 4 * 4], dtype='<u4')
+adrp_idx = np.nonzero((words & 0x9F000000) == 0x90000000)[0]
+aw = words[adrp_idx].astype(np.int64)
+imm = (((aw >> 5) & 0x7FFFF) << 2 | ((aw >> 29) & 3)) << 12
+imm = np.where(imm & (1 << 32), imm - (1 << 33), imm)
+fo_arr = adrp_idx.astype(np.uint64) * np.uint64(4)
+pc_arr = np.zeros(len(adrp_idx), dtype=np.uint64)
+valid = np.zeros(len(adrp_idx), dtype=bool)
+for vm, sfo, fs in segs:
+ m = (fo_arr >= np.uint64(sfo)) & (fo_arr < np.uint64(sfo + fs))
+ pc_arr[m] = np.uint64(vm) + fo_arr[m] - np.uint64(sfo)
+ valid |= m
+target = (pc_arr & ~np.uint64(0xFFF)) + imm.astype(np.uint64)
+
+
+def find_xrefs(s):
+ """Addresses of instructions that load the address of C string `s`."""
+ out, start = [], 0
+ while (i := data.find(b'\0' + s.encode() + b'\0', start)) >= 0:
+ start = i + 1
+ sva = f2v(i + 1)
+ if sva is None:
+ continue
+ for wi in adrp_idx[valid & (target == np.uint64(sva & ~0xFFF))]:
+ wi = int(wi)
+ rd = int(words[wi]) & 31
+ for k in range(1, 6):
+ w2 = int(words[wi + k])
+ if (w2 & 0xFF800000) == 0x91000000 and ((w2 >> 5) & 31) == rd and ((w2 >> 10) & 0xFFF) == (sva & 0xFFF):
+ out.append(f2v(wi * 4))
+ break
+ return out
+
+
+def calls_after(pc, n=8):
+ fo = v2f(pc)
+ return [int(i.op_str[1:], 16) for i in md.disasm(data[fo:fo + n * 4], pc) if i.mnemonic == 'bl']
+
+
+def find_boot_arg_parser():
+ """PE_parse_boot_argn = the function called right after loading a known boot-arg string."""
+ counts = {}
+ for s in ('amfi_get_out_of_my_way', 'debug', 'serial'):
+ for pc in find_xrefs(s):
+ for t in calls_after(pc, 6):
+ counts[t] = counts.get(t, 0) + 1
+ return max(counts, key=counts.get)
+
+
+def disasm(va, n):
+ """Disassembly with ADRP+ADD string operands annotated."""
+ fo, regs, lines = v2f(va), {}, []
+ for ins in md.disasm(data[fo:fo + n * 4], va):
+ note = ''
+ if ins.mnemonic == 'adrp':
+ regs[ins.op_str.split(',')[0]] = int(ins.op_str.split('#')[1], 16)
+ elif ins.mnemonic == 'add' and '#' in ins.op_str:
+ r = [x.strip() for x in ins.op_str.split(',')]
+ if r[1] in regs:
+ s = cstr(regs[r[1]] + int(r[2][1:], 16))
+ note = f' ; {s!r}' if s else ''
+ lines.append(f'{ins.address:#x}: {ins.mnemonic:7} {ins.op_str}{note}')
+ return lines
+
+
+if __name__ == '__main__':
+ pb = find_boot_arg_parser()
+ print(f'PE_parse_boot_argn = {pb:#x}')
+ for s in sys.argv[2:]:
+ for pc in find_xrefs(s):
+ kind = 'BOOT-ARG' if pb in calls_after(pc, 6) else 'other'
+ print(f'{s:32} ref {pc:#x}: {kind}')
diff --git a/tools/linkwatch.sh b/tools/linkwatch.sh
@@ -0,0 +1,8 @@
+#!/bin/zsh
+# Logs DisplayPort link state of each USB-C port, on change.
+prev=""
+while true; do
+ cur=$(ioreg -r -c IOPortTransportStateDisplayPort -w0 | grep -E '"TransportDescription"|"LinkRateDescription"|"LaneCount"' | awk -F'= ' '{print $2}' | tr '\n' ' ')
+ [[ "$cur" != "$prev" ]] && { echo "$(date +%s) $cur"; prev="$cur"; }
+ sleep 1
+done
diff --git a/tools/recovery_test.sh b/tools/recovery_test.sh
@@ -0,0 +1,13 @@
+#!/bin/zsh
+# Measures how the headset recovers its DP link: off -> wait D s -> on/wake -> time until HPD/link returns.
+# Usage: recovery_test.sh <delay_s> <on|wake>
+cd "$(dirname "$0")"
+D=$1; CMD=${2:-on}
+link() { ioreg -r -c IOPortTransportStateDisplayPort -w0 | grep -q HBR; }
+now() { python3 -c 'import time;print(f"{time.time():.2f}")'; }
+link || { echo "link not up at start; aborting"; exit 1; }
+t0=$(now); ./hidcmd off
+until ! link; do sleep 0.1; done; t1=$(now); echo "link down after $(( t1 - t0 ))s"
+sleep $D; t2=$(now); ./hidcmd $CMD
+until link; do sleep 0.2; [ $(( $(now) - t2 )) -gt 200 ] && { echo "NO RECOVERY within 200s"; exit 2; }; done
+t3=$(now); echo "RESULT delay=$D cmd=$CMD -> link back $(( t3 - t2 ))s after $CMD"
diff --git a/tools/testpattern.swift b/tools/testpattern.swift
@@ -0,0 +1,22 @@
+// Fullscreen LEFT (red) / RIGHT (blue) pattern on the headset display (vendor 8718).
+import AppKit
+let app = NSApplication.shared
+app.setActivationPolicy(.regular)
+guard let screen = NSScreen.screens.first(where: {
+ CGDisplayVendorNumber(($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == 8718
+}) else { print("headset display not found"); exit(1) }
+final class V: NSView {
+ override func draw(_ r: NSRect) {
+ let h = bounds.width / 2
+ NSColor.red.setFill(); NSRect(x: 0, y: 0, width: h, height: bounds.height).fill()
+ NSColor.blue.setFill(); NSRect(x: h, y: 0, width: h, height: bounds.height).fill()
+ let a: [NSAttributedString.Key: Any] = [.font: NSFont.boldSystemFont(ofSize: 140), .foregroundColor: NSColor.white]
+ for (i, t) in ["LEFT", "RIGHT"].enumerated() {
+ let s = NSAttributedString(string: t, attributes: a), z = s.size()
+ s.draw(at: NSPoint(x: h * CGFloat(i) + (h - z.width) / 2, y: (bounds.height - z.height) / 2))
+ }
+ }
+}
+let w = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false, screen: screen)
+w.level = .screenSaver; w.contentView = V(); w.setFrame(screen.frame, display: true); w.orderFrontRegardless()
+app.run()
diff --git a/tools/verify.sh b/tools/verify.sh
@@ -0,0 +1,14 @@
+#!/bin/zsh
+# Post-reboot check: boot-args, then wake headset + loggers for 60 s, then summarize.
+cd "$(dirname "$0")"
+echo "kern.bootargs: $(sysctl -n kern.bootargs)"
+pkill -f './hmdwake|./displaywatch|linkwatch.sh' 2>/dev/null
+./displaywatch > displaywatch.log 2>&1 &
+./linkwatch.sh > linkwatch.log 2>&1 &
+./hmdwake > hmdwake.log 2>&1 &
+echo "watching 60 s... (replug DP if the link doesn't come up)"
+sleep 60
+echo "--- link:"; cat linkwatch.log
+echo "--- display events:"; grep -E "ADD|REMOVE" displaywatch.log
+echo "--- panel status:"; grep status hmdwake.log
+echo "--- HDCP log lines (last 2 min):"; /usr/bin/log show --last 2m --info --debug --style compact 2>/dev/null | grep -i -E "hdcp|EnableEncryption" | tail -15