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>
This commit is contained in:
Erik 2026-05-31 18:49:39 +02:00
parent 3066460370
commit e099b4c4a3
3 changed files with 176 additions and 39 deletions

View file

@ -431,7 +431,8 @@ public sealed class ShadowObjectRegistry
float worldOffsetX, float worldOffsetY, uint landblockId,
List<ShadowEntry> results,
System.Collections.Generic.IReadOnlyCollection<uint>? portalReachableCells = null,
uint primaryCellId = 0u)
uint primaryCellId = 0u,
bool isViewer = false)
{
results.Clear();
// A6.P4 door fix (2026-05-24): dedup on the full ShadowEntry rather
@ -477,7 +478,20 @@ public sealed class ShadowObjectRegistry
// because the landblock-wide cottage GfxObj was returned by the
// unconditional radial sweep). Callers that don't pass primaryCellId
// (or pass 0) keep the pre-fix radial-only behavior.
if ((primaryCellId & 0xFFFFu) >= 0x0100u)
//
// M1.5 / Phase U (2026-05-31): exempt the camera viewer (isViewer=true) from
// this gate. The camera probe (ObjectInfoState.IsViewer) sweeps up+back from the
// player pivot through the cottage exterior shell, which is a landblock-baked
// GfxObj registered cellScope=0 (outdoor shadow list). Retail's
// SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) bounds the viewer
// via the player's cell enclosure — in retail, interior EnvCells are
// self-enclosing (walls in the cell's own geometry). In acdream's data model
// the enclosure is the exterior-shell GfxObj (issue #98 established this); the
// viewer must be able to reach it. Retail's find_obj_collisions at :308918 has
// NO indoor-cell gate — the gate is acdream-specific. The #98 protection is
// correct only for the player foot/head capsule (IsPlayer), NOT for IsViewer.
// Spec: docs/superpowers/specs/2026-05-31-camera-collision-indoor-engagement-design.md
if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer)
return;
// Extract landblock X/Y from the ID.

View file

@ -2304,12 +2304,22 @@ public sealed class Transition
// (acclient_2013_pseudo_c.txt:308751-308769) — indoor cells only
// iterate their own shadow lists + portal-visible neighbors, never
// outdoor cells' shadow lists. Closes the cottage cellar-up cap.
//
// M1.5 / Phase U (2026-05-31): pass isViewer so the camera probe
// (ObjectInfoState.IsViewer) bypasses the indoor gate and reaches the
// landblock-baked cottage exterior-shell GfxObj (registered cellScope=0).
// Retail's SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761)
// bounds the viewer by the cell enclosure; in acdream's data model that
// enclosure is the exterior shell GfxObj. The #98 gate stays in force for
// all non-viewer (IsPlayer, NPC, etc.) sweeps. ObjectInfo.IsViewer is
// TransitionTypes.cs:75, derived from ObjectInfoState.IsViewer (0x004).
engine.ShadowObjects.GetNearbyObjects(
currPos, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
nearbyObjs,
portalReachableCells,
primaryCellId: sp.CheckCellId);
primaryCellId: sp.CheckCellId,
isViewer: oi.IsViewer);
foreach (var obj in nearbyObjs)
{

View file

@ -100,7 +100,7 @@ public class CameraCollisionIndoorTests
// ── Test ───────────────────────────────────────────────────────────────
/// <summary>
/// RED test — documents the camera-collision indoor non-engagement bug (cause b).
/// 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
@ -111,29 +111,27 @@ public class CameraCollisionIndoorTests
/// </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.
/// 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>
/// <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).
/// 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>Fix assertion flip: <c>pulledIn >= MinExpectedPullIn</c> becomes true.</para>
/// <para>This test PASSES with the fix, FAILS without it.</para>
/// </summary>
[Fact]
public void SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails()
public void SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption()
{
var (engine, _) = BuildEngineWithSyntheticRoom();
var probe = new PhysicsCameraCollisionProbe(engine);
@ -154,15 +152,127 @@ public class CameraCollisionIndoorTests
$"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.");
$"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 ──────────────────────────────────────────
@ -249,32 +359,35 @@ public class CameraCollisionIndoorTests
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].
// 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);
var wallD = ExteriorWallY;
const float wallLocalD = 0f; // wall at local Y=0 (GfxObj origin)
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),
new Vector3(-3f, 0f, -3f),
new Vector3( 3f, 0f, -3f),
new Vector3( 3f, 0f, 3f),
new Vector3(-3f, 0f, 3f),
},
Plane = new Plane(wallNormal, wallD),
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 = new Vector3(0f, ExteriorWallY, 96f),
Origin = Vector3.Zero, // local-space center
Radius = 10f,
},
};
@ -286,7 +399,7 @@ public class CameraCollisionIndoorTests
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 },
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
};
cache.RegisterGfxObjForTest(ExteriorShellGfxId, gfxPhysics);