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)
{