# M3 — Product Shape

Build-order step 3 (PLAN.md): multi-monitor capture + OS layout, recenter hotkey, idle
safety, settings/persistence, native calibration read. M2 gave one world-fixed test quad
with fusion-correct stereo; M3 makes it the product — **your desktop monitors floating in
space, usable daily.**

## Inputs (all settled, do not re-derive)

| Fact | Source |
|---|---|
| Render path: **CANTED cameras** — per-eye `eye_to_head` rotations + unfolded grow-expanded intrinsics windows. Never return to parallel+fold for rendering. Warp frame-invariant (`u_src=(q+G)/2G`) | M2 |
| Warp: per-channel direct POLY3, shared center, `0.5/(1+grow)` rescale, **no** r²-cutoff clamp, border-black, color mult | M2 / S3 |
| Gyro scale ±2000 dps over int16 (0.00106526 rad/s/LSB nominal, +0.07% measured LHR-1F8E25F1; enlyzeam confirm pending). Accel 4096 LSB/g | M2 |
| AHRS: Mahony head-frame, stillness-validated bias capture, rate+|a|-gated accel correction, gap reset, yaw-only recenter, `pose(predictSec)` gyro prediction (~20 ms) | M2 |
| Present: D3D12 + NVAPI DirectMode, native 5088×2544 @ 75 Hz; Beyond in direct mode does **not** enumerate as a desktop monitor (no capture feedback loop) | S2 / M1 |
| Calibration ingest: `sauna_calib` dev JSON path (`calib/hmd_config`); closed-form config→frustum fold (`tools/derive_projection_fold.py`) — no OpenVR dump needed | M2 |
| M1 resilience invariants: hot-unplug auto-recovery (present + both HID readers), prox doff/don panel self-management, free-run throttle, Watchman/Index `28DE:2300` disambiguation | M1 |
| Frame-source abstraction from day one (capture / remote-stream / test-pattern) | Locked decision 6 |

## Capture backend decision (amends locked decision 5's API note)

v1 capture backend = **DXGI Desktop Duplication** (`IDXGIOutputDuplication`), not
Windows.Graphics.Capture. Rationale: per-monitor full-output capture is exactly the v1
content model; frames arrive as GPU textures with dirty-rect metadata; no WGC
picker/border/consent baggage; lowest-latency classic path. Content decision (multi-monitor
desktop, layout mirrors OS arrangement) unchanged. WGC remains a fallback rung behind the
frame-source trait (per-window capture, if ever needed). PipeWire backend still the Linux
seam.

## Work breakdown (gated, in order; step 6 independent of 1–5, may interleave)

### 1. Frame source: desktop duplication, one monitor
`FrameSource` trait (decision 6) with the existing test card as one impl; new
`DuplicationSource`: `IDXGIOutputDuplication` on the primary monitor, own acquire thread,
latest-frame-wins mailbox into the render loop (no pacing sophistication — M4). D3D11
capture device → shared-handle interop into the D3D12 render device. Cursor composited
from DDA pointer-shape metadata (duplication frames exclude it). Mip chain on the desktop
texture — a 4K monitor at 2 m is heavily minified; unmipped it will shimmer.
Handle `DXGI_ERROR_ACCESS_LOST` (mode change, UAC secure desktop, fullscreen exclusive)
with a reacquire loop, M1-resilience style: never wedge, never kill tracking.
**Gate:** live desktop readable on the M2 quad, cursor visible and tracking, text legible
at 100% zoom; survives display-mode change, UAC prompt, and monitor power cycle.

### 2. Multi-screen layout (OS arrangement)
Enumerate outputs (DXGI adapter/output walk ≅ virtual-desktop rects); one quad + one
`DuplicationSource` per monitor. Map virtual-desktop pixel coordinates → world meters on a
plane at screen distance: single global scale knob (px→m), OS-relative positions preserved
(including gaps/offsets), flat plane v1 (no cylinder). Monitor hotplug → relayout.
**Gate:** two+ monitors appear side-by-side matching the OS arrangement; mouse crosses
between screens where they visually adjoin; layout survives a monitor unplug/replug.

