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() {