acdream/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs
Erik e099b4c4a3 fix(physics): M1.5 — viewer-exempt the #98 indoor shadow gate so the camera eye collides the cottage shell enclosure
Root cause: ShadowObjectRegistry.GetNearbyObjects gated the outdoor radial sweep
whenever primaryCellId is an indoor cell — this was the issue-#98 fix that stops the
cottage-floor GfxObj from capping the player's head sphere from the cellar below.
But the camera probe (ObjectInfoState.IsViewer, 0x004) also sweeps with an indoor
primary cell, and the only geometry that encloses a Holtburg cottage in acdream's data
model is the landblock-baked exterior-shell GfxObj (registered cellScope=0, outdoor).
Result: the camera's spring-arm sweep found nothing and flew to full chase distance
(eye ~3.4 m back, outside the player's cell 90% of frames — root cause of all three
post-flap residuals: transparent outer walls, terrain-through-floor, grey stairs).

Fix (Option A, retail-faithful): add isViewer parameter (default false, all existing
callers keep the gate) to GetNearbyObjects. Thread oi.IsViewer from FindObjCollisions
(TransitionTypes.cs ~line 2307) through to the gate. When isViewer=true the outdoor
sweep runs regardless of indoor primary cell — matching retail's SmartBox::update_viewer
(:92761) which calls find_obj_collisions (:308918) with no indoor-cell restriction.
The #98 gate remains in force for IsPlayer and all other non-viewer sweeps.

Retail anchors:
- SmartBox::update_viewer @ acclient_2013_pseudo_c.txt:92761 — viewer transition
  finds geometry via find_obj_collisions; no indoor gate
