‹ 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

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:
A.gitignore | 14++++++++++++++
ADEPENDENCIES.md | 18++++++++++++++++++
ANOTES.md | 24++++++++++++++++++++++++
APackage.swift | 16++++++++++++++++
ASETUP.md | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/VRWorkstation/App.swift | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/VRWorkstation/HMD.swift | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/VRWorkstation/Renderer.swift | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/VRWorkstation/Shaders.swift | 23+++++++++++++++++++++++
ASources/VRWorkstation/main.swift | 13+++++++++++++
Atools/autowake.sh | 7+++++++
Atools/displaywatch.swift | 23+++++++++++++++++++++++
Atools/hidcmd.swift | 21+++++++++++++++++++++
Atools/hmdwake.swift | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/kernel/.gitignore | 2++
Atools/kernel/xref.py | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/linkwatch.sh | 8++++++++
Atools/recovery_test.sh | 13+++++++++++++
Atools/testpattern.swift | 22++++++++++++++++++++++
Atools/verify.sh | 14++++++++++++++
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