using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
///
/// BR-7 / A6.P4 C2 (2026-06-11):
/// — the registration-side sphere-overlap portal flood, ported from
/// CObjCell::find_cell_list (Ghidra 0x0052b4e0) as called by
/// calc_cross_cells(_static) (0x00515230/0x00515160). Synthetic
/// fixtures mirror CellTransitFindCellSetTests.
///
public class BuildShadowCellSetTests
{
private const uint IndoorSeed = 0xA9B40100u;
private const uint NeighborCell = 0xA9B40101u;
private static Sphere[] One(Vector3 center, float radius) =>
new[] { new Sphere { Origin = center, Radius = radius } };
private static CellPhysics MakeCellWithPortalAtRightWall(
Matrix4x4 worldTransform, ushort otherCellId, IReadOnlySet? visible = null)
{
var portalPoly = new ResolvedPolygon
{
Vertices = new[]
{
new Vector3(2.5f, -2.5f, 0f),
new Vector3(2.5f, 2.5f, 0f),
new Vector3(2.5f, 2.5f, 5f),
new Vector3(2.5f, -2.5f, 5f),
},
Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5
NumPoints = 4,
SidesType = CullMode.None,
};
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary(),
PortalPolygons = new Dictionary { [10] = portalPoly },
Portals = new[]
{
new PortalInfo(otherCellId: otherCellId, polygonId: 10, flags: 0),
},
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
},
VisibleCellIds = visible ?? new HashSet(),
};
}
private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary(),
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
},
};
}
// ── Seeds ──────────────────────────────────────────────────────────
[Fact]
public void IndoorSeed_SphereAwayFromPortals_FloodsSeedOnly()
{
// Neighbor NOT cached: a leaf-root CellBSP would admit any sphere
// (no geometry), so the seed-only case is pinned with the neighbor
// unloaded — the unloaded-neighbor portal-side hint (PortalSide=true
// for flags=0 → dist > -rad) does not fire at local x=-1
// (dist = -3.5 vs -rad = -0.52).
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
Matrix4x4.Identity, otherCellId: 0x0101));
var set = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, One(new Vector3(-1f, 0f, 2.5f), 0.5f), 1,
isStatic: false);
Assert.Equal(new[] { IndoorSeed }, set);
}
[Fact]
public void IndoorSeed_SphereOverlapsNeighborBsp_FloodsNeighbor()
{
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
Matrix4x4.Identity, otherCellId: 0x0101));
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(
Matrix4x4.CreateTranslation(5f, 0f, 0f)));
var set = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, One(new Vector3(2.0f, 0f, 2.5f), 0.5f), 1,
isStatic: false);
Assert.Contains(IndoorSeed, set);
Assert.Contains(NeighborCell, set);
// Seed first — retail CELLARRAY order (seed added at index 0).
Assert.Equal(IndoorSeed, set[0]);
}
[Fact]
public void UnloadedIndoorSeed_ReturnsSeedByIdOnly_NoWalk()
{
// Retail: the unloaded seed is add_cell()'d by id with a null cell
// pointer and the walk is skipped (gate on the seed pointer,
// 0052b576). The object stays registered under its claimed cell
// until re-flood on hydration.
var cache = new PhysicsDataCache();
var set = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, One(Vector3.Zero, 0.5f), 1, isStatic: false);
Assert.Equal(new[] { IndoorSeed }, set);
}
[Fact]
public void OutdoorSeed_FloodsOverlappedLandcells_BlockCrossingMath()
{
var cache = new PhysicsDataCache();
// Near the east boundary of landcell grid(0,0): AddAllOutsideCells
// adds the east neighbor grid(1,0) (block-local frame, anchor block).
var set = CellTransit.BuildShadowCellSet(
cache, 0xA9B40001u, One(new Vector3(23.8f, 12f, 0f), 0.5f), 1,
isStatic: false);
Assert.Contains(0xA9B40001u, set);
Assert.True(set.Count >= 2,
$"Expected >= 2 outdoor cells (seed + east neighbor), got {set.Count}");
Assert.All(set, id => Assert.True((id & 0xFFFFu) < 0x0100u));
}
// ── The building bridge (outdoor → indoor at registration) ────────
[Fact]
public void OutdoorSeed_BuildingOnLandcell_SphereInsideInterior_AddsInteriorCell()
{
// The door shape: an outdoor-positioned object at a doorway whose
// flood sphere pokes into the vestibule. Retail writes it into the
// vestibule's shadow_object_list at registration via
// CLandCell::find_transit_cells → ... → check_building_transit.
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
// Compute the landcell the sphere floods (seed cell of the walk) and
// hang the building off it.
var probe = CellTransit.BuildShadowCellSet(
cache, 0xA9B40001u, sphere, 1, isStatic: false);
uint floodedLandcell = probe[0];
cache.RegisterBuildingForTest(floodedLandcell, new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
new BldPortalInfo(NeighborCell, otherPortalId: 0, flags: 0),
},
});
var set = CellTransit.BuildShadowCellSet(
cache, 0xA9B40001u, sphere, 1, isStatic: false);
Assert.Contains(floodedLandcell, set);
Assert.Contains(NeighborCell, set);
}
[Fact]
public void OutdoorSeed_BuildingPortalNegativeId_InteriorNotAdded()
{
// C1's gate observed through the flood: a portal with
// other_portal_id = -1 (wire 0xFFFF) never admits its interior cell
// (CEnvCell::check_building_transit, 0x0052c5dc).
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
var probe = CellTransit.BuildShadowCellSet(
cache, 0xA9B40001u, sphere, 1, isStatic: false);
uint floodedLandcell = probe[0];
cache.RegisterBuildingForTest(floodedLandcell, new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
new BldPortalInfo(NeighborCell, otherPortalId: unchecked((short)0xFFFF), flags: 0),
},
});
var set = CellTransit.BuildShadowCellSet(
cache, 0xA9B40001u, sphere, 1, isStatic: false);
Assert.DoesNotContain(NeighborCell, set);
}
// ── Exterior straddle from an indoor seed ──────────────────────────
[Fact]
public void IndoorSeed_ExteriorPortalStraddle_AddsOutsideCells()
{
// Cell with an EXTERIOR portal (0xFFFF) at x=2.5; sphere straddling
// the plane → AddAllOutsideCells fires (retail straddle gate,
// 0052c8e5-0052c92d + gate 0052c9d6).
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
Matrix4x4.Identity, otherCellId: 0xFFFF));
var set = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, One(new Vector3(2.4f, 0f, 2.5f), 0.5f), 1,
isStatic: false);
Assert.Contains(IndoorSeed, set);
Assert.Contains(set, id => (id & 0xFFFFu) < 0x0100u);
}
[Fact]
public void IndoorSeed_SphereAwayFromExteriorPortal_NoOutsideCells()
{
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
Matrix4x4.Identity, otherCellId: 0xFFFF));
var set = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, One(new Vector3(-1f, 0f, 2.5f), 0.5f), 1,
isStatic: false);
Assert.Equal(new[] { IndoorSeed }, set);
}
// ── Static prune (do_not_load_cells) ───────────────────────────────
[Fact]
public void StaticIndoorSeed_PrunesCellsOutsideStabList()
{
// The neighbor is flooded by sphere overlap but is NOT in the seed's
// stab list → calc_cross_cells_static's do_not_load_cells prune
// (0052b66e) drops it. {seed} survives unconditionally.
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
Matrix4x4.Identity, otherCellId: 0x0101, visible: new HashSet()));
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(
Matrix4x4.CreateTranslation(5f, 0f, 0f)));
var sphere = One(new Vector3(2.0f, 0f, 2.5f), 0.5f);
var dynamicSet = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, sphere, 1, isStatic: false);
Assert.Contains(NeighborCell, dynamicSet);
var staticSet = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, sphere, 1, isStatic: true);
Assert.Equal(new[] { IndoorSeed }, staticSet);
}
[Fact]
public void StaticIndoorSeed_KeepsCellsInStabList()
{
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
Matrix4x4.Identity, otherCellId: 0x0101,
visible: new HashSet { NeighborCell }));
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(
Matrix4x4.CreateTranslation(5f, 0f, 0f)));
var staticSet = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, One(new Vector3(2.0f, 0f, 2.5f), 0.5f), 1,
isStatic: true);
Assert.Contains(IndoorSeed, staticSet);
Assert.Contains(NeighborCell, staticSet);
}
[Fact]
public void ZeroSpheres_ReturnsEmpty()
{
var cache = new PhysicsDataCache();
var set = CellTransit.BuildShadowCellSet(
cache, IndoorSeed, System.Array.Empty(), 0, isStatic: false);
Assert.Empty(set);
}
}