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>
This commit is contained in:
parent
6ec4cde9a4
commit
abf36e2743
2 changed files with 448 additions and 0 deletions
299
tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
Normal file
299
tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue