diff --git a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs
index 52fad70..0f05c9a 100644
--- a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs
+++ b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs
@@ -2,9 +2,17 @@ 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 — so it
+/// is the robust per-frame "which cell is the camera in?" answer that roots the render.
+///
+public readonly record struct CameraSweepResult(Vector3 Eye, uint ViewerCellId);
+
///
/// Sweeps a small sphere from the camera pivot (player head) toward the
-/// desired eye and returns the stopped (non-penetrating) eye. The seam that
+/// desired eye and returns the stopped (non-penetrating) eye + its cell. The seam that
/// lets collide its eye without depending on
/// the physics engine directly (and stay unit-testable with a fake).
///
@@ -13,8 +21,9 @@ public interface ICameraCollisionProbe
///
/// Roll a collision sphere from to
/// ; return the position it reaches without
- /// penetrating geometry. Returns unchanged
+ /// penetrating geometry AND the cell it ended in. Returns
+ /// + unchanged
/// when nothing blocks the path or when is 0.
///
- Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
+ CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
}
diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
index 8f94e6e..4dc98c8 100644
--- a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
+++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
@@ -21,10 +21,10 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics;
- public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
- // No starting cell → nothing to sweep against; keep the desired eye.
- if (cellId == 0) return desiredEye;
+ // No starting cell → nothing to sweep against; keep the desired eye + cell.
+ if (cellId == 0) return new CameraSweepResult(desiredEye, cellId);
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius)
// (the player foot-capsule convention). Retail's viewer_sphere center is
@@ -80,7 +80,10 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
$"collNormValid={r.CollisionNormalValid}");
}
- return eye;
+ // Phase W single-viewpoint V1 (2026-06-03): surface the swept cell (r.CellId =
+ // sp.CurCellId) as the viewer cell — retail viewer_cell = sphere_path.curr_cell
+ // (update_viewer pc:92871). Graph-tracked, no AABB/grace → the U.4c flap source is gone.
+ return new CameraSweepResult(eye, r.CellId);
}
/// Eye/pivot point → InitPath path point (subtract the sphere-center offset).
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index 79de8f4..993999a 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -153,7 +153,7 @@ public sealed class RetailChaseCamera : ICamera
// leave _dampedEye as the clean, uncollided sought position.
Vector3 publishedEye = _dampedEye;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
- publishedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
+ publishedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId).Eye;
// 6. Publish renderer surface (from the collided eye; rotation stays the
// smoothly-damped look direction toward the pivot).
diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
index 0d859c6..d0fb3b6 100644
--- a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
@@ -140,7 +140,7 @@ public class CameraCollisionIndoorTests
pivot: PivotWorld,
desiredEye: DesiredEye,
cellId: IndoorCellId,
- selfEntityId: 0u);
+ selfEntityId: 0u).Eye;
// The eye should be stopped before the exterior wall at Y=4.0.
// Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7.
diff --git a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
index 5cb7b0c..c756c94 100644
--- a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
@@ -38,6 +38,7 @@ public class PhysicsCameraCollisionProbeTests
var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0);
- Assert.Equal(eye, result);
+ Assert.Equal(eye, result.Eye);
+ Assert.Equal(0u, result.ViewerCellId); // cellId==0 → returned unchanged
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index 8a3063b..0e58afb 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -452,10 +452,11 @@ public class RetailChaseCameraTests
{
public int Calls;
public Vector3 ReturnEye;
- public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ public uint ReturnCell;
+ public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
Calls++;
- return ReturnEye;
+ return new CameraSweepResult(ReturnEye, ReturnCell);
}
}
@@ -538,10 +539,10 @@ public class RetailChaseCameraTests
{
public int Calls;
public Vector3 ClampEye;
- public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
Calls++;
- return Calls == 1 ? ClampEye : desiredEye;
+ return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId);
}
}