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