diag(render): camera-collision indoor non-engagement — RED test + diagnosis

Root cause (b): ShadowObjectRegistry.GetNearbyObjects (line 480) returns early
when primaryCellId is an indoor cell, skipping the outdoor radial sweep that
contains the landblock-baked cottage exterior-shell GfxObj. The issue-#98 fix
that prevents the player's head sphere from being capped by the cottage floor
also prevents the IsViewer camera sweep from finding the exterior building shell.
Result: camera passes through exterior walls unimpeded, driving the residual
transparent-walls symptom after the U.4c flap fix.

Evidence: live capture shows eyeInRoot=n ~90% of frames, eye-player distance
3.43m (full chase, no pull-in). RED test deterministically reproduces: synthetic
indoor cell (0xA9B40175) + exterior GfxObj registered at cellScope=0; probe
SweepEye returns pulledIn=0.0000m (full eye distance Y=5.0, wall at Y=4.0).

Fix design: exempt IsViewer from the indoor-primary early-return gate in
GetNearbyObjects — retail's find_obj_collisions (named-retail :308918) has no
indoor/outdoor cell gate; the acdream fix is correct only for IsPlayer.

Apparatus committed:
- tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs (RED test)
- docs/research/2026-05-31-camera-collision-indoor-diagnosis.md (findings + design)
- PhysicsCameraCollisionProbe.cs [flap-sweep] diagnostic retained (U.4c spike)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-31 18:02:37 +02:00
parent 95b6874c12
commit 3066460370
3 changed files with 520 additions and 1 deletions

View file

