acdream/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
Erik abf36e2743 T6 (BR-7) C2: BuildShadowCellSet - the registration-side portal flood
Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742)
as invoked by calc_cross_cells / calc_cross_cells_static (0x00515230 /
0x00515160) - the flood retail runs at SHADOW REGISTRATION time, minus
the containing-cell pick (registration passes a null out-cell):

- Seed: indoor -> exactly that one cell (added even when unloaded, like
  retail's null-pointer add_cell; the walk is then skipped per the
  0052b576 seed-pointer gate); outdoor -> block-crossing
  AddAllOutsideCells.
- Growing-array walk: indoor cells via FindTransitCellsSphere
  (sphere-vs-neighbor-BSP admission; exterior straddle -> outside cells
  once per walk, retail CELLARRAY.added_outside); outdoor cells via the
  CLandCell leg (0x00533800) = add_all_outside_cells (same once-guard)
  + the building bridge CSortCell -> CBuildingObj ->
  check_building_transit (0x00534060/0x006b5230/0x0052c5d0) - how an
  outdoor-positioned door reaches the vestibule's shadow list at
  registration. No XY grid, no visibility lists (the spec's
  VisibleCellIds rule was REFUTED by the WF1 verification).
- Static prune (do_not_load_cells, 0052b66e): indoor-seeded statics keep
  only {seed} + seed.stab_list (VisibleCellIds) - also strips outdoor
  cells, matching retail (interior statics never shadow into landcells;
  outdoor spheres reach them through their own array's building bridge).

11 unit tests: seeds (indoor/outdoor/unloaded), neighbor-BSP admission,
building bridge incl. the C1 negative-portal-id gate, exterior straddle
on/off, static prune drop/keep, zero-sphere no-op.

Consumed by C3 (ShadowObjectRegistry rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:02:08 +02:00

299 lines
12 KiB
C#

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;
/// <summary>
/// BR-7 / A6.P4 C2 (2026-06-11): <see cref="CellTransit.BuildShadowCellSet"/>
/// — the registration-side sphere-overlap portal flood, ported from
/// <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0) as called by
/// <c>calc_cross_cells(_static)</c> (0x00515230/0x00515160). Synthetic
/// fixtures mirror <c>CellTransitFindCellSetTests</c>.
/// </summary>
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<uint>? 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<ushort, ResolvedPolygon>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [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<uint>(),
};
}
private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
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<uint>()));
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<uint> { 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<Sphere>(), 0, isStatic: false);
Assert.Empty(set);
}
}