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; /// /// Phase U — RED diagnostic test for the camera-collision indoor non-engagement bug. /// /// /// Root cause (b): when the camera sphere is in an indoor cell, /// returns early at line 480 (if ((primaryCellId & 0xFFFF) >= 0x0100) return;), /// skipping the outdoor radial sweep. The cottage exterior-shell GfxObj is registered /// with cellScope=0 (landblock-wide, outdoor) — it lives in the outdoor per-cell /// shadow lists. With the indoor-primary gate active, the camera sweep (which uses /// not ) never /// finds the exterior shell while its sphere center is inside the indoor CellBSP volume. /// Once the sphere center exits the CellBSP boundary ( /// 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). /// /// /// /// Evidence from post-fix live capture (u4c-fix.log): eyeInRoot=n ~90% /// of frames; eye-player distance mean 3.43 m (full/zoomed chase, NOT pulled in). /// The [flap-sweep] diagnostic in /// was designed to confirm this: bsp=ok pulledIn≈0 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. /// /// /// /// 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 /// (), which needs to find the exterior building /// shell to implement retail's SmartBox::update_viewer spring-arm pull-in. /// /// /// /// 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. /// /// /// Diagnosis doc: docs/research/2026-05-31-camera-collision-indoor-diagnosis.md. /// 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 ─────────────────────────────────────────────────────────────── /// /// 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 /// 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 cellScope=0 (outdoor shadow list, NOT in the indoor cell's /// portal-reachable set). /// /// /// /// 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. /// /// /// /// 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. /// /// /// This test PASSES with the fix, FAILS without it. /// [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 ──────────────────────────────────────── /// /// 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 ────────────────────────────────────────── /// /// Builds a minimal with: /// /// One synthetic indoor cell (), identity world transform. /// CellBSP boundary at Y=. /// PhysicsBSP is an empty leaf (no interior wall polygons at the target side — /// represents an open portal/doorway toward +Y). /// One exterior-shell GfxObj registered with cellScope=0 /// (landblock-wide, outdoor shadow list). The GfxObj has a wall polygon /// at Y=, representing the cottage exterior shell /// that retail's camera spring-arm should stop on. /// A stub landblock with terrain far below (Z=-1000) to prevent outdoor /// terrain collision from interfering. /// /// /// /// This fixture directly reproduces the production gap: the issue-#98 fix /// ( 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 () /// from seeing the exterior shell GfxObj — the same fix that closes issue #98 is what /// breaks camera-collision indoors. /// /// 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(), // no interior walls toward +Y CellBSP = new CellBSPTree { Root = cellBspRoot }, Portals = Array.Empty(), PortalPolygons = new Dictionary(), VisibleCellIds = new System.Collections.Generic.HashSet(), }; 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(), Vertices = new VertexArray(), Resolved = new Dictionary { [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(), portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return (engine, cache); } }