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_collisions → find_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);
}
}