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>
299 lines
12 KiB
C#
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);
|
|
}
|
|
}
|