feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks)

Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye
now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end:

- Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT
  via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector,
  head up at floor level); outdoor keeps the player cell.
- Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags).
- Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell.
- Fallback 1 (pc:92878): AdjustPosition(sought_eye).
- Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This
  also makes cellId==0 faithful (was returning the desired eye; retail snaps to
  player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye.

Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail
find_valid_position != 0, pc:273898) so SweepEye knows when to fall back.

TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2,
SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto-
advance feet->room, so the pivot-seated start cell is genuinely decisive. Core
1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression.

Per the live-capture finding, the visible payoff is the cellar-corner (point 3);
the cottage-room bluish void stays for residual C. Spec:
docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-05 11:10:32 +02:00
parent 5177b54bbe
commit 9e70031bc6
10 changed files with 234 additions and 44 deletions

View file

@ -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);
}
/// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>