diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 94269d5f..169bb2d1 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -62,7 +62,15 @@ accepted-divergence entries (#96, #49, #50).
---
-## 2. Adaptation (AD) — 46 rows (AD-25 retired 2026-07-30 at Campaign P Slice P3 — the remote dead-reckoning post-resolve now calls the exact ported `PhysicsObjUpdate.HandleAllCollisions` (the same function the local player and every ordinary body already use) instead of its own hand-inlined, narrower reflect gate; the row's own premise ("the remote DR sweep hasn't been rebuilt yet") no longer holds; AD-55 filed 2026-07-30 at Campaign P Slice P2, split out of the retired AP-7 row — the open cos(10°)-vs-0.99999536f Sledding slope-flatness constant; AD-53/AD-54 filed the same slice, split out of the retired TS-1 row — CliffSlide's three-source reference-normal fallback chain and the walkable-steepness reroute to CliffSlide; AD-52 filed 2026-07-29 at Campaign N slice N6 — the fragment-assembler 60 s partial TTL + completed-sequence ring; AD-51 filed 2026-07-29 at Campaign N slice N4 — the reclaimed-word pool for ACE's fresh-sequence cleartext RejectRetransmit; AD-50 filed 2026-07-29 at Campaign N slice N2 — the inbound-watermark ACE init; AD-49 stays reserved for Campaign N §5's blob-layer ordering deferral, filed when its slice lands; AD-47 and AD-48 filed 2026-07-29 at Campaign V slice V11 — the MSAA sample-position and present-pacing rows the campaign's risk register scheduled for the GL deletion; AD-11 retired 2026-07-23 — exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
+## 2. Adaptation (AD) — 44 active rows
+
+Recent retirements: AD-3/AD-4 retired 2026-07-31 by the exact loaded-cell
+availability and null-root containment port; AD-25 retired 2026-07-30 by the
+shared `PhysicsObjUpdate.HandleAllCollisions` remote path; AD-11 retired
+2026-07-23 by the exact low-bit ItemUses predicate; AD-31 retired 2026-07-15
+by the DAT-authored portal-space viewport. Recent additions and splits:
+AD-47/AD-48 (Vulkan sample/present behavior), AD-50..AD-52 (Campaign N), and
+AD-53..AD-55 (Campaign P response-layer findings).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -78,8 +86,6 @@ accepted-divergence entries (#96, #49, #50).
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
-| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
-| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index c034005a..0b1cd17f 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -178,6 +178,15 @@ package schema, bake, DAT reader, collision formula, or render portal graph
changed. Evidence:
`docs/research/2026-07-26-prepared-indoor-transit-regression.md`.
+**Cell availability semantics (2026-07-31).** Raw and prepared CellStruct
+publication now retains a `CellPhysics` record even when authored physics or
+containment roots are empty. Payload absence is the unavailable state; a loaded
+null/-1 containment root keeps retail's universal-inside BSP base case.
+Registration-side outdoor floods still add outside cells but skip transit when
+the active CLandCell is unavailable, then recover through the existing reflood
+after terrain/cell hydration. No package schema or DAT reader changed.
+Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`.
+
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while
retail inherits the complete `CreateParticleHook` payload. The narrow readers in
diff --git a/docs/research/2026-07-31-cell-availability-semantics.md b/docs/research/2026-07-31-cell-availability-semantics.md
new file mode 100644
index 00000000..a2bd019b
--- /dev/null
+++ b/docs/research/2026-07-31-cell-availability-semantics.md
@@ -0,0 +1,62 @@
+# Retail cell availability and null-root containment — 2026-07-31
+
+## Scope
+
+This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's
+atomic streaming-generation work.
+
+The bug was one collapsed state. acdream treated all three of these as
+“containment unavailable”:
+
+1. no visible cell payload is loaded;
+2. a loaded CellStruct has a null containment root;
+3. a loaded CellStruct has an authored containment root.
+
+Retail distinguishes (1) from (2). A failed visible-cell lookup is
+unavailable. A loaded CellStruct remains a real cell even when its BSPTREE
+root is null, and the containment query's null-node base case is inside.
+
+## Retail oracle
+
+`CObjCell::find_cell_list @ 0x0052B4E0` in
+`docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the
+availability gate:
+
+- `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at
+ `0x0052B50C..0x0052B515`;
+- the outdoor branch still calls `CLandCell::add_all_outside_cells` at
+ `0x0052B53F`, even when that seed lookup returned null;
+- the complete growing-array transit walk and containing-cell pick are gated
+ by `seed != null && num_spheres != 0` at `0x0052B576`;
+- each later candidate is independently skipped when its stored cell pointer
+ is null at `0x0052B58E`.
+
+`CCellStruct::point_in_cell @ 0x005338F0` delegates directly to
+`BSPTREE::point_inside_cell_bsp @ 0x005398C0`. The already-ported graph and
+flat BSP queries preserve the retail null-root base case: a negative/null root
+returns true. Root presence is therefore not an availability predicate.
+
+## Ported behavior
+
+- `PhysicsDataCache` now publishes a `CellPhysics` record whenever an authored
+ raw or prepared CellStruct payload exists, even if its physics BSP and/or
+ containment BSP root is absent.
+- `CollisionTraversal.HasCellContainment` tests representation payload
+ availability, not `Root` / `RootIndex`. `PointInsideCell` then lets the
+ graph or flat query return true for the null-root base case.
+- `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells,
+ but skips the transit walk when the active outdoor seed cannot be resolved
+ from `CellGraph`. A separately cached building can no longer promote an
+ object through an unavailable landcell.
+- The existing reflood lifecycle remains the recovery mechanism. Once terrain
+ or an indoor CellStruct publishes, the next reflood walks the same authored
+ portal/building relationships without reconstructing a different rule.
+
+## Gates
+
+Focused tests cover raw graph and prepared flat cache publication, absent
+versus loaded-null-root containment, indoor and outdoor seeds, preservation of
+outside-cell seeding, suppression of spurious building promotion, and
+hydration/reflood recovery. Final Release gates passed: Core 4,162 / 1 skipped,
+Runtime 440 / 0 skipped, App 4,002 / 3 skipped, plus the complete solution
+build with zero errors.
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 3c5cc1ed..1c2855e8 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -577,10 +577,12 @@ public static class CellTransit
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;
+ // Retail preserves the outside-cell additions above but skips
+ // the complete growing-array transit walk when GetVisible cannot
+ // resolve the ACTIVE seed CLandCell (0052b50e, 0052b576). A cached
+ // building alone must not promote an object through an unavailable
+ // landcell; the normal reflood after terrain publication retries.
+ seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
}
if (seedLoaded)
@@ -664,11 +666,9 @@ public static class CellTransit
///
///
///
- /// acdream adaptation (matches at line 518): a cell
- /// with no hydrated cannot run
- /// point_in_cell, so it is treated as NOT containing the point (skipped),
- /// rather than letting 's null-node
- /// "inside" default make it spuriously claim every point.
+ /// A missing record is unavailable and skipped.
+ /// A loaded record whose authored containment root is null retains retail's
+ /// universal-inside base case.
///
///
public static uint FindVisibleChildCell(
@@ -705,8 +705,8 @@ public static class CellTransit
///
/// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then .
- /// A cell with no hydrated returns false (see
- /// 's adaptation note).
+ /// A missing cell payload returns false; a loaded payload with a null root
+ /// returns true through the retail BSP base case.
///
private static bool PointInCell(
PhysicsDataCache cache,
diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs
index d75f4bea..a08a188b 100644
--- a/src/AcDream.Core/Physics/CollisionTraversal.cs
+++ b/src/AcDream.Core/Physics/CollisionTraversal.cs
@@ -25,9 +25,14 @@ internal static class CollisionTraversal
{
if (UseFlat(cache))
{
- FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
+ // Availability is the authored CellStruct payload, not the
+ // containment root. Retail's loaded BSPTREE may have a null
+ // root; BSPNODE::point_inside_cell_bsp treats that base case as
+ // universally inside. A missing flat payload is still a broken
+ // production publication and must fail loudly.
+ _ = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
- bool flatAuthorityResult = flat.RootIndex >= 0;
+ const bool flatAuthorityResult = true;
CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
if (flatShadow is null ||
!flatShadow.TrySample(out long flatAuthoritySample))
@@ -38,7 +43,7 @@ internal static class CollisionTraversal
flatShadow.BeginGraphPass();
try
{
- graphRefereeResult = cell.CellBSP?.Root is not null;
+ graphRefereeResult = true;
}
catch (Exception fault)
{
@@ -74,15 +79,16 @@ internal static class CollisionTraversal
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
- return cell.CellBSP?.Root is not null;
+ return true;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
- flatResult = (cell.FlatContainmentBsp ??
- throw MissingFlat("cell containment")).RootIndex >= 0;
+ _ = cell.FlatContainmentBsp ??
+ throw MissingFlat("cell containment");
+ flatResult = true;
}
catch (Exception fault)
{
@@ -93,7 +99,7 @@ internal static class CollisionTraversal
shadow.EndFlatPass();
}
- bool graphResult = cell.CellBSP?.Root is not null;
+ const bool graphResult = true;
if (flatFault is null)
{
shadow.RecordBoolean(
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index b7a520b2..43d2fbb4 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
///
/// The unified cell graph (UCG): the active id->cell resolver and registry.
- /// Populated unconditionally in — BEFORE the
- /// idempotency + null-BSP guards, so BSP-less cells are registered too — and
+ /// Populated unconditionally in so BSP-less
+ /// authored cells are registered too, and
/// consumed across the engine: the player render/lighting root
/// (CellGraph.CurrCell, written at the player chokepoint
/// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the
@@ -364,9 +364,11 @@ public sealed class PhysicsDataCache
}
///
- /// Extract and cache the physics BSP + polygon data from a CellStruct
- /// (indoor room geometry). No-ops if the id is already cached or the
- /// CellStruct has no physics BSP.
+ /// Extract and cache the authored CellStruct payload (indoor room
+ /// geometry), including cells whose physics or containment BSP has a null
+ /// root. Retail keeps those loaded cells distinct from an unavailable
+ /// visible-cell lookup; the null containment root is universally inside.
+ /// No-ops only when the id is already cached.
///
public void CacheCellStruct(
uint envCellId,
@@ -414,8 +416,7 @@ public sealed class PhysicsDataCache
return;
}
- // UCG Stage 1: register in the unified graph for ALL cells — before the
- // idempotency + null-BSP guards below, so BSP-less cells are still included.
+ // UCG Stage 1: register in the unified graph for every authored cell.
if (!CellGraph.Contains(envCellId))
{
CellGraph.Add(UcgEnvCell.FromDat(
@@ -427,11 +428,12 @@ public sealed class PhysicsDataCache
}
if (_cellStruct.ContainsKey(envCellId)) return;
- if (cellStruct.PhysicsBSP?.Root is null) return;
Matrix4x4.Invert(worldTransform, out var inverseTransform);
- var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
+ var resolved = cellStruct.PhysicsPolygons is null
+ ? new Dictionary()
+ : ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
@@ -628,11 +630,9 @@ public sealed class PhysicsDataCache
preparedTopology));
}
- // Preserve CacheCellStruct's existing distinction: BSP-less cells
- // participate in the cell graph but do not masquerade as hydrated
- // collision cells.
- if (preparedStructure.PhysicsBsp.RootIndex < 0)
- return;
+ // The prepared structure itself is the loaded CellStruct payload.
+ // Empty physics and containment roots remain meaningful authored
+ // values; neither means that the cell is unavailable.
if (_cellStruct.ContainsKey(envCellId))
return;
@@ -1023,8 +1023,9 @@ public sealed class CellPhysics
/// (point-in-cell tests). Separate tree from
/// (collision) and from the renderer's drawing-BSP.
/// Source: cellStruct.CellBSP at cache time.
- /// Nullable: cells without a CellBSP cannot participate in portal
- /// containment and are skipped by .
+ /// A nullable root is an authored, universally-inside containment tree.
+ /// Cell availability is represented by presence of this
+ /// record, not by root presence.
///
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs
index 873b00b1..d8eed135 100644
--- a/src/AcDream.Core/World/Cells/CellGraph.cs
+++ b/src/AcDream.Core/World/Cells/CellGraph.cs
@@ -8,8 +8,8 @@ namespace AcDream.Core.World.Cells;
///
/// The unified cell graph: the active, authoritative id->cell resolver and registry.
/// Populated unconditionally from
-/// (before its
-/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across
+/// (including
+/// authored cells with null physics or containment roots) and consumed across
/// the engine: resolves any cell id, is
/// the player render/lighting root, resolves the
/// 3rd-person camera cell, and supplies the block-local
diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs
index c4b96750..d18039c6 100644
--- a/src/AcDream.Core/World/Cells/EnvCell.cs
+++ b/src/AcDream.Core/World/Cells/EnvCell.cs
@@ -10,7 +10,11 @@ namespace AcDream.Core.World.Cells;
/// Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072).
public sealed class EnvCell : ObjCell
{
- /// Cell-containment BSP (retail CellStruct.CellBSP). Null => AABB fallback.
+ ///
+ /// Cell-containment BSP (retail CellStruct.CellBSP). A present tree with a
+ /// null root is universally inside; an absent test/tooling payload uses the
+ /// legacy AABB fallback.
+ ///
public CellBSPTree? ContainmentBsp { get; }
///
@@ -37,7 +41,7 @@ public sealed class EnvCell : ObjCell
var local = Vector3.Transform(worldPoint, InverseWorldTransform);
if (FlatContainmentBsp is not null)
return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local);
- if (ContainmentBsp?.Root is not null)
+ if (ContainmentBsp is not null)
return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034
return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X
&& local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y
diff --git a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
index 448f100d..274ad311 100644
--- a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using DatReaderWriter.Enums;
@@ -74,6 +75,64 @@ public class BuildShadowCellSetTests
};
}
+ private static CellPhysics MakeNullRootCell(Matrix4x4 worldTransform)
+ {
+ Matrix4x4.Invert(worldTransform, out var inv);
+ return new CellPhysics
+ {
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree { Root = null },
+ FlatContainmentBsp = new FlatCellContainmentBsp(
+ -1,
+ ImmutableArray.Empty),
+ };
+ }
+
+ private static CellPhysics MakeNullRootCellWithExteriorPortal(
+ Matrix4x4 worldTransform)
+ {
+ Matrix4x4.Invert(worldTransform, out var inv);
+ var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f);
+ return new CellPhysics
+ {
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree { Root = null },
+ FlatContainmentBsp = new FlatCellContainmentBsp(
+ -1,
+ ImmutableArray.Empty),
+ PortalPolygons = new Dictionary
+ {
+ [10] = new ResolvedPolygon
+ {
+ Vertices =
+ [
+ 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 = portalPlane,
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ },
+ },
+ Portals =
+ [
+ new PortalInfo(otherCellId: 0xFFFF, polygonId: 10, flags: 0),
+ ],
+ };
+ }
+
+ private static void RegisterFlatTerrain(PhysicsDataCache cache)
+ => cache.CellGraph.RegisterTerrain(
+ 0xA9B40000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ Vector3.Zero);
+
// ── Seeds ──────────────────────────────────────────────────────────
[Fact]
@@ -129,6 +188,42 @@ public class BuildShadowCellSetTests
Assert.Equal(new[] { IndoorSeed }, set);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void IndoorSeed_RefloodsAfterNullRootPayloadHydrates(
+ bool useFlat)
+ {
+ var cache = new PhysicsDataCache
+ {
+ CollisionTraversalMode = useFlat
+ ? CollisionTraversalMode.Flat
+ : CollisionTraversalMode.Graph,
+ };
+ Sphere[] sphere = One(new Vector3(2.4f, 0f, 2.5f), 0.5f);
+
+ IReadOnlyList unavailable = CellTransit.BuildShadowCellSet(
+ cache,
+ IndoorSeed,
+ sphere,
+ 1,
+ isStatic: false);
+ Assert.Equal(new[] { IndoorSeed }, unavailable);
+
+ cache.RegisterCellStructForTest(
+ IndoorSeed,
+ MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity));
+ IReadOnlyList hydrated = CellTransit.BuildShadowCellSet(
+ cache,
+ IndoorSeed,
+ sphere,
+ 1,
+ isStatic: false);
+
+ Assert.Contains(IndoorSeed, hydrated);
+ Assert.Contains(hydrated, id => (id & 0xFFFFu) < 0x0100u);
+ }
+
[Fact]
public void OutdoorSeed_FloodsOverlappedLandcells_BlockCrossingMath()
{
@@ -155,6 +250,7 @@ public class BuildShadowCellSetTests
// vestibule's shadow_object_list at registration via
// CLandCell::find_transit_cells → ... → check_building_transit.
var cache = new PhysicsDataCache();
+ RegisterFlatTerrain(cache);
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
@@ -188,6 +284,7 @@ public class BuildShadowCellSetTests
// other_portal_id = -1 (wire 0xFFFF) never admits its interior cell
// (CEnvCell::check_building_transit, 0x0052c5dc).
var cache = new PhysicsDataCache();
+ RegisterFlatTerrain(cache);
cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
@@ -211,6 +308,63 @@ public class BuildShadowCellSetTests
Assert.DoesNotContain(NeighborCell, set);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void UnavailableOutdoorSeed_AddsOutsideButSkipsTransit_UntilTerrainHydrates(
+ bool useFlat)
+ {
+ var cache = new PhysicsDataCache
+ {
+ CollisionTraversalMode = useFlat
+ ? CollisionTraversalMode.Flat
+ : CollisionTraversalMode.Graph,
+ };
+ cache.RegisterCellStructForTest(
+ NeighborCell,
+ MakeNullRootCell(Matrix4x4.Identity));
+
+ var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
+ IReadOnlyList seeded = CellTransit.BuildShadowCellSet(
+ cache,
+ 0xA9B40001u,
+ sphere,
+ 1,
+ isStatic: false);
+ uint landcell = seeded[0];
+ cache.RegisterBuildingForTest(landcell, new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals =
+ [
+ new BldPortalInfo(NeighborCell, otherPortalId: 0, flags: 0),
+ ],
+ });
+
+ IReadOnlyList unavailable = CellTransit.BuildShadowCellSet(
+ cache,
+ 0xA9B40001u,
+ sphere,
+ 1,
+ isStatic: false);
+
+ Assert.NotEmpty(unavailable);
+ Assert.All(unavailable, id => Assert.True((id & 0xFFFFu) < 0x0100u));
+ Assert.DoesNotContain(NeighborCell, unavailable);
+
+ RegisterFlatTerrain(cache);
+ IReadOnlyList hydrated = CellTransit.BuildShadowCellSet(
+ cache,
+ 0xA9B40001u,
+ sphere,
+ 1,
+ isStatic: false);
+
+ Assert.Contains(landcell, hydrated);
+ Assert.Contains(NeighborCell, hydrated);
+ }
+
// ── Exterior straddle from an indoor seed ──────────────────────────
[Fact]
diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs
index ec99de63..843c0512 100644
--- a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs
@@ -11,14 +11,14 @@ namespace AcDream.Core.Tests.Physics;
public class CellGraphPopulationTests
{
[Fact]
- public void CacheCellStruct_AddsEnvCellToGraph_EvenWhenPhysicsBspIsNull()
+ public void CacheCellStruct_PublishesLoadedCell_WhenPhysicsAndContainmentRootsAreNull()
{
var cache = new PhysicsDataCache();
var cellStruct = new CellStruct
{
VertexArray = new VertexArray { Vertices = new Dictionary() },
Polygons = new Dictionary(),
- // PhysicsBSP omitted (defaults to null) — triggers the null-BSP drop from _cellStruct
+ CellBSP = new CellBSPTree { Root = null },
};
var dat = new DatEnvCell
{
@@ -29,8 +29,14 @@ public class CellGraphPopulationTests
cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity);
- Assert.Null(cache.GetCellStruct(0xA9B40174u)); // dropped from physics cache
- Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u)); // but present in the graph
+ CellPhysics loaded = Assert.IsType(
+ cache.GetCellStruct(0xA9B40174u));
+ Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
+ Assert.True(CollisionTraversal.PointInsideCell(
+ cache,
+ loaded,
+ new Vector3(10_000f, -10_000f, 500f)));
+ Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u));
Assert.IsType(cache.CellGraph.GetVisible(0xA9B40174u));
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
index 722e94fa..265fc0d6 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
@@ -8,16 +8,11 @@ namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests
{
[Fact]
- public void BuildingPortalWithUnloadedCellBSP_NoCandidateAdded()
+ public void BuildingPortalWithLoadedNullRoot_CellIsAdmitted()
{
- // Verifies the null-CellBSP guard: when the destination interior cell
- // is cached but its CellBSP isn't yet loaded (or is structurally absent),
- // CheckBuildingTransit must NOT add the cell to candidates — even though
- // PointInsideCellBsp(null, _) returns true.
- //
- // Happy-path (CellBSP present, sphere inside) requires a synthetic
- // CellBSPTree which is non-trivial to construct from DatReaderWriter
- // types. Deferred to visual verification.
+ // Retail separates an unavailable CEnvCell lookup from an authored
+ // CellStruct whose cell_bsp root is null. The latter is loaded, and
+ // the null-root sphere query is the universal-inside base case.
// Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics
@@ -33,14 +28,13 @@ public class CellTransitCheckBuildingTransitTests
},
};
- // Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true,
- // but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this
- // cell is skipped.
+ // Interior cell with an authored null containment root.
var interiorCell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary(),
+ CellBSP = new DatReaderWriter.Types.CellBSPTree { Root = null },
};
var cache = new PhysicsDataCache();
@@ -53,8 +47,33 @@ public class CellTransitCheckBuildingTransitTests
sphereRadius: 0.5f,
candidates);
- // CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null)
- // skips this cell. No candidate added.
+ Assert.Contains(0xA9B40100u, candidates);
+ }
+
+ [Fact]
+ public void BuildingPortalWithUnavailableCell_NoCandidateAdded()
+ {
+ var building = new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals =
+ [
+ new BldPortalInfo(
+ otherCellId: 0xA9B40100u,
+ otherPortalId: 0,
+ flags: 0),
+ ],
+ };
+ var candidates = new HashSet();
+
+ CellTransit.CheckBuildingTransit(
+ new PhysicsDataCache(),
+ building,
+ worldSphereCenter: Vector3.Zero,
+ sphereRadius: 0.5f,
+ candidates);
+
Assert.Empty(candidates);
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
index cdbf5afa..dd849175 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
@@ -315,10 +315,11 @@ public class CellTransitFindCellSetTests
}
[Fact]
- public void IndoorSeed_CellWithoutBsp_CannotVerify_StaysCurrent()
+ public void IndoorSeed_LoadedNullRoot_IsUniversallyInside_StaysCurrent()
{
- // Stale-beats-null while streaming hydrates: a registered cell with
- // no CellBSP yet cannot be verified — trust the claim (no demotion).
+ // Retail distinguishes a failed cell lookup from a loaded CellStruct
+ // whose containment root is null. The latter is the BSP query's
+ // universally-inside base case, so the current cell wins immediately.
Matrix4x4.Invert(Matrix4x4.Identity, out var inv);
var cellNoBsp = new CellPhysics
{
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
index e02ed83b..45b95f5c 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
@@ -160,4 +160,50 @@ public sealed class PhysicsDataCacheProductionTests
structure.ContainmentBsp,
runtimeCell.FlatContainmentBsp);
}
+
+ [Fact]
+ public void ProductionCellPublication_PreservesLoadedCellWithEmptyRoots()
+ {
+ const uint cellId = 0xA9B4_0174u;
+ PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
+ var emptyPhysics = new FlatPhysicsBsp(
+ -1,
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ FlatPolygonTable.Empty);
+ var emptyContainment = new FlatCellContainmentBsp(
+ -1,
+ ImmutableArray.Empty);
+ var structure = new FlatCellStructureCollisionAsset(
+ emptyPhysics,
+ emptyContainment,
+ FlatPolygonTable.Empty);
+ var topology = new FlatEnvCellTopology(
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ seenOutside: false);
+
+ cache.CacheCellStruct(
+ cellId,
+ new EnvCell(),
+ Matrix4x4.Identity,
+ structure,
+ topology);
+
+ CellPhysics loaded = Assert.IsType(
+ cache.GetCellStruct(cellId));
+ Assert.False(CollisionTraversal.HasPhysics(cache, loaded));
+ Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
+ Assert.True(CollisionTraversal.PointInsideCell(
+ cache,
+ loaded,
+ new Vector3(10_000f, -10_000f, 500f)));
+ var graphCell = Assert.IsType(
+ cache.CellGraph.GetVisible(cellId));
+ Assert.True(graphCell.PointInCell(
+ new Vector3(10_000f, -10_000f, 500f)));
+ Assert.Equal(1, cache.CellStructCount);
+ Assert.Equal(1, cache.FlatCellStructCount);
+ Assert.Equal(0, cache.GraphCellStructCount);
+ }
}
diff --git a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
index 7c1ddda0..e099c2ae 100644
--- a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
+++ b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
@@ -27,6 +27,23 @@ public class EnvCellTests
public void PointInCell_NullBsp_Aabb_OutsideIsFalse()
=> Assert.False(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(20,5,5)));
+ [Fact]
+ public void PointInCell_LoadedNullRoot_IsUniversallyInside()
+ {
+ var cell = new EnvCell(
+ 0xA9B4_0174u,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ Vector3.Zero,
+ Vector3.One,
+ Array.Empty(),
+ Array.Empty(),
+ seenOutside: false,
+ containmentBsp: new CellBSPTree { Root = null });
+
+ Assert.True(cell.PointInCell(new Vector3(10_000f, -10_000f, 500f)));
+ }
+
[Fact]
public void PointInCell_TransformsWorldToLocalBeforeTesting()
{