fix(physics): restore retail cell availability semantics

This commit is contained in:
Erik 2026-07-31 14:22:45 +02:00
parent d3c0d9ec0e
commit 7716c2ee89
14 changed files with 393 additions and 62 deletions

View file

@ -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 |

View file

@ -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

View file

@ -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.

View file

@ -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
/// </para>
///
/// <para>
/// acdream adaptation (matches <see cref="FindCellList"/> at line 518): a cell
/// with no hydrated <see cref="CellPhysics.CellBSP"/> cannot run
/// <c>point_in_cell</c>, so it is treated as NOT containing the point (skipped),
/// rather than letting <see cref="BSPQuery.PointInsideCellBsp"/>'s null-node
/// "inside" default make it spuriously claim every point.
/// A missing <see cref="CellPhysics"/> record is unavailable and skipped.
/// A loaded record whose authored containment root is null retains retail's
/// <see cref="BSPQuery.PointInsideCellBsp"/> universal-inside base case.
/// </para>
/// </summary>
public static uint FindVisibleChildCell(
@ -705,8 +705,8 @@ public static class CellTransit
/// <summary>
/// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>.
/// A cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see
/// <see cref="FindVisibleChildCell"/>'s adaptation note).
/// A missing cell payload returns false; a loaded payload with a null root
/// returns true through the retail BSP base case.
/// </summary>
private static bool PointInCell(
PhysicsDataCache cache,

View file

@ -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(

View file

@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
/// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> — BEFORE the
/// idempotency + null-BSP guards, so BSP-less cells are registered too — and
/// Populated unconditionally in <see cref="CacheCellStruct"/> so BSP-less
/// authored cells are registered too, and
/// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
@ -364,9 +364,11 @@ public sealed class PhysicsDataCache
}
/// <summary>
/// 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.
/// </summary>
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<ushort, ResolvedPolygon>()
: 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 <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// A nullable root is an authored, universally-inside containment tree.
/// Cell availability is represented by presence of this
/// <see cref="CellPhysics"/> record, not by root presence.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }

View file

@ -8,8 +8,8 @@ namespace AcDream.Core.World.Cells;
/// <summary>
/// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry.
/// Populated unconditionally from
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (before its
/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (including
/// authored cells with null physics or containment roots) and consumed across
/// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is
/// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
/// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local

View file

@ -10,7 +10,11 @@ namespace AcDream.Core.World.Cells;
/// <summary>Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072).</summary>
public sealed class EnvCell : ObjCell
{
/// <summary>Cell-containment BSP (retail CellStruct.CellBSP). Null =&gt; AABB fallback.</summary>
/// <summary>
/// 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.
/// </summary>
public CellBSPTree? ContainmentBsp { get; }
/// <summary>
@ -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

View file

@ -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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>
{
[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<uint> unavailable = CellTransit.BuildShadowCellSet(
cache,
IndoorSeed,
sphere,
1,
isStatic: false);
Assert.Equal(new[] { IndoorSeed }, unavailable);
cache.RegisterCellStructForTest(
IndoorSeed,
MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity));
IReadOnlyList<uint> 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<uint> 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<uint> 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<uint> hydrated = CellTransit.BuildShadowCellSet(
cache,
0xA9B40001u,
sphere,
1,
isStatic: false);
Assert.Contains(landcell, hydrated);
Assert.Contains(NeighborCell, hydrated);
}
// ── Exterior straddle from an indoor seed ──────────────────────────
[Fact]

View file

@ -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<ushort, SWVertex>() },
Polygons = new Dictionary<ushort, Polygon>(),
// 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<CellPhysics>(
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<EnvCell>(cache.CellGraph.GetVisible(0xA9B40174u));
}
}

View file

@ -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<ushort, ResolvedPolygon>(),
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<uint>();
CellTransit.CheckBuildingTransit(
new PhysicsDataCache(),
building,
worldSphereCenter: Vector3.Zero,
sphereRadius: 0.5f,
candidates);
Assert.Empty(candidates);
}

View file

@ -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
{

View file

@ -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<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
var emptyContainment = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty);
var structure = new FlatCellStructureCollisionAsset(
emptyPhysics,
emptyContainment,
FlatPolygonTable.Empty);
var topology = new FlatEnvCellTopology(
ImmutableArray<FlatEnvCellPortal>.Empty,
ImmutableArray<uint>.Empty,
seenOutside: false);
cache.CacheCellStruct(
cellId,
new EnvCell(),
Matrix4x4.Identity,
structure,
topology);
CellPhysics loaded = Assert.IsType<CellPhysics>(
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<AcDream.Core.World.Cells.EnvCell>(
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);
}
}

View file

@ -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<UcgCellPortal>(),
Array.Empty<uint>(),
seenOutside: false,
containmentBsp: new CellBSPTree { Root = null });
Assert.True(cell.PointInCell(new Vector3(10_000f, -10_000f, 500f)));
}
[Fact]
public void PointInCell_TransformsWorldToLocalBeforeTesting()
{