diff --git a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs
index 0f05c9a9..2ad67136 100644
--- a/src/AcDream.App/Rendering/ICameraCollisionProbe.cs
+++ b/src/AcDream.App/Rendering/ICameraCollisionProbe.cs
@@ -21,9 +21,11 @@ public interface ICameraCollisionProbe
///
/// Roll a collision sphere from to
/// ; return the position it reaches without
- /// penetrating geometry AND the cell it ended in. Returns
- /// + unchanged
- /// when nothing blocks the path or when is 0.
+ /// penetrating geometry AND the cell it ended in. Mirrors retail
+ /// SmartBox::update_viewer: when is indoor the
+ /// sweep's start cell is seated at the pivot, and when there is no start cell or
+ /// the sweep fails the eye snaps to (retail
+ /// set_viewer(player_pos), viewer cell null).
///
- CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
+ CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos);
}
diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
index 4dc98c83..4bb194b2 100644
--- a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
+++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
@@ -21,22 +21,35 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics;
- public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
- // No starting cell → nothing to sweep against; keep the desired eye + cell.
- if (cellId == 0) return new CameraSweepResult(desiredEye, cellId);
+ // update_viewer: player->cell == 0 → set_viewer(player_pos, 1), viewer_cell = null
+ // (acclient_2013_pseudo_c.txt:92775). No cell to sweep against → snap to the player.
+ if (cellId == 0) return new CameraSweepResult(playerPos, 0u);
- // SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius)
- // (the player foot-capsule convention). Retail's viewer_sphere center is
- // (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER
- // travel pivot→eye, then add it back to the swept stop position.
+ // === Start cell (pc:92824-92844) ===
+ // Indoor (objcell_id >= 0x100): seat the sweep's start cell at the head-PIVOT via
+ // CPhysicsObj::AdjustPosition (pc:92832) — the head can sit in a different cell than
+ // the feet (the cellar lip: feet in the low connector, head up at floor level). On
+ // failure retail falls back to player->cell. Outdoor: cell = player->cell (no AdjustPosition).
+ uint startCell = cellId;
+ if ((cellId & 0xFFFFu) >= 0x0100u)
+ {
+ var (pivotCell, found) = _physics.AdjustPosition(cellId, pivot);
+ if (found) startCell = pivotCell;
+ }
+
+ // === Sweep the viewer_sphere pivot → sought-eye from the start cell (pc:92860-92868) ===
+ // SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) (the player
+ // foot-capsule convention). Retail's viewer_sphere center is (0,0,0), so shift the
+ // path DOWN by the radius to make the SPHERE CENTER travel pivot→eye, then add it back.
Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius);
Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius);
var r = _physics.ResolveWithTransition(
currentPos: begin,
targetPos: end,
- cellId: cellId,
+ cellId: startCell,
sphereRadius: ViewerSphereRadius,
sphereHeight: 0f, // single sphere (no head sphere)
stepUpHeight: 0f, // no step-up for a camera
@@ -58,32 +71,34 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
- // Phase U.4c spike apparatus (THROWAWAY — strip with ACDREAM_PROBE_FLAP).
- // The post-fix [flap-cam] capture shows the eye flying to full chase distance
- // (eyeInRoot=n ~90%) in cells like 0xA9B40174/0175 — i.e. this sweep is not
- // stopping it. This line answers WHY, the fork that picks the primary residual
- // fix: pulledIn≈0 with resolved=Y bsp=ok ⇒ the sweep ran but found NOTHING in
- // that cell (space genuinely open, or wall geometry the per-cell sweep can't
- // reach → clip-robustness is primary); resolved=n / bsp=nobsp/noroot ⇒ collision
- // can't even run there (cell/BSP not loaded → camera-collision reliability is
- // primary); pulledIn large ⇒ collision IS engaging (eye leaving is then expected
- // through an opening). Paired per-frame with the builder's [flap]/[flap-cam].
+ // [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the
+ // builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the
+ // player feet cell); ok = the sweep found a valid position (find_valid_position != 0).
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
{
- var cp = _physics.DataCache?.GetCellStruct(cellId);
+ var cp = _physics.DataCache?.GetCellStruct(startCell);
string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok");
float desiredBack = Vector3.Distance(pivot, desiredEye);
float eyeBack = Vector3.Distance(pivot, eye);
System.Console.WriteLine(
- $"[flap-sweep] cell=0x{cellId:X8} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
+ $"[flap-sweep] cell=0x{cellId:X8} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
$"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " +
- $"collNormValid={r.CollisionNormalValid}");
+ $"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}");
}
- // 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);
+ // success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871).
+ // Graph-tracked, no AABB/grace.
+ if (r.Ok) return new CameraSweepResult(eye, r.CellId);
+
+ // === Fallback 1 (pc:92878-92883): AdjustPosition at the sought eye ===
+ // The sweep found no valid position; try to seat the eye at its own cell.
+ // (Seed with the player cell — acdream's camera doesn't track the sought-eye's
+ // cell separately; the eye is near the player so its stab-list is the right one.)
+ var (eyeCell, eyeFound) = _physics.AdjustPosition(cellId, desiredEye);
+ if (eyeFound) return new CameraSweepResult(desiredEye, eyeCell);
+
+ // === Fallback 2 (pc:92886-92887): set_viewer(player_pos), viewer_cell = null ===
+ return new CameraSweepResult(playerPos, 0u);
}
/// 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 d05b907a..614935be 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -166,7 +166,7 @@ public sealed class RetailChaseCamera : ICamera
ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
- var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
+ var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 68d5d4f6..4cc5005a 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -965,7 +965,8 @@ public sealed class PhysicsEngine
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
partialOnGround,
collisionNormalValid,
- collisionNormal);
+ collisionNormal,
+ Ok: false); // Render Residual A — the sweep failed (find_valid_position == 0)
}
// A6.P3 #98 capture: emit one JSON Lines record per player call,
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index 63d18452..c9c6f2f1 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -27,4 +27,11 @@ public readonly record struct ResolveResult(
bool CollisionNormalValid = false,
/// Outward surface normal of the wall the sphere hit. Used
/// by the velocity-reflection step. Pointing away from the wall.
- Vector3 CollisionNormal = default);
+ Vector3 CollisionNormal = default,
+ /// Render Residual A — whether the underlying
+ /// FindTransitionalPosition found a valid position (retail
+ /// find_valid_position != 0, pc:273898). False when the sweep had no
+ /// start cell or was immediately stuck. The camera SweepEye reads this
+ /// to trigger SmartBox::update_viewer's fallbacks. Default true
+ /// so existing callers are unaffected.
+ bool Ok = true);
diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
index d0fb3b66..02b8f6eb 100644
--- a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
@@ -140,7 +140,8 @@ public class CameraCollisionIndoorTests
pivot: PivotWorld,
desiredEye: DesiredEye,
cellId: IndoorCellId,
- selfEntityId: 0u).Eye;
+ selfEntityId: 0u,
+ playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).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/CameraCollisionUpdateViewerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs
new file mode 100644
index 00000000..a5dae8ce
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// Render Residual A — the verbatim SmartBox::update_viewer
+/// (acclient_2013_pseudo_c.txt:92761) orchestration in
+/// : seat the sweep's start
+/// cell at the head-PIVOT when indoor (via AdjustPosition →
+/// find_visible_child_cell), and snap the eye to the player when there is
+/// no start cell / the sweep fails.
+///
+public class CameraCollisionUpdateViewerTests
+{
+ private const uint FeetCellId = 0xA9B40174u; // cellar (low ≥ 0x0100 → indoor), interior Z ≤ 94
+ private const uint RoomCellId = 0xA9B40171u; // cottage floor above, interior Z ≥ 94, in feet cell's stab list
+ private const uint LandblockId = 0xA9B40000u;
+
+ ///
+ /// The cellar-lip case (user point 3): the player's FEET are in the low cellar
+ /// cell, but the head-PIVOT is up at cottage-floor level in a different cell.
+ /// Retail seats the sweep at the pivot's cell (AdjustPosition, pc:92832),
+ /// so the viewer cell is the room above — NOT the feet cell. Without the start-cell
+ /// port the sweep stays in the feet cell.
+ ///
+ [Fact]
+ public void SweepEye_IndoorPivotInCellAboveFeet_SeatsStartAtPivotCell()
+ {
+ var engine = BuildTwoCellEngine();
+ var probe = new PhysicsCameraCollisionProbe(engine);
+
+ var feet = new Vector3(0f, 0f, 93f); // in the feet cell (Z ≤ 94)
+ var pivot = new Vector3(0f, 0f, 94.5f); // head, up in the room cell (Z ≥ 94)
+ var eye = new Vector3(0f, 3f, 95.5f); // behind + up, still in the room region, no wall
+
+ var result = probe.SweepEye(pivot, eye, cellId: FeetCellId, selfEntityId: 0u, playerPos: feet);
+
+ Assert.Equal(RoomCellId, result.ViewerCellId);
+ }
+
+ ///
+ /// Retail update_viewer snaps the viewer to the player position when the
+ /// player has no cell (pc:92775) and as fallback 2 when the sweep fails
+ /// (pc:92886): set_viewer(player_pos); viewer_cell = null.
+ ///
+ [Fact]
+ public void SweepEye_NoStartCell_SnapsToPlayer()
+ {
+ var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
+
+ var player = new Vector3(7f, 8f, 9f);
+ var result = probe.SweepEye(
+ pivot: new Vector3(7f, 8f, 10.5f), desiredEye: new Vector3(7f, 13f, 11f),
+ cellId: 0u, selfEntityId: 0u, playerPos: player);
+
+ Assert.Equal(player, result.Eye);
+ Assert.Equal(0u, result.ViewerCellId);
+ }
+
+ // ── fixture ────────────────────────────────────────────────────────────
+
+ private static PhysicsEngine BuildTwoCellEngine()
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ // Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals
+ // (so the collision sweep cannot transit to the room — the start cell is decisive).
+ cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorZAtMost(94f), new uint[] { RoomCellId }));
+ // Room cell: interior Z ≥ 94, no walls, no portals.
+ cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty()));
+
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
+ engine.AddLandblock(
+ landblockId: LandblockId,
+ terrain: new TerrainSurface(heights, heightTable),
+ cells: Array.Empty(),
+ portals: Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ return engine;
+ }
+
+ private static CellBSPNode InteriorZAtMost(float boundary) => new()
+ {
+ SplittingPlane = new Plane(new Vector3(0f, 0f, -1f), boundary), // dist = boundary − Z ≥ 0 ⇔ Z ≤ boundary
+ PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
+ };
+
+ private static CellBSPNode InteriorZAtLeast(float boundary) => new()
+ {
+ SplittingPlane = new Plane(new Vector3(0f, 0f, 1f), -boundary), // dist = Z − boundary ≥ 0 ⇔ Z ≥ boundary
+ PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
+ };
+
+ private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new()
+ {
+ BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree { Root = cellBspRoot },
+ Portals = Array.Empty(),
+ PortalPolygons = new Dictionary(),
+ VisibleCellIds = new HashSet(visibleCellIds),
+ };
+}
diff --git a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
index c756c94c..dec7fd44 100644
--- a/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
@@ -27,18 +27,20 @@ public class PhysicsCameraCollisionProbeTests
Assert.Equal(p.Z, back.Z, 5);
}
- // cellId == 0 means "no starting cell" — the probe must short-circuit and
- // return the desired eye without touching the engine.
+ // cellId == 0 means "no starting cell" — retail update_viewer snaps the viewer
+ // to the player position (set_viewer(player_pos), viewer_cell = null; pc:92775),
+ // so the probe must short-circuit to playerPos without touching the engine.
[Fact]
- public void SweepEye_NoStartingCell_ReturnsDesiredEyeUnchanged()
+ public void SweepEye_NoStartingCell_SnapsToPlayer()
{
- var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
- var pivot = new Vector3(0f, 0f, 1.5f);
- var eye = new Vector3(-2f, 0f, 2.2f);
+ var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
+ var pivot = new Vector3(0f, 0f, 1.5f);
+ var eye = new Vector3(-2f, 0f, 2.2f);
+ var player = new Vector3(0f, 0f, 0f);
- var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0);
+ var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0, playerPos: player);
- Assert.Equal(eye, result.Eye);
- Assert.Equal(0u, result.ViewerCellId); // cellId==0 → returned unchanged
+ Assert.Equal(player, result.Eye);
+ Assert.Equal(0u, result.ViewerCellId); // cellId==0 → snap to player, null viewer cell
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index 989dcb2b..6e7f8c92 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -453,7 +453,7 @@ public class RetailChaseCameraTests
public int Calls;
public Vector3 ReturnEye;
public uint ReturnCell;
- public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Calls++;
return new CameraSweepResult(ReturnEye, ReturnCell);
@@ -571,7 +571,7 @@ public class RetailChaseCameraTests
{
public int Calls;
public Vector3 ClampEye;
- public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Calls++;
return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId);
diff --git a/tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs
new file mode 100644
index 00000000..d8dc8db2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/ResolveResultOkTests.cs
@@ -0,0 +1,45 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Render Residual A — surfaces the
+/// FindTransitionalPosition return (retail find_valid_position != 0,
+/// pc:273898) so the camera SweepEye can trigger SmartBox::update_viewer's
+/// fallbacks when the sweep fails. Default true keeps every existing caller
+/// (which never reads it) unaffected.
+///
+public class ResolveResultOkTests
+{
+ [Fact]
+ public void NoStartCell_ReportsNotOk()
+ {
+ var engine = new PhysicsEngine();
+
+ // cellId == 0 → FindTransitionalPosition returns false at its first guard
+ // (TransitionTypes.cs:665) → the sweep did not find a valid position.
+ var r = engine.ResolveWithTransition(
+ currentPos: Vector3.Zero, targetPos: new Vector3(1f, 0f, 0f), cellId: 0u,
+ sphereRadius: 0.3f, sphereHeight: 0f, stepUpHeight: 0f, stepDownHeight: 0f,
+ isOnGround: false);
+
+ Assert.False(r.Ok);
+ }
+
+ [Fact]
+ public void ZeroMovementValidCell_ReportsOk()
+ {
+ var engine = new PhysicsEngine();
+
+ // Zero offset with a non-zero start cell → FindTransitionalPosition's
+ // zero-step path returns true (TransitionTypes.cs:718-723), no geometry needed.
+ var r = engine.ResolveWithTransition(
+ currentPos: new Vector3(5f, 5f, 5f), targetPos: new Vector3(5f, 5f, 5f), cellId: 0xA9B40001u,
+ sphereRadius: 0.3f, sphereHeight: 0f, stepUpHeight: 0f, stepDownHeight: 0f,
+ isOnGround: false);
+
+ Assert.True(r.Ok);
+ }
+}