# 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`. ✓