# M2 — Tracked + Warped

Build-order step 2 (PLAN.md): AHRS pose + distortion/canting/IPD pipeline — **one virtual
screen fixed in space, fusion-correct binocular view.** M1 gave us the chassis (IMU reader,
presenter, prox, hot-unplug resilience); M2 makes it a spatial display.

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

| Fact | Source |
|---|---|
| `DISTORT_POLY3` = **direct** polynomial `d = 1 + k1·r² + k2·r⁴ + k3·r⁶`, per-channel, shared center per eye | S3 FINDINGS |
| Output rescale `0.5/(1+grow_for_undistort)`, **no** `undistort_r2_cutoff` clamp | S3 (0.0002 px max err) |
| Center conventions: `center_x = −K[0][2]`, `center_y = +K[1][2]` | S3 |
| Driver folds canting into asymmetric `GetProjectionRaw`; `eye_to_head` reported IDENTITY ± ipd/2 (`parallel_render_cameras: true`). Fitted per-eye offset/scale archived as ground truth. **Replicate the fold OR render genuinely canted cameras with consistent warp math — never mix.** | S3 FINDINGS |
| IPD translation = user setting (`driver_BeyondSteamVR.ipd_mm`), not config `ipd.default_mm` | S3 |
| IMU: 4096 LSB/g accel; ~994 Hz unique; ~7/s single-seq device-side skips (baseline, not loss); timecode 48 MHz uint32 wraps ~89.5 s | S1 + M1 field |
| **Gyro scale UNVERIFIED** (~0.001 rad/s/LSB plausible) | S1 open item |
| v=0 = top of panel; left half = left eye | S3 eyes-in |

## Work breakdown (gated, in order)

### 1. Calibration ingest (dev scaffold)
JSON import of the per-unit config (SteamVR lighthouse cache / `downloadconfig` dump) per
locked decision 3 — native Watchman flash read is M3. Parse: per-channel POLY3 coeffs +
centers per eye, `grow_for_undistort`, intrinsics / fitted projection ground truth,
`eye_to_head`, ipd default, `imu` block (`plus_x`, `plus_z`, `position`, gyro/acc bias+scale),
`display_color_mult`. Serial cross-check against the attached unit (enlyzeam cache holds ~47
stale factory configs — verify.py precedent).
**Gate:** parsed values reproduce the S3 evaluator's numbers for LHR-599F3B91 exactly.

### 2. Gyro scale verification (closes S1 open item)
Controlled-rotation test: place headset flat, rotate exactly 90°/180°/360° about one axis
(table edge jig), integrate raw gyro over the move, solve scale. Repeat per axis. Candidate
priors: 1/1024 ≈ 0.000977, 0.001 rad/s/LSB. Config `imu` block may already hold per-axis
scale — verify against measurement, don't trust blind.
**Gate:** integrated angle within ~2% of physical rotation on all axes.

### 3. AHRS (own Mahony-style, locked decision 4)
Gyro integration in IMU frame → quaternion; accel gravity correction (Mahony proportional
term, conservative gain — no magnetometer); config biases/scales applied; IMU frame → head
frame via `imu.plus_x`/`plus_z`/`position`; timecode-wrap-safe dt. Recenter = yaw zeroing on
keypress (console key now, global hotkey M3). Yaw drift accepted (recenter UX owns it).
**Gate:** head-locked logging tool shows stable gravity vector, quiet rest drift
(< ~1°/min pitch/roll), recenter works; rotation sense matches physical motion on all axes.

### 4. Render core (D3D12, per-eye offscreen)
Scene: one textured quad (test card) fixed in world. Per-eye view from AHRS pose +
±ipd/2 translation; projection from the per-eye fold ground truth (start with fitted
offset/scale as data — the parallel-camera path; canted-camera variant only if it fails).
Render each eye to an offscreen target at panel-half resolution.
**Gate:** on-desktop debug window (or frame dump) shows correct stereo pair geometry; quad
counter-rotates against headset motion.

### 5. Distortion pass
Full-screen warp (fragment pass over the DM surface): for each panel pixel → lens-space r →
per-channel direct POLY3 → three source UVs → sample eye texture per channel (distortion +
lateral CA in one pass), rescale, `display_color_mult`.
**Gate A (static, no headset motion needed):** feed the S3 grid as the eye texture with
identity pose and pixel-diff the live pipeline output against `make_warped_grid.py`'s
pre-warped image — must match to generator-aliasing level.
**Gate B (eyes-in):** rectilinear through the lens, CA stable, both eyes.

### 6. First spatial light (the M2 exit)
All pieces live: virtual screen fixed in space.
**Exit criteria (eyes-in, user):** screen stays put under yaw/pitch/roll; no inversion or
mirroring; stereo fuses comfortably at screen distance (the convergence check S3 couldn't
do); no swimming at rest; recenter snaps it back ahead.

## Carry-over engineering

- Pose sampling: latest IMU sample at render start (no prediction in M2; latency comp is M4
  pacing work).
- Keep M1 resilience invariants: device reconnect must not wedge the AHRS (reset filter on
  IMU gap > ~0.5 s); prox doff → keep tracking, presenter untouched (panels self-manage).
- Free-run flap while display unplugged: cosmetic, defer to app shell (M3).
- Frame-convention sign errors are the classic time sink — gate 3's per-axis physical check
  is mandatory before touching gate 4.

## Out of M2 scope

Desktop capture (M3 — test card/static image only), multi-screen, settings UI, native
calibration read, global hotkeys, persistence/brightness policy, pacing/latency tuning (M4).