- find_obj_collisions @ :308918 — iterates shadow_object_list unconditionally
- CObjCell::find_cell_list @ :308751-308769 — retail's own indoor/outdoor branch
  (the model that makes the #98 gate correct for the player)

Also fixes a test-fixture geometry bug: the original RED test had
gfxLeaf.BoundingSphere.Origin in world space (0, ExteriorWallY, 96) instead of
object-local space (0, 0, 0), causing NodeIntersects to return false even when the
gate was bypassed. Corrected to local space; wall polygon vertices/plane also
expressed in local space relative to the GfxObj origin.

Tests (3 new, 1 renamed):
- SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption:
  was RED (_CurrentlyFails); now GREEN — camera sweep stopped by exterior GfxObj wall
- GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj: #98 guard
  (isViewer=false keeps the gate → GfxObj NOT returned)
- GetNearbyObjects_IndoorPrimaryCell_IsViewer_DoesReturnOutdoorGfxObj: viewer-exempt
  guard (isViewer=true bypasses gate → GfxObj IS returned)

App.Tests: 154 pass / 0 fail (was 151/1). Core.Tests: 15 fail (same pre-existing
static-leak flakiness, unchanged from baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:39 +02:00

436 lines
22 KiB
C#

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>
/// Documents the fix for 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 fix: <see cref="ShadowObjectRegistry.GetNearbyObjects"/> now accepts an
/// <c>isViewer</c> parameter (default <c>false</c>). When <c>isViewer=true</c>, the
/// issue-#98 indoor gate is bypassed so the camera probe can reach the exterior-shell
/// GfxObj. <see cref="Transition.FindObjCollisions"/> passes <c>oi.IsViewer</c> (i.e.
/// <c>ObjectInfo.IsViewer</c> at TransitionTypes.cs:75) at the <c>GetNearbyObjects</c>
/// call site. The #98 gate remains active for all non-viewer (player, NPC) sweeps.
/// </para>
///
/// <para>
/// Retail faithfulness: <c>SmartBox::update_viewer</c> at
/// <c>acclient_2013_pseudo_c.txt:92761</c> bounds the viewer via the player-cell
/// enclosure; retail's interior EnvCells are self-enclosing. In acdream's data model
/// the enclosure is the exterior-shell GfxObj (issue #98). Retail's
/// <c>find_obj_collisions</c> at <c>:308918</c> has no indoor gate — so exempting
/// <c>IsViewer</c> is the faithful analog.
/// </para>
///
/// <para>This test PASSES with the fix, FAILS without it.</para>
/// </summary>
[Fact]
public void SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption()
{
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}). " +
$"REGRESSION: ShadowObjectRegistry.GetNearbyObjects indoor gate is incorrectly " +
$"blocking the IsViewer (camera) sweep from reaching the exterior-shell GfxObj. " +
$"Fix: pass isViewer=true at the FindObjCollisions call site so the indoor gate " +
$"is bypassed for camera sweeps (ShadowObjectRegistry.cs, TransitionTypes.cs:~2307).");
}
// ── Issue #98 regression guard ────────────────────────────────────────
/// <summary>
/// Regression guard: the issue-#98 indoor gate must remain active for non-viewer sweeps.
///
/// <para>
/// A GfxObj registered with <c>cellScope=0</c> (outdoor shadow list) must NOT be returned
/// by <see cref="ShadowObjectRegistry.GetNearbyObjects"/> when the primary cell is indoor
/// and <c>isViewer=false</c> (i.e. the default player / NPC path). This is the protection
/// that prevents the cottage-floor polygon from capping the player's head sphere while the
/// player is in the cellar directly below.
/// </para>
///
/// <para>
/// Issue #98 fix (2026-05-24): gate fires at <c>ShadowObjectRegistry.cs:~480</c> when
/// <c>(primaryCellId &amp; 0xFFFF) >= 0x0100</c> AND <c>isViewer=false</c>. This test
/// ensures the guard cannot regress.
/// </para>
/// </summary>
[Fact]
public void GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj()
{
var registry = new ShadowObjectRegistry();
// Register a GfxObj at cellScope=0 (landblock-wide / outdoor shadow list).
const uint EntityId = 0x00010001u;
const uint GfxId = 0x01000001u;
const uint LbId = 0xA9B40000u;
var pos = new Vector3(0f, 4f, 96f);
registry.Register(
entityId: EntityId,
gfxObjId: GfxId,
worldPos: pos,
rotation: System.Numerics.Quaternion.Identity,
radius: 10f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: LbId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: 0u); // outdoor, landblock-wide
var results = new List<ShadowEntry>();
// Non-viewer query with indoor primary cell — gate must fire, GfxObj NOT returned.
registry.GetNearbyObjects(
worldPos: pos,
queryRadius: 20f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: LbId,
results: results,
portalReachableCells: null,
primaryCellId: 0xA9B40175u, // indoor cell (low 16 bits 0x0175 >= 0x0100)
isViewer: false);
Assert.Empty(results); // Issue #98 gate must block the outdoor GfxObj for non-viewer sweeps.
}
/// <summary>
/// Regression guard: the viewer exemption allows the camera to reach outdoor GfxObjs
/// registered at <c>cellScope=0</c> even when the primary cell is indoor.
///
/// <para>
/// This is the dual of <see cref="GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj"/>:
/// the same GfxObj / same indoor primary cell, but <c>isViewer=true</c>.
/// The outdoor sweep must run and return the GfxObj.
/// </para>
///
/// <para>
/// Retail faithfulness: <c>SmartBox::update_viewer</c> (acclient_2013_pseudo_c.txt:92761)
/// calls <c>find_obj_collisions</c> (:308918) which has no indoor-cell gate — the viewer
/// reaches any geometry in the player's cell enclosure. The #98 gate is an acdream-specific
/// workaround that must not apply to the viewer.
/// </para>
/// </summary>
[Fact]
public void GetNearbyObjects_IndoorPrimaryCell_IsViewer_DoesReturnOutdoorGfxObj()
{
var registry = new ShadowObjectRegistry();
// Same outdoor-scope GfxObj as the non-viewer test above.
const uint EntityId = 0x00010002u;
const uint GfxId = 0x01000002u;
const uint LbId = 0xA9B40000u;
var pos = new Vector3(0f, 4f, 96f);
registry.Register(
entityId: EntityId,
gfxObjId: GfxId,
worldPos: pos,
rotation: System.Numerics.Quaternion.Identity,
radius: 10f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: LbId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: 0u); // outdoor, landblock-wide
var results = new List<ShadowEntry>();
// Viewer query with indoor primary cell — gate must be bypassed, GfxObj IS returned.
registry.GetNearbyObjects(
worldPos: pos,
queryRadius: 20f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: LbId,
results: results,
portalReachableCells: null,
primaryCellId: 0xA9B40175u, // indoor cell (low 16 bits 0x0175 >= 0x0100)
isViewer: true);
Assert.NotEmpty(results); // Viewer must bypass the indoor gate and find the exterior GfxObj.
Assert.Equal(EntityId, results[0].EntityId);
}
// ── 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 in OBJECT-LOCAL space (GfxObj registered at world Y=ExteriorWallY).
// In local space the wall is at Y=0 (directly at the GfxObj's origin).
// Normal stays (-Y, facing INTO the room) — same direction as in world space.
// X ∈ [-3, 3], Z ∈ [-3, 3] (local, centered on the GfxObj's world origin).
var wallNormal = new Vector3(0f, -1f, 0f);
const float wallLocalD = 0f; // wall at local Y=0 (GfxObj origin)
const ushort WallPolyId = 1;
var wallPoly = new ResolvedPolygon
{
Vertices = new[]
{
new Vector3(-3f, 0f, -3f),
new Vector3( 3f, 0f, -3f),
new Vector3( 3f, 0f, 3f),
new Vector3(-3f, 0f, 3f),
},
Plane = new Plane(wallNormal, wallLocalD),
NumPoints = 4,
SidesType = CullMode.None, // two-sided: should stop from both directions
};
// GfxObj PhysicsBSP: single leaf containing the exterior wall.
// BoundingSphere in OBJECT-LOCAL space: centered at origin (0,0,0), radius 10.
var gfxLeaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero, // local-space center
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 = Vector3.Zero, 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);
}
}