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; /// /// Camera (viewer) sweep vs the BUILDING collision channel. /// /// /// History: this file was born as the Phase U RED diagnostic for the /// camera-collision indoor non-engagement bug — the b3ce505 #98 indoor gate /// in the old radial GetNearbyObjects blocked the viewer sweep from /// reaching the exterior-shell GfxObj, and the fix was an /// isViewer exemption. BR-7 / A6.P4 (2026-06-11) deleted that whole /// stack: building shells are no longer shadow objects at all — they /// dispatch through the retail per-LandCell building channel /// (CSortCell::find_collisionsfind_building_collisions, /// Ghidra 0x005340a0/0x006b5300), and the indoor camera is bounded by the /// interior cell's own physics BSP (the env channel — retail's actual /// viewer enclosure; pinned against real dat by /// CameraCornerSealReplayTests). /// /// /// /// What remains here is the end-to-end pin of the NEW channel: a viewer /// sweep whose primary cell is an outdoor landcell with a cached building /// must be stopped by the building's shell BSP. /// /// public class CameraCollisionIndoorTests { // ── Geometry constants ───────────────────────────────────────────────── // Player outdoors at (0, 1, 94). Pivot = (0, 1, 95.5). Camera sweeps // backward (+Y) and slightly upward toward the building wall at Y=4.0. private const uint OutdoorCellId = 0xA9B40001u; // landcell (0,0) private const uint LandblockId = 0xA9B40000u; private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f); // Desired eye: backward past the building wall at Y=4.0. private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f); private const float BuildingWallY = 4.0f; // The sphere should be stopped near Y = BuildingWallY - viewerRadius; // anything ≥ this margin proves the channel engaged. private const float MinExpectedPullIn = 0.5f; /// /// BR-7 / A6.P4: the viewer sweep is stopped by the building channel — /// Transition.FindBuildingCollisions runs the shell part-0 BSP /// for the outdoor primary cell holding the building reference, exactly /// like retail CLandCell::find_collisions → /// CSortCell::find_collisions (Ghidra 0x00532d60/0x005340a0). /// The shell is NOT in the ShadowObjectRegistry (production skips /// IsBuildingShell entities); the stop can only come from the /// channel. /// [Fact] public void SweepEye_OutdoorCell_StoppedByBuildingChannelShell() { var (engine, _) = BuildEngineWithBuilding(); var probe = new PhysicsCameraCollisionProbe(engine); var stoppedEye = probe.SweepEye( pivot: PivotWorld, desiredEye: DesiredEye, cellId: OutdoorCellId, selfEntityId: 0u, playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye; float pulledIn = MathF.Abs(DesiredEye.Y - stoppedEye.Y); Assert.True( pulledIn >= MinExpectedPullIn, $"Camera sweep should be stopped by the building-channel shell BSP at " + $"Y={BuildingWallY:F1} (cache.GetBuilding({OutdoorCellId:X8}) with ModelId set). " + $"Actual pulled-in: {pulledIn:F4} m (stopped eye Y={stoppedEye.Y:F4}). " + $"REGRESSION: Transition.FindBuildingCollisions (retail " + $"CBuildingObj::find_building_collisions, Ghidra 0x006b5300) is not engaging " + $"for the viewer's outdoor primary cell."); } /// /// Channel inertness guard: a building cached WITHOUT a model id /// (legacy entries, portal-transit-only callers) must not collide — /// the sweep reaches the desired eye. /// [Fact] public void SweepEye_BuildingWithoutModelId_ChannelInert() { var (engine, cache) = BuildEngineWithBuilding(); // Replace the building entry with a model-less one. cache.RegisterBuildingForTest(OutdoorCellId, new BuildingPhysics { WorldTransform = Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f), InverseWorldTransform = InvertOrIdentity(Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f)), Portals = Array.Empty(), ModelId = 0u, }); var probe = new PhysicsCameraCollisionProbe(engine); var stoppedEye = probe.SweepEye( pivot: PivotWorld, desiredEye: DesiredEye, cellId: OutdoorCellId, selfEntityId: 0u, playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye; Assert.True(MathF.Abs(DesiredEye.Y - stoppedEye.Y) < 0.05f, $"Model-less building entries must keep the channel inert; eye stopped at " + $"Y={stoppedEye.Y:F4} instead of reaching {DesiredEye.Y:F4}."); } // ── Engine + fixture builder ────────────────────────────────────────── private static Matrix4x4 InvertOrIdentity(Matrix4x4 m) => Matrix4x4.Invert(m, out var inv) ? inv : Matrix4x4.Identity; /// /// Minimal engine with ONE outdoor landcell building: /// /// Shell GfxObj: a single two-sided wall polygon at local Y=0, /// anchored by the building transform at world Y=. /// at /// with ModelId set — the /// per-LandCell building reference (retail CSortCell.building, /// set once at the origin cell by CLandBlock::init_buildings, /// Ghidra 0x0052fd80). /// Stub terrain far below so terrain collision never interferes. /// /// private static (PhysicsEngine engine, PhysicsDataCache cache) BuildEngineWithBuilding() { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; // ── 1. Shell GfxObj: one two-sided wall polygon at local Y=0 ────── const uint ShellGfxId = 0x01AABB01u; 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 System.Numerics.Plane(new Vector3(0f, -1f, 0f), 0f), NumPoints = 4, SidesType = CullMode.None, // two-sided: stops from both directions }; var gfxLeaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf, BoundingSphere = new Sphere { Origin = Vector3.Zero, 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(ShellGfxId, gfxPhysics); // ── 2. The per-LandCell building reference ───────────────────────── var bldTransform = Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f); cache.RegisterBuildingForTest(OutdoorCellId, new BuildingPhysics { WorldTransform = bldTransform, InverseWorldTransform = InvertOrIdentity(bldTransform), Portals = Array.Empty(), ModelId = ShellGfxId, }); // ── 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; engine.AddLandblock( landblockId: LandblockId, terrain: new TerrainSurface(heights, heightTable), cells: Array.Empty(), portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return (engine, cache); } }