From abf36e2743a66d1f9ed6f1044c60210eaf3f2cce Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 14:02:08 +0200 Subject: [PATCH] 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 --- src/AcDream.Core/Physics/CellTransit.cs | 149 +++++++++ .../Physics/BuildShadowCellSetTests.cs | 299 ++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 0b307182..949ca16c 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -441,6 +441,155 @@ public static class CellTransit } } + /// + /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the + /// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time. + /// Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, + /// pc:308742) as invoked by CPhysicsObj::calc_cross_cells / + /// calc_cross_cells_static (Ghidra 0x00515230 / 0x00515160): + /// the seed + growing-array walk, WITHOUT the containing-cell pick + /// (registration passes a null out-cell). + /// + /// Shape, all Ghidra-verified (wf1-interior-collision.md): + /// + /// Seed: indoor id (low16 ≥ 0x100) → exactly that one cell + /// (0052b563), added EVEN IF unloaded (retail add_cell()s the id + /// with a null pointer); outdoor id → the block-crossing + /// + /// (0052b53f). + /// Growing-array walk (0052b576-0052b5ab), gated on the seed + /// being LOADED: each array cell's find_transit_cells + /// (vtable+0x80). Indoor cells → + /// + /// (sphere-vs-neighbor-BSP gates; exterior straddle → outside + /// cells, once per walk like retail's CELLARRAY.added_outside). + /// Outdoor cells → CLandCell::find_transit_cells + /// (0x00533800) = add_all_outside_cells (same once-guard) + + /// the building bridge CSortCell → CBuildingObj → + /// CEnvCell::check_building_transit + /// (0x00534060/0x006b5230/0x0052c5d0) — how an outdoor-seeded + /// door reaches the vestibule's shadow list. Unloaded indoor + /// cells in the array are not walked (0052b58e null check). + /// Static prune (, retail + /// do_not_load_cells, 0052b66e): when the seed is indoor, + /// flood results are pruned to {seed} ∪ seed.stab_list + /// () — placement of + /// statics must not force-load cells. Note this also strips + /// outdoor cells (they are never in an EnvCell stab list): + /// retail interior statics never shadow into landcells; an + /// outdoor sphere reaches them via its OWN array's + /// check_building_transit instead. + /// + /// + /// Flood spheres: the object's REAL collision footprint — retail + /// globalizes the CylSpheres (low_pt → world, cyl radius, cap 10; + /// overload 0x0052b9f0), falling back to the part-array sorting sphere. + /// Callers map ShadowShape lists via + /// -derived helpers (see + /// ShadowObjectRegistry). + /// + public static IReadOnlyList BuildShadowCellSet( + PhysicsDataCache cache, + uint seedCellId, + IReadOnlyList worldSpheres, + int numSpheres, + bool isStatic) + { + var candidates = new CellArray(); + int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); + if (seedCellId == 0 || sphereCount == 0) + return candidates.OrderedIds; + + uint seedLow = seedCellId & 0xFFFFu; + // #106 frame convention: LandDefs lcoord math runs block-local; + // TryGetTerrainOrigin supplies the seed block's world origin + // (Zero fallback = legacy anchor-frame, same as the transit path). + cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin); + + bool seedLoaded; + if (seedLow >= 0x0100u) + { + candidates.Add(seedCellId); + // Retail adds the unloaded seed by id (null cell pointer) and + // skips the walk (gate on arg4 != 0 at 0052b576) — the object + // stays registered under just its claimed cell until the cell + // hydrates and the registration is re-run (CObjCell::init_objects + // → recalc_cross_cells, Ghidra 0x0052b420/0x00515a30; our + // equivalent is ShadowObjectRegistry's re-flood hook). + seedLoaded = cache.GetCellStruct(seedCellId) is not null; + } + else + { + AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); + // Outdoor seeds always walk: retail's null-CLandCell case is + // "landblock not loaded at all", where our per-cell building + // lookups below come back null anyway (documented adaptation). + seedLoaded = true; + } + + if (seedLoaded) + { + bool outdoorAdded = seedLow < 0x0100u; // retail CELLARRAY.added_outside + for (int i = 0; i < candidates.Count; i++) + { + uint cellId = candidates.OrderedIds[i]; + if ((cellId & 0xFFFFu) >= 0x0100u) + { + var cell = cache.GetCellStruct(cellId); + if (cell is null) continue; + + FindTransitCellsSphere( + cache, cell, cellId, worldSpheres, sphereCount, + candidates, out bool exitStraddle, out _); + + if (exitStraddle && !outdoorAdded) + { + AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); + outdoorAdded = true; + } + } + else + { + // CLandCell::find_transit_cells (0x00533800): + // add_all_outside_cells (added_outside-guarded) then the + // building bridge for the landcell's building, if any. + if (!outdoorAdded) + { + AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates); + outdoorAdded = true; + } + + var building = cache.GetBuilding(cellId); + if (building is not null) + CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _); + } + } + + // Static prune (do_not_load_cells, 0052b66e): indoor-seeded + // statics keep only {seed} ∪ seed.stab_list. + if (isStatic && seedLow >= 0x0100u) + { + var seedCell = cache.GetCellStruct(seedCellId); + if (seedCell is not null) + { + var keep = new List(candidates.Count); + foreach (uint id in candidates.OrderedIds) + { + if (id == seedCellId || seedCell.VisibleCellIds.Contains(id)) + keep.Add(id); + } + if (keep.Count != candidates.Count) + { + candidates.Clear(); + foreach (uint id in keep) candidates.Add(id); + } + } + } + } + + return candidates.OrderedIds; + } + /// /// Verbatim port of CEnvCell::find_visible_child_cell /// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP diff --git a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs new file mode 100644 index 00000000..448f100d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs @@ -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; + +/// +/// 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); + } +}