@ -0,0 +1,323 @@
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;
/// <summary>
/// Phase U — RED diagnostic test for the camera-collision indoor non-engagement bug.
///
/// <para>
/// Root cause (b): when the camera sphere is in an indoor cell, <see cref="ShadowObjectRegistry.GetNearbyObjects"/>
/// returns early at line 480 (<c>if ((primaryCellId &amp; 0xFFFF) >= 0x0100) return;</c>),
/// skipping the outdoor radial sweep. The cottage exterior-shell GfxObj is registered
/// with <c>cellScope=0</c> (landblock-wide, outdoor) — it lives in the outdoor per-cell
/// shadow lists. With the indoor-primary gate active, the camera sweep (which uses
/// <see cref="ObjectInfoState.IsViewer"/> not <see cref="ObjectInfoState.IsPlayer"/>) never
/// finds the exterior shell while its sphere center is inside the indoor CellBSP volume.
/// Once the sphere center exits the CellBSP boundary (<see cref="PhysicsEngine.ResolveCellId"/>
/// falls through to outdoor), the outdoor sweep runs — but by then the sphere may have
/// already crossed the exterior wall polygon's front face (going in the same direction).
/// </para>
///
/// <para>
/// Evidence from post-fix live capture (<c>u4c-fix.log</c>): <c>eyeInRoot=n</c> ~90%
/// of frames; eye-player distance mean 3.43 m (full/zoomed chase, NOT pulled in).
/// The <c>[flap-sweep]</c> diagnostic in <see cref="PhysicsCameraCollisionProbe.SweepEye"/>
/// was designed to confirm this: <c>bsp=ok pulledIn≈0</c> means the cell is loaded with
/// a valid BSP but the sweep returns full eye distance, confirming the exterior shell is
/// not reached from the indoor context.
/// </para>
///
/// <para>
/// The issue #98 fix (2026-05-24) deliberately gates the outdoor sweep when the primary
/// cell is indoor — this is CORRECT for the player (prevents the cottage floor from
/// capping the player's head sphere). But it is WRONG for the camera probe
/// (<see cref="ObjectInfoState.IsViewer"/>), which needs to find the exterior building
/// shell to implement retail's <c>SmartBox::update_viewer</c> spring-arm pull-in.
/// </para>
///
/// <para>
/// Fixture gap: the actual residual cells (0xA9B40174/0175, main-floor cottage) are not
/// in the fixture set (the issue-98 fixtures cover 0xA9B4014X, a different cellar
/// cottage). This test uses a fully synthetic setup to prove the mechanism identically —
/// the issue #98 gate fires on any indoor primary cell id.
/// </para>
///
/// <para>Diagnosis doc: <c>docs/research/2026-05-31-camera-collision-indoor-diagnosis.md</c>.</para>
/// </summary>
public class CameraCollisionIndoorTests
{
// ── Geometry constants ─────────────────────────────────────────────────
// Room interior: player at world (0, 1, 94). Pivot = (0, 1, 95.5).
// Camera sweeps backward (+Y) and slightly upward.
// The EXTERIOR WALL GfxObj is at Y = 4.0 (just outside the room's back boundary
// at Y = 3.5). The interior CellBSP covers Y ∈ [-2, 3.5].
//
// Desired eye: Y = 5.0 — past the exterior wall.
//
// Expected: sweep stops at the exterior wall (pulledIn ≥ MinExpectedPullIn = 0.5 m).
// Actual: sweep reaches Y = 5.0 (pulledIn ≈ 0) because GetNearbyObjects skips the
// outdoor sweep when primaryCellId is indoor, so the GfxObj exterior wall is not
// tested while the sphere is inside the CellBSP volume. After the sphere crosses the
// CellBSP boundary (Y > 3.5 + ~0.3 = 3.8), ResolveCellId returns an outdoor cell
// and the outdoor sweep IS run — but the exterior wall is at Y = 4.0 and the sphere
// center is approaching from Y = 3.8 toward +Y, so the exterior wall polygon (with
// inward normal = -Y) is hit from its BACK FACE. If the wall polygon is one-sided
// (CullMode.Clockwise from the outer face), the back-face hit is suppressed and the
// sphere passes through. The net result is no stop.
private const uint IndoorCellId = 0xA9B40175u; // low 16 bits 0x0175 ≥ 0x0100 → indoor
private const uint LandblockId = 0xA9B40000u;
// Player head-pivot in world space.
private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f);
// Desired eye: backward and slightly above pivot.
// Goes from Y=1 to Y=5, passing through the exterior wall at Y=4.0.
private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f);
// Exterior wall GfxObj position: at Y=4.0, normal facing INTO the room (-Y).
// When seen from outside, the front face has +Y normal (outward). When seen
// from inside (camera going toward +Y), the facing side is the back face.
// The wall is registered with cellScope=0 (landblock-wide, outdoor shadow list).
private const float ExteriorWallY = 4.0f;
// The sphere should be stopped at approximately Y = ExteriorWallY - ViewerSphereRadius.
// Pulled-in distance ≥ MinExpectedPullIn.
private const float MinExpectedPullIn = 0.5f;
// CellBSP inner boundary: sphere is considered "inside" the cell when Y ≤ 3.5.
// Once the sphere center crosses Y = 3.5 + (radius + 0.01) ≈ 3.81, ResolveCellId
// will classify it as outdoor.
private const float CellBspBoundaryY = 3.5f;
// ── Test ───────────────────────────────────────────────────────────────
/// <summary>
/// RED test — documents the camera-collision indoor non-engagement bug (cause b).
///
/// <para>
/// Setup: indoor cell <c>0xA9B40175</c> with a CellBSP boundary at Y=3.5 and no
/// solid physics wall at that boundary (the room opens toward +Y, representing the
/// cottage front wall / portal). A landblock-baked exterior-shell GfxObj is registered
/// at Y=4.0 with <c>cellScope=0</c> (outdoor shadow list, NOT in the indoor cell's
/// portal-reachable set).
/// </para>
///
/// <para>
/// The camera sweep goes from pivot (0,1,95.5) to desired eye (0,5,96.25), through
/// the exterior wall at Y=4.0. While the sphere center is inside the CellBSP (Y ≤ 3.8),
/// <c>GetNearbyObjects</c> skips the outdoor sweep → GfxObj not found. Once the sphere
/// center exits the CellBSP (<c>ResolveCellId</c> returns outdoor), <c>GetNearbyObjects</c>
/// runs the outdoor sweep — but the sphere center (at Y ≈ 3.81 when first outdoor) is
/// approaching the wall at Y=4.0 from behind, and the exterior wall polygon's inward
/// normal (facing -Y, toward the building interior) means the sphere is on the polygon's
/// BACK face. Retail BSP collision tests (Path 5 near-miss) rely on the polygon's front
/// face normal for the sliding test; with the sphere on the back face the collision either
/// misfires or is suppressed by the front-face cull.
/// </para>
///
/// <para>
/// <b>This test currently FAILS</b>: pulledIn ≈ 0.
/// It will PASS when the camera probe bypasses the issue-#98 indoor gate
/// (e.g., <c>IsViewer</c> flag exempt from the gate, or camera uses a BSP-level
/// direct wall test instead of the full <c>ResolveWithTransition</c> player path).
/// </para>
///
/// <para>Fix assertion flip: <c>pulledIn >= MinExpectedPullIn</c> becomes true.</para>
/// </summary>
[Fact]
public void SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails()
{
var (engine, _) = BuildEngineWithSyntheticRoom();
var probe = new PhysicsCameraCollisionProbe(engine);
var stoppedEye = probe.SweepEye(
pivot: PivotWorld,
desiredEye: DesiredEye,
cellId: IndoorCellId,
selfEntityId: 0u);
// The eye should be stopped before the exterior wall at Y=4.0.
// Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7.
// Pulled-in = |DesiredEye.Y - stoppedEye.Y| should be ≥ 0.5 m.
float pulledIn = MathF.Abs(DesiredEye.Y - stoppedEye.Y);
Assert.True(
pulledIn >= MinExpectedPullIn,
$"Camera sweep should be stopped by the exterior-shell GfxObj wall at " +
$"Y={ExteriorWallY:F1} (registered outdoor/landblock-wide, cellScope=0). " +
$"Actual pulled-in: {pulledIn:F4} m (stopped eye Y={stoppedEye.Y:F4}). " +
$"CAUSE (b): ShadowObjectRegistry.GetNearbyObjects (ShadowObjectRegistry.cs:480) " +
$"returns early when primaryCellId={IndoorCellId:X8} (indoor: low byte 0x{IndoorCellId & 0xFFFF:X4} >= 0x0100), " +
$"skipping the outdoor radial sweep that contains the exterior GfxObj. " +
$"After ResolveCellId flips to outdoor (sphere Y > {CellBspBoundaryY:F1} + radius), the sweep " +
$"runs the outdoor list but the exterior wall polygon is approached from behind " +
$"(sphere on back face of Y={ExteriorWallY:F1} polygon). " +
$"FIX: exempt IsViewer sweeps from the indoor-primary gate, OR use a direct " +
$"BSP ray/sphere cast that tests BOTH cell interior walls AND the exterior GfxObj " +
$"regardless of primary-cell-id classification.");
}
// ── Engine + fixture builder ──────────────────────────────────────────
/// <summary>
/// Builds a minimal <see cref="PhysicsEngine"/> with:
/// <list type="bullet">
/// <item>One synthetic indoor cell (<see cref="IndoorCellId"/>), identity world transform.
/// CellBSP boundary at Y=<see cref="CellBspBoundaryY"/>.
/// PhysicsBSP is an empty leaf (no interior wall polygons at the target side —
/// represents an open portal/doorway toward +Y).</item>
/// <item>One exterior-shell GfxObj registered with <c>cellScope=0</c>
/// (landblock-wide, outdoor shadow list). The GfxObj has a wall polygon
/// at Y=<see cref="ExteriorWallY"/>, representing the cottage exterior shell
/// that retail's camera spring-arm should stop on.</item>
/// <item>A stub landblock with terrain far below (Z=-1000) to prevent outdoor
/// terrain collision from interfering.</item>
/// </list>
///
/// <para>
/// This fixture directly reproduces the production gap: the issue-#98 fix
/// (<see cref="ShadowObjectRegistry.GetNearbyObjects"/> early-return at line 480)
/// correctly prevents indoor spheres (the PLAYER) from being capped by the landblock-baked
/// cottage floor. But it also prevents the camera sphere (<see cref="ObjectInfoState.IsViewer"/>)
/// from seeing the exterior shell GfxObj — the same fix that closes issue #98 is what
/// breaks camera-collision indoors.
/// </para>
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithSyntheticRoom()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// ── 1. Indoor cell with open-toward-+Y boundary ────────────────────
// PhysicsBSP: empty leaf — no walls on the +Y side. This represents
// a room that has a portal (doorway / open passage) toward +Y.
// The exterior shell is NOT part of any indoor cell's BSP.
var emptyLeaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 20f },
};
var emptyBsp = new PhysicsBSPTree { Root = emptyLeaf };
// CellBSP: splitting plane at Y = CellBspBoundaryY with normal = -Y
// (interior is at Y ≤ CellBspBoundaryY).
// SphereIntersectsCellBsp returns false when:
// dist = dot(-Y, center) + CellBspBoundaryY = CellBspBoundaryY - center.Y
// < -(radius + 0.01f)
// i.e. center.Y > CellBspBoundaryY + radius + 0.01
// For radius=0.3: center.Y > 3.5 + 0.31 = 3.81.
var cellBspLeaf = new CellBSPNode { Type = BSPNodeType.Leaf };
var cellBspRoot = new CellBSPNode
{
SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), CellBspBoundaryY),
PosNode = cellBspLeaf,
};
var indoorCell = new CellPhysics
{
BSP = emptyBsp,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(), // no interior walls toward +Y
CellBSP = new CellBSPTree { Root = cellBspRoot },
Portals = Array.Empty<PortalInfo>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new System.Collections.Generic.HashSet<uint>(),
};
cache.RegisterCellStructForTest(IndoorCellId, indoorCell);
// ── 2. Exterior shell GfxObj registered OUTDOORS (cellScope=0) ─────
// This is the landblock-baked cottage exterior shell. The wall polygon
// at Y=ExteriorWallY has its front face pointing INTO the room (-Y normal)
// — so from the outside the polygon's front face faces +Y (outward).
// When the camera sphere approaches from inside (+Y direction), it hits
// the BACK face of this polygon.
//
// We register it with cellScope=0 (landblock-wide), which puts it in the
// outdoor per-cell shadow lists — NOT in the indoor cell's portal-reachable
// set. This mirrors how production registers landblock-baked statics:
// GameWindow.cs:5899 uses entity.ParentCellId ?? 0u → 0 for top-level statics.
const uint ExteriorShellEntityId = 0x00990001u;
const uint ExteriorShellGfxId = 0x01AABB01u;
// Wall polygon at Y=ExteriorWallY, facing INTO the room (normal=-Y).
// X ∈ [-3, 3], Z ∈ [93, 99].
var wallNormal = new Vector3(0f, -1f, 0f);
var wallD = ExteriorWallY;
const ushort WallPolyId = 1;
var wallPoly = new ResolvedPolygon
{
Vertices = new[]
{
new Vector3(-3f, ExteriorWallY, 93f),
new Vector3( 3f, ExteriorWallY, 93f),
new Vector3( 3f, ExteriorWallY, 99f),
new Vector3(-3f, ExteriorWallY, 99f),
},
Plane = new Plane(wallNormal, wallD),
NumPoints = 4,
SidesType = CullMode.None, // two-sided: should stop from both directions
};
// GfxObj PhysicsBSP: single leaf containing the exterior wall.
var gfxLeaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = new Vector3(0f, ExteriorWallY, 96f),
Radius = 10f,
},
};
gfxLeaf.Polygons.Add(WallPolyId);
var gfxPhysics = new GfxObjPhysics
{
BSP = new PhysicsBSPTree { Root = gfxLeaf },
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon> { [WallPolyId] = wallPoly },
BoundingSphere = new Sphere { Origin = new Vector3(0f, ExteriorWallY, 96f), Radius = 10f },
};
cache.RegisterGfxObjForTest(ExteriorShellGfxId, gfxPhysics);
// Register in the OUTDOOR shadow list (cellScope=0 → landblock-wide).
// This mirrors production's GameWindow.cs:5893 for landblock-baked statics.
engine.ShadowObjects.Register(
entityId: ExteriorShellEntityId,
gfxObjId: ExteriorShellGfxId,
worldPos: new Vector3(0f, ExteriorWallY, 96f),
rotation: Quaternion.Identity,
radius: 10f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: LandblockId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: 0u); // ← landblock-wide outdoor, NOT indoor cell scope
// ── 3. Stub landblock: terrain far below ───────────────────────────
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
var stubTerrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(
landblockId: LandblockId,
terrain: stubTerrain,
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return (engine, cache);
}
}