diff --git a/docs/superpowers/plans/2026-06-03-single-viewpoint-render-v1.md b/docs/superpowers/plans/2026-06-03-single-viewpoint-render-v1.md new file mode 100644 index 0000000..a5e2dab --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-single-viewpoint-render-v1.md @@ -0,0 +1,336 @@ +# Single-Viewpoint Render — V1 (un-split) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (inline) or +> superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox +> (`- [ ]`) syntax for tracking. + +**Goal:** Make the render key its inside/outside decision, indoor root, and portal side-test on the +**collided-camera (viewer) cell + eye** — one viewpoint, retail-faithful — instead of splitting them +across the player cell (visibility/side-test) and the eye (projection only). + +**Architecture:** The camera-collision sweep (`PhysicsCameraCollisionProbe.SweepEye` → +`PhysicsEngine.ResolveWithTransition`) already produces the collided eye AND its swept cell +(`ResolveResult.CellId == sp.CurCellId`, retail `viewer_cell = sphere_path.curr_cell`). V1 surfaces +that cell (`RetailChaseCamera.ViewerCellId`) and routes the render through it + the eye, while +**lighting / `seen_outside` / `CurrCell` stay on the player** (retail `CellManager::ChangePosition`). +No AABB, no grace frames → the U.4c flap source is gone. The blue-hole fix (`CurrCell` player-only) +is untouched. + +**Tech Stack:** C# / .NET 10, Silk.NET. Spec: `docs/superpowers/specs/2026-06-03-single-viewpoint-render-design.md`. + +--- + +## File structure + +- `src/AcDream.App/Rendering/ICameraCollisionProbe.cs` — add `CameraSweepResult`; change `SweepEye` return. +- `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs` — return `(eye, r.CellId)`. +- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — new `ViewerCellId`; set it in `Update`. +- `src/AcDream.App/Rendering/GameWindow.cs` (~7150-7332) — render keys on viewer; lighting on player. +- `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` — update fakes; add `ViewerCellId` test. + +--- + +## Task 1: `CameraSweepResult` + `SweepEye` returns (Eye, ViewerCellId) + +**Files:** +- Modify: `src/AcDream.App/Rendering/ICameraCollisionProbe.cs` +- Modify: `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24-84` +- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs:154-156` (caller — keep behavior) +- Modify: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs:451-460,537-546` (fakes) + +This is a pure plumbing/type change — no new behavior. Verified by a green build + the (updated) existing tests; the new behavior is tested in Task 2. + +- [ ] **Step 1: Add the result type + change the interface** + +In `ICameraCollisionProbe.cs`, replace the `SweepEye` signature and add the struct: + +```csharp +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// Result of a camera spring-arm sweep: the collided eye position and the +/// cell the swept viewer-sphere ended in (retail viewer_cell = sphere_path.curr_cell, +/// update_viewer pc:92871). The cell is graph-tracked by the transition — no AABB, no grace frames. +public readonly record struct CameraSweepResult(Vector3 Eye, uint ViewerCellId); + +public interface ICameraCollisionProbe +{ + /// + /// Roll a collision sphere from to ; + /// return the stopped (non-penetrating) eye AND the cell it ended in. Returns + /// + unchanged when nothing blocks the + /// path or when is 0. + /// + CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId); +} +``` + +- [ ] **Step 2: Update `PhysicsCameraCollisionProbe`** + +In `PhysicsCameraCollisionProbe.cs`, change the method return type to `CameraSweepResult`. Early return becomes `return new CameraSweepResult(desiredEye, cellId);` (line ~27). The final return becomes: + +```csharp + Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius); + // ... existing [flap-sweep] probe block unchanged ... + return new CameraSweepResult(eye, r.CellId); +``` + +- [ ] **Step 3: Update the `RetailChaseCamera` call site (behavior-preserving)** + +In `RetailChaseCamera.cs:154-156`, extract the eye from the result (ViewerCellId is wired in Task 2): + +```csharp + Vector3 publishedEye = _dampedEye; + if (CameraDiagnostics.CollideCamera && CollisionProbe is not null) + publishedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId).Eye; +``` + +- [ ] **Step 4: Update the test fakes so the project compiles** + +In `RetailChaseCameraTests.cs`, both fakes return `CameraSweepResult`. `FakeProbe` (451-460): + +```csharp + private sealed class FakeProbe : ICameraCollisionProbe + { + public int Calls; + public Vector3 ReturnEye; + public uint ReturnCell; + public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + { + Calls++; + return new CameraSweepResult(ReturnEye, ReturnCell); + } + } +``` + +`ClampThenReleaseProbe` (537-546): + +```csharp + private sealed class ClampThenReleaseProbe : ICameraCollisionProbe + { + public int Calls; + public Vector3 ClampEye; + public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + { + Calls++; + return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId); + } + } +``` + +- [ ] **Step 5: Build + run the camera tests** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then +`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"` +Expected: build green; all existing RetailChaseCameraTests PASS (eye behavior unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/ICameraCollisionProbe.cs src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +git commit -m "refactor(render): SweepEye returns (Eye, ViewerCellId) — surface the swept viewer cell" +``` + +--- + +## Task 2: `RetailChaseCamera.ViewerCellId` (TDD) + +**Files:** +- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (new property + set in `Update`) +- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `RetailChaseCameraTests.cs` (in the camera-collision region): + +```csharp + [Fact] + public void Update_WithProbeAndFlagOn_ExposesSweptViewerCell() + { + CameraDiagnostics.CollideCamera = true; + var probe = new FakeProbe { ReturnEye = new Vector3(1f, 2f, 3f), ReturnCell = 0xA9B40170u }; + var cam = new RetailChaseCamera { CollisionProbe = probe }; + + cam.Update( + playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, + isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, + cellId: 0xA9B40171u, selfEntityId: 0x5); + + Assert.Equal(0xA9B40170u, cam.ViewerCellId); // the swept cell, not the player cell + } + + [Fact] + public void Update_FlagOff_ViewerCellFallsBackToPlayerCell() + { + CameraDiagnostics.CollideCamera = false; + try + { + var cam = new RetailChaseCamera { CollisionProbe = new FakeProbe { ReturnCell = 0xDEADu } }; + cam.Update( + playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, + isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, + cellId: 0xA9B40171u, selfEntityId: 0x5); + + Assert.Equal(0xA9B40171u, cam.ViewerCellId); // collision off → the passed player cell + } + finally { CameraDiagnostics.CollideCamera = true; } + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ViewerCell"` +Expected: FAIL — `RetailChaseCamera` has no `ViewerCellId` (compile error). + +- [ ] **Step 3: Implement** + +In `RetailChaseCamera.cs`, add the property (near `Position`): + +```csharp + /// The cell the collided viewer-sphere ended in (retail viewer_cell). Roots the + /// render mode + indoor visibility + the portal side-test. Equals the passed player cell when + /// camera collision is off. Read by GameWindow.OnRender. + public uint ViewerCellId { get; private set; } +``` + +In `Update`, replace the collision block (step 5b) so it captures both fields and always sets `ViewerCellId`: + +```csharp + Vector3 publishedEye = _dampedEye; + ViewerCellId = cellId; // default: the player cell (collision off / null probe) + if (CameraDiagnostics.CollideCamera && CollisionProbe is not null) + { + var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId); + publishedEye = swept.Eye; + ViewerCellId = swept.ViewerCellId; + } +``` + +- [ ] **Step 4: Run the tests** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"` +Expected: PASS (both new tests + all existing). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +git commit -m "feat(render): RetailChaseCamera.ViewerCellId — the swept viewer cell (retail viewer_cell)" +``` + +--- + +## Task 3: Route the render through the viewer; keep lighting on the player (integration + visual gate) + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (~7150-7202 lighting/root split; ~7315-7332 builder call; the `[flap-cam]` probe block ~7368) + +Not unit-testable (render integration). Verified by build + the `[flap-sweep]`/`[flap-cam]` probes + the user's eyes. + +- [ ] **Step 1: Split the lighting root (player) from the render root (viewer)** + +Replace the `visRootPos` / `physicsRoot` / `visibility` / `cameraInsideCell` / `rootSeenOutside` block +(~7162-7186, 7202) with: + +```csharp + // ── Lighting / seen_outside key on the PLAYER cell (retail CellManager::ChangePosition + // @ 0x4559b0): a sealed interior kills the sun; a seen_outside interior keeps it. ── + LoadedCell? playerRoot = null; + if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pCell + && _cellVisibility.TryGetCell(pCell.Id, out var pReg)) + playerRoot = pReg; + bool playerSeenOutside = playerRoot?.SeenOutside ?? true; + + // ── Render keys on the VIEWER (collided camera) cell + eye — ONE viewpoint (retail + // RenderNormalMode → DrawInside(viewer_cell) @92675; InitCell side-test vs + // viewer.viewpoint @432991). The viewer cell is the camera-collision sweep's swept + // cell (RetailChaseCamera.ViewerCellId): graph-tracked, no AABB/grace → no U.4c flap. + // Falls back to the player cell for the non-default legacy/debug camera paths. ── + uint viewerCellId = + (_playerMode && _retailChaseCamera is not null + && AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera) + ? _retailChaseCamera.ViewerCellId + : (_physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u); + Vector3 viewerEyePos = camPos; // the collided eye now drives the side-test AND the projection + LoadedCell? viewerRoot = null; + if ((viewerCellId & 0xFFFFu) >= 0x0100u && _cellVisibility.TryGetCell(viewerCellId, out var vReg)) + viewerRoot = vReg; + var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos); + bool cameraInsideCell = visibility?.CameraCell is not null; + bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; // render's seen_outside (terrain-thru-door) +``` + +Then change the lighting predicate (was `cameraInsideCell && !rootSeenOutside`) to key on the **player**: + +```csharp + // Lighting: kill the sun only when the PLAYER is in a sealed interior (dungeon), not the + // camera. (Render seen_outside above is separate — it gates terrain-through-the-door.) + bool playerInsideCell = playerRoot is not null && !playerSeenOutside; +``` + +- [ ] **Step 2: Point the portal builder at the viewer eye** + +At the `PortalVisibilityBuilder.Build` call (~7326-7330), the side-test position becomes the eye: + +```csharp + pvFrame = PortalVisibilityBuilder.Build( + clipRoot, + viewerEyePos, // U.4c split removed: side-test from the collided eye, not the player + id => _cellVisibility.TryGetCell(id, out var c) ? c : null, + envCellViewProj); +``` + +(`clipRoot = visibility?.CameraCell` at ~7315 now resolves to the viewer cell — no edit needed there.) + +- [ ] **Step 3: Extend the `[flap-cam]` probe to log viewer vs player** + +In the `ProbeFlapEnabled` block (~7368), add `viewerCell=0x{viewerCellId:X8}` and +`playerCell=0x{(_physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0):X8}` to the emitted line so the +viewer-cell-vs-player-cell relationship (and any oscillation) is visible in the capture. + +- [ ] **Step 4: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: green. + +- [ ] **Step 5: Full test suite (no regressions)** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and +`dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: App green; Core = the documented baseline (1295 pass / 5 pre-existing fails — 2 BSPStepUp + 3 door-collision), no NEW failures. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(render): V1 — render keys on the viewer cell+eye; lighting stays on the player (un-split)" +``` + +- [ ] **Step 7: VISUAL GATE (user)** + +Launch (PowerShell, `ACDREAM_PROBE_FLAP=1 ACDREAM_PROBE_CELL=1`). Capture `[flap-sweep]`/`[flap-cam]` +(viewerCell vs playerCell). User verifies, walking from outside → doorway → inside a Holtburg cottage: +- The doorway-straddle **void is gone** — when the camera is outside, the outdoor world renders (cottage = solid exterior); inside, the interior renders; the transition follows the **camera**. +- **No flap** standing or walking inside (viewerCell stable in the probe — the key risk). +- Inside-looking-out still correct. + +(Outside the cottage = solid exterior at this gate; interior-through-door is V2. Blue floor is V3.) + +--- + +## After V1 + +V2 (DrawPortal / outside-looking-in) and V3 (floor seal) get their own plans once the V1 gate passes +(spec §5, §6). Do not start them before the V1 visual gate. + +## Self-review + +- **Spec coverage:** §3 keystone → Task 1+2 (SweepEye returns the cell; ViewerCellId). §4 un-split → + Task 3 (render on viewer, lighting on player, side-test from the eye, probe). §2 "lighting on player" + → Task 3 Step 1. V2/V3 explicitly deferred. ✓ +- **Placeholder scan:** Step 3 of Task 3 (probe format) is a diagnostic line described, not exact code — + acceptable (non-load-bearing; the executor reads the existing block). All load-bearing steps have code. ✓ +- **Type consistency:** `CameraSweepResult(Vector3 Eye, uint ViewerCellId)` used identically in Task 1 + (def + probe + fakes) and Task 2 (`swept.Eye`, `swept.ViewerCellId`); `ViewerCellId` property name + consistent across Task 2 + Task 3. `ResolveResult.CellId` confirmed = `sp.CurCellId`. ✓