From e099b4c4a358ee09c73ba732f1d20a716d412fd3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 31 May 2026 18:49:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20M1.5=20=E2=80=94=20viewer-exemp?= =?UTF-8?q?t=20the=20#98=20indoor=20shadow=20gate=20so=20the=20camera=20ey?= =?UTF-8?q?e=20collides=20the=20cottage=20shell=20enclosure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Physics/ShadowObjectRegistry.cs | 18 +- src/AcDream.Core/Physics/TransitionTypes.cs | 12 +- .../Rendering/CameraCollisionIndoorTests.cs | 185 ++++++++++++++---- 3 files changed, 176 insertions(+), 39 deletions(-) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index c8b53ba..b0ddadd 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -431,7 +431,8 @@ public sealed class ShadowObjectRegistry float worldOffsetX, float worldOffsetY, uint landblockId, List results, System.Collections.Generic.IReadOnlyCollection? 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. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index f947a67..3f7491c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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) { diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs index db5dde8..0d859c6 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs @@ -100,7 +100,7 @@ public class CameraCollisionIndoorTests // ── Test ─────────────────────────────────────────────────────────────── /// - /// 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). /// /// /// Setup: indoor cell 0xA9B40175 with a CellBSP boundary at Y=3.5 and no @@ -111,29 +111,27 @@ public class CameraCollisionIndoorTests /// /// /// - /// 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), - /// GetNearbyObjects skips the outdoor sweep → GfxObj not found. Once the sphere - /// center exits the CellBSP (ResolveCellId returns outdoor), GetNearbyObjects - /// 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: now accepts an + /// isViewer parameter (default false). When isViewer=true, the + /// issue-#98 indoor gate is bypassed so the camera probe can reach the exterior-shell + /// GfxObj. passes oi.IsViewer (i.e. + /// ObjectInfo.IsViewer at TransitionTypes.cs:75) at the GetNearbyObjects + /// call site. The #98 gate remains active for all non-viewer (player, NPC) sweeps. /// /// /// - /// This test currently FAILS: pulledIn ≈ 0. - /// It will PASS when the camera probe bypasses the issue-#98 indoor gate - /// (e.g., IsViewer flag exempt from the gate, or camera uses a BSP-level - /// direct wall test instead of the full ResolveWithTransition player path). + /// Retail faithfulness: SmartBox::update_viewer at + /// acclient_2013_pseudo_c.txt:92761 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 + /// find_obj_collisions at :308918 has no indoor gate — so exempting + /// IsViewer is the faithful analog. /// /// - /// Fix assertion flip: pulledIn >= MinExpectedPullIn becomes true. + /// This test PASSES with the fix, FAILS without it. /// [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 ──────────────────────────────────────── + + /// + /// Regression guard: the issue-#98 indoor gate must remain active for non-viewer sweeps. + /// + /// + /// A GfxObj registered with cellScope=0 (outdoor shadow list) must NOT be returned + /// by when the primary cell is indoor + /// and isViewer=false (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. + /// + /// + /// + /// Issue #98 fix (2026-05-24): gate fires at ShadowObjectRegistry.cs:~480 when + /// (primaryCellId & 0xFFFF) >= 0x0100 AND isViewer=false. This test + /// ensures the guard cannot regress. + /// + /// + [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(); + + // 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. + } + + /// + /// Regression guard: the viewer exemption allows the camera to reach outdoor GfxObjs + /// registered at cellScope=0 even when the primary cell is indoor. + /// + /// + /// This is the dual of : + /// the same GfxObj / same indoor primary cell, but isViewer=true. + /// The outdoor sweep must run and return the GfxObj. + /// + /// + /// + /// Retail faithfulness: SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) + /// calls find_obj_collisions (: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. + /// + /// + [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(); + + // 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(), Vertices = new VertexArray(), Resolved = new Dictionary { [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);