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