### 3. App shell: global recenter hotkey + tray
`RegisterHotKey` global recenter (configurable, default something unclaimed); tray icon
(recenter / settings / quit); console window becomes dev-only (`--console`). Free-run flap
cosmetic fix lands here (display unplugged → quiet idle, no log spam).
**Gate:** recenter fires with any app focused, fullscreen game included; tray quit exits
clean (panels released, threads joined).

### 4. Safety + display policy
Burn-in is ours now: conservative persistence + brightness defaults applied at startup;
idle timeout (no head motion above threshold for N min) → panels off via MCU, instant
wake on motion/prox; doff/don keeps M1 behavior (tracking never resets). 75 Hz default,
90 Hz opt-in flag passthrough.
**Gate:** idle 10 min → panels dark; head motion → content back < 1 s, pose continuous;
doff/don unchanged from M1.

### 5. Settings + persistence
Plain desktop window (visible in-headset via capture — decision already in PLAN.md UX
defaults), backed by a config file (`%APPDATA%\sauna\config.json`): screen distance +
scale, predict-ms, neck model, hotkey, brightness/persistence, refresh, monitor
include/exclude. Live-apply on change; arrow-key live-tunes from M2 stay as dev shortcuts.
**Gate:** change a setting → see it live in-headset → restart → it stuck.

### 6. Native calibration read (Watchman flash) — tooling FIRST
Replaces the dev JSON path as default. Order is the gate:
1. **Backup tool**: full config dump over Tundra HID (port from vendored libsurvive),
   saved per-serial, run twice, byte-identical; archive both units' dumps off-machine.
2. **Parse + verify**: native-read values reproduce the archived dev-JSON numbers
   *exactly* for the attached unit (calib_check precedent), both units.
3. Only then: `sauna_calib` default flips to native read; JSON path kept as
   `--calib-json` override / dev scaffold.
**Gate:** cold machine, no JSON present, full pipeline up from flash read alone; numbers
byte-match the archived JSON on both LHR-1F8E25F1 and LHR-599F3B91.

### 7. Onboard IMU-cal store (write path) — strictly gated, may slip to M4
Goal: measured IMU cal (gyro_cal_vr output) written to a spare onboard config key so the
cal travels with the headset. Flash write = brick risk. Hard preconditions, in order:
step 6 backups archived for both units; confirm the Watchman protocol exposes a config
*write* at all (libsurvive/watchman docs — if unsupported or unclear, STOP); scratch-key
read-modify-write with read-back verify; power-cycle persistence check; factory keys
never touched. Fallback if write is a no-go: per-serial cal store on host
(`%APPDATA%\sauna\calib\<serial>.json`) — ships the same UX minus portability.
**Gate:** written key survives power cycle and reads back exact; all factory keys
byte-identical to the step-6 backup afterward.

## Parallel track: enlyzeam field validation (user-gated, not code-gated)

- M2 `spatial_light` eyes-in on enlyzeam (scp `_new.exe` dance; resident wired Index
  controller `28DE:2300` collision — disambiguation landed M1, verify it holds).
- `gyro_cal_vr.py` confirmation run there: decides whether 2000 dps/32768 ships as a
  universal constant. Constant → first-time setup needs zero IMU cal; per-unit → the
  gyro_cal_vr flow joins settings (step 5) or the onboard store (step 7).

## Carry-over engineering

- M1 resilience invariants extend to capture: `ACCESS_LOST` reacquire must not wedge
  render or tracking; AHRS gap reset unchanged.
- Pacing: blit-latest-frame only; capture-Hz vs panel-Hz pacing, latency budget, and
  paced prediction are M4. Keep the acquire thread structured so M4 can swap the mailbox
  for a paced queue.
- Color: assume SDR sRGB desktops v1; HDR/scRGB capture renders wrong — detect and warn,
  correctness is M4.
- Beyond-2e eye-tracked dynamic distortion: future comfort win, out of scope.

## Out of M3 scope

Pacing/latency proper, color/HDR correctness, install story (M4). Streaming bridge seam
only (decision 6 trait is the seam). Linux/PipeWire. 6DOF. IddCx virtual displays.
