diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index 93c77ef3..3180232b 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -176,21 +176,33 @@ Likewise `PView::DrawCells` calls `DrawEnvCell` once per admitted cell and
draws its complete constructed shell. Portal polygons are not GPU mesh clips;
pixel occlusion comes from the ordered walk and depth buffer.
-**Retail static render-shadow cell membership (2026-08-31).** Indoor
-statics are not rendered solely from their authored parent EnvCell. Retail's
+**Retail static render-shadow cell membership (2026-08-31; ownership moved
+to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2).** Indoor statics
+are not rendered solely from their authored parent EnvCell. Retail's
`CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static,
then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every
visual part in each crossed cell's `shadow_part_list`, including decorative
-GfxObjs that have no collision BSP. `WalkProductionWorldData` therefore owns
-a distinct render-only per-cell index built from each MeshRef's prepared
-visual AABB and the existing exact static part transit walk. This must not be
-collapsed into `ShadowObjectRegistry`'s collision rows: the two retail lists
-share transit math but have different membership. A primitive-only Setup may
-reuse its already-authored cylsphere collision cell set; BSP-bearing and
-pure-visual statics use the all-visual-part box walk. The Facility Hub stair
-Setup `0x02000623` is the installed-DAT regression: its authored parent is
-`0x8A02015F`, while its visual parts also register in `0x8A02015E` and the
-adjoining vertical cells.
+GfxObjs that have no collision BSP. `ShadowObjectRegistry` owns this product
+as a retained per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus
+per-cell part entries (`GetRetailPartEntriesInCell`), computed once at the
+SAME registration transaction as the collision flood
+(`RegisterMultiPart`/`Register`'s optional `partArray:` parameter) — not
+rebuilt per frame. `WalkProductionWorldData`'s indoor and outdoor static
+sweeps BORROW that array whole per record; they no longer recompute
+membership from each MeshRef's prepared visual AABB (the deleted
+`ResolveStaticRenderCells` / `ShadowObjectRegistry.ComputeStaticRenderCells`
+pair). The retail CELLARRAY remains distinct from `ShadowObjectRegistry`'s
+own collision rows (`GetOwnerCells`/`_entityToCells`) — the two retail lists
+share transit math but have different membership (collision cutover to the
+same CELLARRAY is S2 chunk 3) — until then, a record whose registry entry has
+no retail array yet (a streaming-window race between the static-projection
+journal and the physics publisher) falls back to its authored parent cell
+alone, counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`.
+A primitive-only Setup may reuse its already-authored cylsphere collision
+cell set; BSP-bearing and pure-visual statics use the all-visual-part box
+walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
+regression: its authored parent is `0x8A02015F`, while its visual parts also
+register in `0x8A02015E` and the adjoining vertical cells.
Moving multipart objects follow the sibling retail route rather than their
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
diff --git a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md
index 224394c8..0ab3b891 100644
--- a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md
+++ b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md
@@ -37,7 +37,7 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
| Owner | Inputs | Output | Runs | Consumers |
|---|---|---|---|---|
| `LandblockPhysicsPublisher.PublishStaticEntity` → `ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs:493-584`) | BSP parts only (`ShadowShapeBuilder.FromLandblockBspParts`) or Setup cyl/sphere fallback; `CellTransit.BuildShadowCellSetFromParts` | `_entityToCells[id]` (ordered, the CELLARRAY analog) + `_cells[cell]` `ShadowEntry` rows | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); outdoor static render lookup reads it verbatim |
-| `WalkProductionWorldData.ResolveStaticRenderCells` → `ShadowObjectRegistry.ComputeStaticRenderCells` (`WalkProductionWorldData.cs:285-366`, `ShadowObjectRegistry.cs:371-394`) | ALL visual parts (`ShadowShapeBuilder.FromStaticRenderParts`: physics sphere else visual-AABB circumsphere; exact visual box); same `BuildShadowCellSetFromParts` | per-object cell list, cached by fingerprint; whole `RenderProjectionRecord` bucketed into every cell | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic` | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union |
+| **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells` → `ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells` → `ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, computed once at registration | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` |
| `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs:881-982`) | `ShadowShapeBuilder.FromSetup` (BSP parts, else cylspheres, else spheres) | `_entityToCells` + `_cells` | spawn and every accepted move/appearance change | physics broadphase; dynamic render lookup reads it verbatim |
| `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` |
| `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) |
@@ -78,12 +78,25 @@ formation, an outdoor landblock-edge static, a multi-part Setup) reports the
three answers side by side: retail CELLARRAY, current collision cells,
current render cells. Differences are evidence for chunks 2/3, not failures.
-**Chunk 2 — render statics cut over.** `WalkProductionWorldData` indoor and
-outdoor static buckets are filled from the registry's per-cell part entries
-(grouped back to records for the existing draw path; per-part draw
-admission is S3). `ResolveStaticRenderCells`, `ComputeStaticRenderCells`,
-the fingerprint cache, and App's use of `FromStaticRenderParts` are deleted.
-The Facility stair pin moves to the registry.
+**Chunk 2 — render statics cut over (landed 2026-09-02).** `WalkProductionWorldData`
+indoor and outdoor static buckets are filled whole-record-per-cell from the
+registry's retail CELLARRAY (`TryGetRetailCellArray`), not from its per-cell
+part entries: per-part draw admission is S3's job, and today's draw path
+still consumes whole `RenderProjectionRecord`s, so grouping
+`GetRetailPartEntriesInCell` back into records here would be a no-op
+detour. `ResolveStaticRenderCells`, `ComputeStaticRenderCells`, the
+fingerprint cache (`_staticRenderCellCache` + its revision/generation
+invalidation), the `primitiveSetup` special case, and
+`WalkProductionWorldData`'s use of `ShadowShapeBuilder.FromStaticRenderParts`
+are deleted. An entity with no retail array yet (the streaming-window race
+between the static-projection journal and the physics publisher — AD-40's
+residency reasoning) falls back to a conservative single-cell membership
+(indoor: authored `ParentCellId`; outdoor: today's `GetOwnerCells`), counted
+by the new `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`
+for chunk 5 to weigh against the connected-route count. The Facility stair
+pin (`Issue177StairDescentCameraFloodTests.FacilityStairAssembly_*`) moved to
+registering through the production `RegisterMultiPart(..., partArray:)` +
+`TryGetRetailCellArray` recipe.
**Chunk 3 — collision cut over.** `_entityToCells`/`_cells` are populated
from the retail CELLARRAY (collision shapes into every array cell). Physics
diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
index cb149869..cad00897 100644
--- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
@@ -14,10 +14,23 @@ namespace AcDream.App.Rendering.Walk;
///
///
/// - Cell statics — ONE sweep
-/// over authored indoor statics, bucketed into every cell crossed by the
-/// object's visual parts. This is retail's CPartArray::AddPartsShadow
-/// render index, which deliberately includes non-colliding decorations and
-/// is distinct from the physics shadow-object index.
+/// over authored indoor statics, bucketed into every cell of
+/// 's retail
+/// CELLARRAY. Campaign OVERHAUL S2 chunk 2: membership is BORROWED from that
+/// registry — the sole owner of retail's calc_cross_cells_static
+/// (0x00515160) → CPartArray::AddPartsShadow (0x00517e40) transaction
+/// — never rebuilt in this class. A registered entity's array deliberately
+/// includes non-colliding decorations and is distinct from the physics
+/// shadow-object index ().
+/// An entity the registry has not registered yet (the streaming window
+/// where the static-projection journal published the record before the
+/// physics publisher registered the entity — two independent incremental
+/// state machines) falls back to its authored
+/// alone and is counted in
+/// . Per-cell PART
+/// entries ()
+/// are not consumed here — buckets stay whole-record per cell, which equals
+/// CELLARRAY membership; per-part draw admission is Campaign OVERHAUL S3.
/// - Live dynamics — ONE global dynamic-index sweep, bucketed into every
/// cell in the object's retained physics CELLARRAY. Retail feeds that
/// exact array to CPhysicsObj::add_shadows_to_cells, which calls
@@ -26,12 +39,12 @@ namespace AcDream.App.Rendering.Walk;
/// the head cell; indexing only by its authored parent makes individual body
/// parts disappear at the portal edge.
/// - Outdoor statics — ONE
-/// sweep bucketed by landscape cell id
-/// ((lb & 0xFFFF0000) | (cellX*8 + cellY + 1) from the record's
-/// world position — the same encoding
-/// computes), EXCLUDING building shells (they draw at their building's own
-/// shell turn, retail CPhysicsPart::Draw(parts, 0) @0x0059f331, not
-/// at the cell's DrawObjCell turn).
+/// sweep bucketed the same way as cell statics — the registry's retail
+/// CELLARRAY first, falling back to
+/// (counted the same way)
+/// when unregistered — EXCLUDING building shells (they draw at their
+/// building's own shell turn, retail CPhysicsPart::Draw(parts, 0)
+/// @0x0059f331, not at the cell's DrawObjCell turn).
/// - Building shells — the same sweep's IsBuildingShell records
/// bucketed by Source.BuildingShellAnchorCellId for portal-bearing
/// buildings. Portal-less buildings have no interior anchor; retail still
@@ -82,10 +95,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary _outdoorMaterialized = new();
private readonly Dictionary _outdoorDynamicsMaterialized = new();
private readonly Dictionary _shellMaterialized = new();
- private readonly Dictionary
- _staticRenderCellCache = new();
- private ulong _staticRenderCellIndexRevision = ulong.MaxValue;
- private RenderSceneGeneration _staticRenderCellGeneration;
+ private readonly Func Cells)> _tryGetRetailCellArray;
private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256];
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256];
@@ -103,10 +113,37 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_findParentLocalId = findParentLocalId ?? NoParentLocalId;
+ _tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
}
private static uint? NoParentLocalId(uint _) => null;
+ private (bool Found, IReadOnlyList Cells) TryGetRetailCellArrayForEntity(uint entityId)
+ {
+ bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList cells);
+ return (found, cells);
+ }
+
+ ///
+ /// Campaign OVERHAUL S2 chunk 2: how many indoor/outdoor static records
+ /// this frame fell back to a conservative single-cell membership
+ /// (indoor: the authored ;
+ /// outdoor: today's collision-flood
+ /// answer) because
+ /// had no retail
+ /// CELLARRAY registered yet for the entity. Retail has no such gap —
+ /// CEnvCell::init_static_objects installs the CELLARRAY before a
+ /// static is ever drawable — so a nonzero count here reflects two
+ /// independent incremental state machines (the static-projection journal
+ /// versus the physics publisher) racing during streaming, not
+ /// steady-state behavior; see AD-40's residency reasoning in
+ /// . Reset to zero
+ /// at the start of every . Campaign OVERHAUL S2
+ /// chunk 5 decides this adaptation's fate against the connected-route
+ /// count.
+ ///
+ public int UnregisteredStaticRenderFallbackCount { get; private set; }
+
/// Rebuilds the frame's outdoor/shell buckets and clears the
/// per-cell cache. Call once per frame before the driver runs.
/// /
@@ -132,6 +169,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear();
_arenaLength = 0;
+ UnregisteredStaticRenderFallbackCount = 0;
foreach (List bucket in _indoorByCell.Values)
bucket.Clear();
foreach (List bucket in _indoorDynamicsByCell.Values)
@@ -143,19 +181,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
foreach (List bucket in _shellsByAnchor.Values)
bucket.Clear();
- if (_staticRenderCellIndexRevision != scene.IndexRevision
- || _staticRenderCellGeneration != scene.Generation)
- {
- _staticRenderCellCache.Clear();
- _staticRenderCellIndexRevision = scene.IndexRevision;
- _staticRenderCellGeneration = scene.Generation;
- }
-
// Retail CEnvCell::init_static_objects does not leave an object solely
// in its authored parent cell. add_obj_to_cell ->
// calc_cross_cells_static -> CPartArray::AddPartsShadow registers all
// visual parts in every crossed cell, including parts with no physics
- // BSP. Build that render-only index before the walk starts.
+ // BSP. Campaign OVERHAUL S2 chunk 2: that membership is borrowed
+ // whole from ShadowObjectRegistry's retained retail CELLARRAY — never
+ // recomputed here.
int required = _scene.IndexCounts.For(RenderSceneIndex.IndoorCellStatic);
if (required > _indoorSweepScratch.Length)
{
@@ -168,7 +200,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i];
- IReadOnlyList renderCells = ResolveStaticRenderCells(in record);
+ IReadOnlyList renderCells = ResolveIndoorStaticRenderCells(
+ in record, _tryGetRetailCellArray, out bool usedFallback);
+ if (usedFallback)
+ UnregisteredStaticRenderFallbackCount++;
BucketIndoorRecord(
in record,
renderCells,
@@ -198,9 +233,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
shells.Add(record);
continue;
}
+ IReadOnlyList renderCells = ResolveOutdoorStaticRenderCells(
+ in record, _tryGetRetailCellArray, _shadows.GetOwnerCells, out bool usedFallback);
+ if (usedFallback)
+ UnregisteredStaticRenderFallbackCount++;
BucketOutdoorRecord(
in record,
- _shadows.GetOwnerCells(record.Source.LocalEntityId),
+ renderCells,
_outdoorByCell,
_renderCenterLbX,
_renderCenterLbY);
@@ -282,87 +321,70 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return Array.Empty();
}
- private IReadOnlyList ResolveStaticRenderCells(
- in RenderProjectionRecord record)
+ ///
+ /// Campaign OVERHAUL S2 chunk 2: one authored indoor static's render-cell
+ /// membership, borrowed from
+ /// — retail's calc_cross_cells_static (0x00515160) →
+ /// CPartArray::AddPartsShadow (0x00517e40) CELLARRAY. When the
+ /// registry has no retail array yet for this entity (the streaming window
+ /// where the static-projection journal published the record before the
+ /// physics publisher registered the entity — two independent incremental
+ /// state machines; AD-40's residency reasoning in
+ /// ), the LAST-RESORT
+ /// fallback is the authored parent cell alone, and
+ /// reports it so the caller can count it.
+ ///
+ internal static IReadOnlyList ResolveIndoorStaticRenderCells(
+ in RenderProjectionRecord record,
+ Func Cells)> tryGetRetailCellArray,
+ out bool usedFallback)
{
- if (_staticRenderCellCache.TryGetValue(
- record.Id,
- out StaticRenderCellCacheEntry cached)
- && cached.ParentCellId == record.Source.ParentCellId
- && cached.TransformFingerprint == record.Source.TransformFingerprint
- && cached.GeometryFingerprint == record.Source.GeometryFingerprint)
+ ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
+
+ (bool found, IReadOnlyList cells) =
+ tryGetRetailCellArray(record.Source.LocalEntityId);
+ if (found)
{
- return cached.Cells;
+ usedFallback = false;
+ return cells;
}
- IReadOnlyList collisionCells =
- _shadows.GetOwnerCells(record.Source.LocalEntityId);
- IReadOnlyList cells = Array.Empty();
- int visualPartCount = 0;
- bool hasPhysicsBsp = false;
- bool primitiveSetup = false;
- PhysicsDataCache? cache = _shadows.DataCache;
- if (cache is not null
- && record.EntityPayload.MeshRefs is { Count: > 0 } meshRefs)
- {
- List visualParts =
- ShadowShapeBuilder.FromStaticRenderParts(
- meshRefs,
- cache.GetGfxObj,
- cache.GetVisualBounds,
- out hasPhysicsBsp);
- visualPartCount = visualParts.Count;
+ usedFallback = true;
+ return record.Source.ParentCellId != 0u
+ ? new[] { record.Source.ParentCellId }
+ : Array.Empty();
+ }
- // A primitive-only Setup takes retail's cylsphere/sorting-sphere
- // calc_cross_cells_static arm. Its collision registration already
- // carries that exact cell set. BSP-bearing objects and pure visual
- // objects take the per-visual-part box walk instead.
- primitiveSetup =
- (record.Source.SourceId & 0xFF000000u) == 0x02000000u
- && !hasPhysicsBsp
- && collisionCells.Count > 0;
- if (primitiveSetup)
- {
- cells = collisionCells;
- }
- else if (visualParts.Count > 0)
- {
- cells = _shadows.ComputeStaticRenderCells(
- record.Source.ParentCellId,
- record.Transform.Position,
- record.Transform.Rotation,
- visualParts);
- }
+ ///
+ /// Campaign OVERHAUL S2 chunk 2: one authored outdoor static's
+ /// render-cell membership, borrowed from
+ /// the same way
+ /// as . The fallback for an
+ /// entity the registry has not registered yet is today's collision-flood
+ /// answer (itself
+ /// possibly empty — already carries its
+ /// own root-position-cell fallback for that case), counted the same way
+ /// as the indoor path via .
+ ///
+ internal static IReadOnlyList ResolveOutdoorStaticRenderCells(
+ in RenderProjectionRecord record,
+ Func Cells)> tryGetRetailCellArray,
+ Func> getOwnerCells,
+ out bool usedFallback)
+ {
+ ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
+ ArgumentNullException.ThrowIfNull(getOwnerCells);
+
+ (bool found, IReadOnlyList cells) =
+ tryGetRetailCellArray(record.Source.LocalEntityId);
+ if (found)
+ {
+ usedFallback = false;
+ return cells;
}
- if (cells.Count == 0)
- cells = collisionCells.Count > 0
- ? collisionCells
- : record.Source.ParentCellId != 0u
- ? new[] { record.Source.ParentCellId }
- : Array.Empty();
-
- uint[] snapshot = cells as uint[] ?? cells.ToArray();
- if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
- && record.Source.SourceId == 0x020009A2u)
- {
- static string DescribeCells(IReadOnlyList source) =>
- "[" + string.Join(",", source.Select(id => $"0x{id:X8}")) + "]";
- Console.WriteLine(
- $"[cathedral-static-index] source=0x{record.Source.SourceId:X8} "
- + $"local=0x{record.Source.LocalEntityId:X8} "
- + $"parent=0x{record.Source.ParentCellId:X8} "
- + $"parts={visualPartCount} hasPhysicsBsp={(hasPhysicsBsp ? 1 : 0)} "
- + $"primitiveSetup={(primitiveSetup ? 1 : 0)} "
- + $"collision={DescribeCells(collisionCells)} "
- + $"render={DescribeCells(snapshot)}");
- }
- _staticRenderCellCache[record.Id] = new StaticRenderCellCacheEntry(
- record.Source.ParentCellId,
- record.Source.TransformFingerprint,
- record.Source.GeometryFingerprint,
- snapshot);
- return snapshot;
+ usedFallback = true;
+ return getOwnerCells(record.Source.LocalEntityId);
}
internal bool StaticBucketContains(uint cellId, uint sourceId) =>
@@ -759,10 +781,4 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
}
return entry.WorldTransform;
}
-
- private readonly record struct StaticRenderCellCacheEntry(
- uint ParentCellId,
- RenderSceneHash128 TransformFingerprint,
- RenderSceneHash128 GeometryFingerprint,
- uint[] Cells);
}
diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
index 7120daae..33fd6988 100644
--- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
+++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
@@ -957,9 +957,11 @@ public sealed class LandblockPhysicsPublisher
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every visual part of this static, colliding or not — beside the
// BSP-only/primitive-only COLLISION dispatch above and below. The
- // SAME resolver pair WalkProductionWorldData.ResolveStaticRenderCells
- // already reads off this landblock's PhysicsDataCache. A side
- // product only: no consumer reads it yet.
+ // SAME resolver pair this landblock's PhysicsDataCache already
+ // exposes. Chunk 2 (2026-09-02) made WalkProductionWorldData's
+ // indoor/outdoor static sweeps the consumer, via
+ // ShadowObjectRegistry.TryGetRetailCellArray — this call is what
+ // populates that registry product, not a side channel any more.
IReadOnlyList partArray =
ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs,
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index 5883e4dd..4c6802eb 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -400,47 +400,6 @@ public sealed class ShadowObjectRegistry
private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
- ///
- /// Computes retail's static PartArray render-cell membership without
- /// publishing collision rows. Static visual parts and collision shapes
- /// share the same find_bbox_cell_list portal walk, but retail keeps
- /// them in separate cell lists (shadow_part_list versus
- /// shadow_object_list). Decorative meshes therefore need this path
- /// even when is empty.
- ///
- ///
- /// Campaign OVERHAUL S2 chunk 1: RecomputeRetailCellArray's bbox
- /// route is this same primitive, called from the entity's own
- /// registration transaction instead of a per-frame consumer. This
- /// method is that recomputation's predecessor; S2 chunk 2 deletes it
- /// once consumers cut over to .
- ///
- ///
- public IReadOnlyList ComputeStaticRenderCells(
- uint seedCellId,
- Vector3 entityWorldPosition,
- Quaternion entityWorldRotation,
- IReadOnlyList visualParts)
- {
- if (seedCellId == 0u || visualParts.Count == 0)
- return Array.Empty();
-
- List boxes = BuildFloodPartBoxes(
- entityWorldPosition,
- entityWorldRotation,
- visualParts);
- List spheres = BuildBspPartSpheres(
- entityWorldPosition,
- entityWorldRotation,
- visualParts);
- return CellTransit.BuildShadowCellSetFromParts(
- FloodCache,
- seedCellId,
- boxes,
- spheres,
- isStatic: true);
- }
-
///
/// Retail's exact CObjCell::find_cell_list/find_bbox_cell_list
/// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
@@ -473,8 +432,7 @@ public sealed class ShadowObjectRegistry
///
- otherwise the bbox route (CPhysicsObj::find_bbox_cell_list
/// 0x00510fc0 via ) —
/// every other case, including a BSP-bearing object (state bit set) and a
- /// part array with no authored CylSpheres at all. This is the exact
- /// primitive already uses.
+ /// part array with no authored CylSpheres at all.
///
///
private void RecomputeRetailCellArray(
diff --git a/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs
index ccc444ac..4c91b4a9 100644
--- a/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs
+++ b/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs
@@ -81,10 +81,10 @@ internal sealed class LiveEntityCollisionBuilder
///
/// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape
- /// WalkProductionWorldData.ResolveStaticRenderCells passes to
- /// — a static and a
- /// live Setup sharing a GfxObj id can never disagree about its render
- /// geometry. Production: PhysicsDataCache.GetGfxObj.
+ /// takes for a
+ /// landblock-baked static — a static and a live Setup sharing a GfxObj id
+ /// can never disagree about its render geometry. Production:
+ /// PhysicsDataCache.GetGfxObj.
///
private readonly Func _getGfxObj;
diff --git a/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs b/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs
index 2c991299..367d05b3 100644
--- a/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs
+++ b/tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs
@@ -33,16 +33,19 @@ namespace AcDream.App.Tests.Physics;
/// PublishStaticEntity branches, plus the
/// part-array input —
/// against a registry backed by the real , then
-/// prints and compares three answers per fixture: retail's CELLARRAY
-/// (), today's
-/// COLLISION cells (), and
-/// today's OLD RENDER cells
-/// ( over the
-/// SAME part array and seed). Chunk 1's ownership-map claim is that retail
-/// and old-render share one primitive for a BSP-routed static, so this
-/// asserts that equality; a retail-vs-collision difference is EXPECTED
-/// evidence for chunk 3 (decorative, non-colliding parts) and is printed,
-/// never asserted.
+/// prints and compares two answers per fixture: retail's CELLARRAY
+/// () and today's
+/// COLLISION cells (). Chunk
+/// 2 deleted the third "old render cells" column
+/// (ShadowObjectRegistry.ComputeStaticRenderCells) along with its
+/// only production consumer (WalkProductionWorldData.ResolveStaticRenderCells).
+/// Every fixture here is BSP-bearing, and chunk 1's evidence
+/// (`docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md` §5)
+/// found retail == collision == old-render exactly for all five as a
+/// structural consequence of the shared bbox-route primitive — so this
+/// asserts retail == collision directly; a genuine divergence (expected only
+/// for a fixture mixing colliding and non-colliding parts, none of which
+/// exist among these fixtures) remains chunk 3 evidence.
///
[Trait("Lane", "InstalledDat")]
public sealed class RetailCellArrayComparatorInstalledDatTests
@@ -118,11 +121,10 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
///
/// Verbatim copy of ShadowObjectRegistry.DeriveOutdoorSeed (private
- /// there) — needed here so the SAME resolved seed feeds both
- /// and the
- /// independent
- /// recompute below (that method requires a non-zero seed directly; it
- /// does not derive one).
+ /// there) — needed here so an outdoor fixture with no explicit
+ /// seedCellId resolves the SAME seed
+ /// would derive
+ /// internally, for printing alongside the registered result.
///
private static uint DeriveOutdoorSeedForTest(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId)
@@ -141,7 +143,6 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
uint EntityId,
IReadOnlyList RetailCells,
IReadOnlyList CollisionCells,
- IReadOnlyList OldRenderCells,
RetailCellArrayRoute Route,
int PartCount,
int BspShapeCount,
@@ -154,7 +155,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
/// LandblockPhysicsPublisher.PublishStaticEntity uses — plus the
/// chunk-1b whole-part-array side product
/// (), then reads
- /// back and prints all three comparator answers.
+ /// back and prints both comparator answers.
///
private ComparatorOutcome RegisterAndCompare(
string label,
@@ -232,20 +233,16 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
}
}
- IReadOnlyList oldRenderCells = resolvedSeed != 0u && partArray.Count > 0
- ? engine.ShadowObjects.ComputeStaticRenderCells(resolvedSeed, worldPos, worldRot, partArray)
- : Array.Empty();
-
engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList retailCells);
IReadOnlyList collisionCells = engine.ShadowObjects.GetOwnerCells(entityId);
RetailCellArrayRoute route = engine.ShadowObjects.GetRetailCellArrayRoute(entityId);
PrintOutcome(
- label, entityId, retailCells, collisionCells, oldRenderCells, route,
+ label, entityId, retailCells, collisionCells, route,
partArray.Count, bspShapes.Count, hasPhysicsBsp, registered, resolvedSeed);
return new ComparatorOutcome(
- label, entityId, retailCells, collisionCells, oldRenderCells, route,
+ label, entityId, retailCells, collisionCells, route,
partArray.Count, bspShapes.Count, registered);
}
@@ -254,7 +251,6 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
uint entityId,
IReadOnlyList retailCells,
IReadOnlyList collisionCells,
- IReadOnlyList oldRenderCells,
RetailCellArrayRoute route,
int partCount,
int bspShapeCount,
@@ -271,7 +267,6 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
$" parts={partCount} bspShapes={bspShapeCount} hasPhysicsBsp={hasPhysicsBsp} collisionRegistered={collisionRegistered} route={route}"));
_out.WriteLine($" retail n={retailCells.Count} {CellSet(retailCells)}");
_out.WriteLine($" collision n={collisionCells.Count} {CellSet(collisionCells)}");
- _out.WriteLine($" oldRender n={oldRenderCells.Count} {CellSet(oldRenderCells)}");
foreach (uint cellId in retailCells)
{
@@ -287,7 +282,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
if (retailOnly.Any() || collisionOnly.Any())
{
_out.WriteLine(
- $" retail-vs-collision difference (EXPECTED evidence, not asserted): "
+ $" retail-vs-collision difference: "
+ $"retailOnly={CellSet(retailOnly)} collisionOnly={CellSet(collisionOnly)}");
}
}
@@ -316,10 +311,10 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
worldPos, worldRot, worldOffsetX, worldOffsetY, landblockId, seedCellId);
}
- private static void AssertSameCells(IReadOnlyList retail, IReadOnlyList oldRender)
+ private static void AssertSameCells(IReadOnlyList retail, IReadOnlyList collision)
{
Assert.Equal(
- oldRender.OrderBy(id => id).ToArray(),
+ collision.OrderBy(id => id).ToArray(),
retail.OrderBy(id => id).ToArray());
}
@@ -328,7 +323,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
// ---------------------------------------------------------------
[Fact]
- public void FacilityHubStair_RetailCellArrayMatchesOldRenderCells()
+ public void FacilityHubStair_RetailCellArrayMatchesCollisionCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
@@ -366,7 +361,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
Assert.Contains(FacilityHub | 0x015Fu, outcome.RetailCells);
Assert.Contains(FacilityHub | 0x015Eu, outcome.RetailCells);
- AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
+ AssertSameCells(outcome.RetailCells, outcome.CollisionCells);
}
// ---------------------------------------------------------------
@@ -389,7 +384,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
}
[Fact]
- public void CathedralRamp_RetailCellArrayMatchesOldRenderCells()
+ public void CathedralRamp_RetailCellArrayMatchesCollisionCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
@@ -434,7 +429,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
landblockId: Cathedral,
seedCellId: parentCellId);
- AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
+ AssertSameCells(outcome.RetailCells, outcome.CollisionCells);
}
// ---------------------------------------------------------------
@@ -442,7 +437,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
// ---------------------------------------------------------------
[Fact]
- public void NeftetFormation_RetailCellArrayMatchesOldRenderCells()
+ public void NeftetFormation_RetailCellArrayMatchesCollisionCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
@@ -475,7 +470,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
landblockId: NeftetLandblock,
seedCellId: 0u);
- AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
+ AssertSameCells(outcome.RetailCells, outcome.CollisionCells);
}
// ---------------------------------------------------------------
@@ -490,7 +485,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
// square_length (#334) — the sphere is typically much looser (a first
// pass keyed on BoundingSphere.Radius picked the #334 Neftet formation
// itself, whose 69 m sphere radius crosses 0 m at its 63.78 m origin but
- // whose real 96 m box does not — see NeftetFormation_RetailCellArrayMatchesOldRenderCells,
+ // whose real 96 m box does not — see NeftetFormation_RetailCellArrayMatchesCollisionCells,
// which already covers that object; this scan explicitly excludes it so
// fixture 4 stays independent of fixture 3).
// ---------------------------------------------------------------
@@ -521,7 +516,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
}
[Fact]
- public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesOldRenderCells()
+ public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesCollisionCells()
{
string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage);
@@ -574,7 +569,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
$" cells in a NEIGHBOR landblock (edge-crossing evidence): "
+ $"[{string.Join(",", foreignLandblockCells.Select(id => FormattableString.Invariant($"0x{id:X8}")))}]");
- AssertSameCells(outcome.RetailCells, outcome.OldRenderCells);
+ AssertSameCells(outcome.RetailCells, outcome.CollisionCells);
}
// ---------------------------------------------------------------
diff --git a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs
index 421a53fc..1badf347 100644
--- a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs
@@ -167,8 +167,29 @@ public class Issue177StairDescentCameraFloodTests
}
}
+ ///
+ /// Campaign OVERHAUL S2 chunk 2: retargeted from the deleted
+ /// ShadowObjectRegistry.ComputeStaticRenderCells pure-function
+ /// side channel to the production
+ /// transaction
+ /// (the SAME BSP-collision + partArray: recipe
+ /// LandblockPhysicsPublisher.PublishStaticEntity uses, and the
+ /// same one RetailCellArrayComparatorInstalledDatTests' fixture 1
+ /// exercises for this exact Setup) plus
+ /// . The entity
+ /// id now MUST match the projection's
+ /// — chunk 2's
+ /// indoor sweep looks the retail
+ /// CELLARRAY up BY that id, unlike the deleted method's seed+position
+ /// pure function, which never carried entity identity. The former
+ /// "WithoutCollisionRows" name no longer describes production: a part
+ /// array is always supplied at the SAME registration as the collision
+ /// shapes now (chunk 1b), so GetOwnerCells for this BSP-bearing
+ /// Setup reflects that registration — and, per the comparator's fixture-1
+ /// evidence, equals the retail CELLARRAY exactly.
+ ///
[Fact]
- public void FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows()
+ public void FacilityStairAssembly_RegisterAcross015FTo015E()
{
string? datDir = ResolveDatDir();
if (datDir is null)
@@ -199,6 +220,8 @@ public class Issue177StairDescentCameraFloodTests
stair.Frame.Origin.Z);
Quaternion rootRotation = stair.Frame.Orientation;
+ List bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
+ meshRefs, isBuildingShell: false, cache.GetGfxObj);
List parts = ShadowShapeBuilder.FromStaticRenderParts(
meshRefs,
cache.GetGfxObj,
@@ -206,14 +229,25 @@ public class Issue177StairDescentCameraFloodTests
out bool hasPhysicsBsp);
Assert.True(hasPhysicsBsp);
Assert.NotEmpty(parts);
+ Assert.NotEmpty(bspShapes);
- const uint syntheticOwner = 0x7F00DEADu;
- Assert.Empty(engine.ShadowObjects.GetOwnerCells(syntheticOwner));
- IReadOnlyList cells = engine.ShadowObjects.ComputeStaticRenderCells(
- FacilityHub | 0x015Fu,
+ const uint entityId = 0x48A02000u;
+ engine.ShadowObjects.RegisterMultiPart(
+ entityId,
rootPosition,
rootRotation,
- parts);
+ bspShapes,
+ state: 0u,
+ flags: EntityCollisionFlags.None,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: FacilityHub,
+ seedCellId: FacilityHub | 0x015Fu,
+ isStatic: true,
+ partArray: parts);
+
+ Assert.True(engine.ShadowObjects.TryGetRetailCellArray(
+ entityId, out IReadOnlyList cells));
ShadowShape part = parts[0];
ShadowPartBox box = ShadowPartBox.FromShape(
@@ -229,11 +263,21 @@ public class Issue177StairDescentCameraFloodTests
Assert.Contains(FacilityHub | 0x015Fu, cells);
Assert.Contains(FacilityHub | 0x015Eu, cells);
- Assert.Empty(engine.ShadowObjects.GetOwnerCells(syntheticOwner));
+
+ // The registration carries collision shapes at the SAME transaction
+ // now (production always does — chunk 1b threaded the part array
+ // through the SAME RegisterMultiPart call the collision dispatch
+ // uses), so GetOwnerCells reflects that registration instead of
+ // staying empty; retail == collision for this BSP-bearing Setup
+ // (RetailCellArrayComparatorInstalledDatTests fixture 1), so the two
+ // sets agree exactly.
+ Assert.Equal(
+ cells.OrderBy(id => id),
+ engine.ShadowObjects.GetOwnerCells(entityId).OrderBy(id => id));
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
- RenderProjectionId projectionId = RenderProjectionId.FromRaw(0x48A02000u);
+ RenderProjectionId projectionId = RenderProjectionId.FromRaw(entityId);
RenderTransform transform = RenderTransform.FromRoot(
rootPosition,
rootRotation,
@@ -252,7 +296,7 @@ public class Issue177StairDescentCameraFloodTests
Flags = RenderProjectionFlags.Draw,
Source = new RenderSourceMetadata() with
{
- LocalEntityId = 0x48A02000u,
+ LocalEntityId = entityId,
SourceId = setupId,
ParentCellId = FacilityHub | 0x015Fu,
},
@@ -272,6 +316,7 @@ public class Issue177StairDescentCameraFloodTests
Assert.Contains(
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
record => record.Id == projectionId);
+ Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
}
///
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs
index d8d3a6c7..c06aa61e 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs
@@ -1,6 +1,8 @@
using System.Numerics;
using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Rendering.Walk;
+using AcDream.Core.Physics;
namespace AcDream.App.Tests.Rendering.Walk;
@@ -269,6 +271,121 @@ public sealed class WalkProductionWorldDataTests
Assert.Equal([0xF4180106u, 0xF4180104u], cells);
}
+ // -----------------------------------------------------------------
+ // Campaign OVERHAUL S2 chunk 2: BeginFrame's indoor static sweep now
+ // borrows membership from ShadowObjectRegistry.TryGetRetailCellArray
+ // instead of rebuilding it (the deleted ResolveStaticRenderCells /
+ // ShadowObjectRegistry.ComputeStaticRenderCells pair). These two tests
+ // drive the REAL production path end to end — a real (bare, no-DAT)
+ // ShadowObjectRegistry, a real ArchRenderScene, and BeginFrame itself —
+ // rather than the hardcoded-cell-array style of the Bucket* tests above,
+ // which exercise only the (unchanged) bucketing primitive.
+ // -----------------------------------------------------------------
+
+ private static ShadowShape Bsp(uint gfxObjId, float radius = 1f) =>
+ ShadowShape.Bsp(
+ gfxObjId,
+ Vector3.Zero,
+ Quaternion.Identity,
+ scale: 1f,
+ localGeometry: ShadowPartGeometry.Create(
+ new FlatCollisionSphere(Vector3.Zero, radius), null));
+
+ private static RenderProjectionRecord IndoorStaticRecord(
+ uint entityId, uint sourceId, uint parentCellId) =>
+ new RenderProjectionRecord() with
+ {
+ Id = RenderProjectionId.FromRaw(entityId),
+ ProjectionClass = RenderProjectionClass.IndoorCellStatic,
+ OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
+ Transform = new RenderTransform(Matrix4x4.Identity),
+ PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity),
+ Residency = new RenderSpatialResidency(
+ RenderSpatialBucket.FromRaw(parentCellId), 0x8A020000u, parentCellId),
+ Flags = RenderProjectionFlags.Draw,
+ Source = new RenderSourceMetadata() with
+ {
+ LocalEntityId = entityId,
+ SourceId = sourceId,
+ ParentCellId = parentCellId,
+ },
+ };
+
+ [Fact]
+ public void BeginFrame_IndoorStatic_RegisteredEntityUsesRegistryRetailCellArray()
+ {
+ const uint entityId = 0x48A02001u;
+ const uint retailCellId = 0x8A02015Fu;
+ // A DECOY parent cell, deliberately different from the registered
+ // retail array's cell, so the assertions below can only pass if the
+ // indoor sweep actually consulted the registry rather than falling
+ // back to the authored parent.
+ const uint decoyParentCellId = 0x8A0201C1u;
+
+ var shadows = new ShadowObjectRegistry();
+ IReadOnlyList parts = new[] { Bsp(0x01001234u) };
+ shadows.RegisterMultiPart(
+ entityId,
+ Vector3.Zero,
+ Quaternion.Identity,
+ parts,
+ state: 0u,
+ flags: EntityCollisionFlags.None,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: 0x8A020000u,
+ seedCellId: retailCellId,
+ isStatic: true,
+ partArray: parts);
+ Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList retailCells));
+ Assert.Equal(new[] { retailCellId }, retailCells);
+
+ RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
+ using var scene = new ArchRenderScene(generation);
+ RenderProjectionRecord projection =
+ IndoorStaticRecord(entityId, sourceId: 0x02000001u, decoyParentCellId);
+ scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
+
+ var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
+ worldData.BeginFrame(
+ scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
+
+ Assert.Contains(
+ worldData.GetCellStatics(retailCellId).Records,
+ record => record.Id == projection.Id);
+ Assert.DoesNotContain(
+ worldData.GetCellStatics(decoyParentCellId).Records,
+ record => record.Id == projection.Id);
+ Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
+ }
+
+ [Fact]
+ public void BeginFrame_IndoorStatic_UnregisteredEntityFallsBackToParentCellAndCountsFallback()
+ {
+ const uint entityId = 0x48A02002u;
+ const uint parentCellId = 0x8A02015Fu;
+
+ // Bare, empty registry: entityId is never registered, so
+ // TryGetRetailCellArray must answer false for it.
+ var shadows = new ShadowObjectRegistry();
+ Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
+
+ RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
+ using var scene = new ArchRenderScene(generation);
+ RenderProjectionRecord projection =
+ IndoorStaticRecord(entityId, sourceId: 0x02000002u, parentCellId);
+ scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
+
+ var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
+ worldData.BeginFrame(
+ scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
+
+ Assert.Contains(
+ worldData.GetCellStatics(parentCellId).Records,
+ record => record.Id == projection.Id);
+ Assert.Equal(1, worldData.UnregisteredStaticRenderFallbackCount);
+ }
+
private static RenderProjectionRecord Record(uint id, Vector3 position) =>
new RenderProjectionRecord() with
{
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
index 2cd0a189..c96e2287 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
@@ -92,12 +92,14 @@ public class ShadowObjectRegistryRetailCellArrayTests
}
// -------------------------------------------------------------------
- // The bbox route is ComputeStaticRenderCells's own primitive, and
- // supplying a part array never touches the existing collision product.
+ // The bbox route (find_bbox_cell_list 0x00510fc0) is the SAME primitive
+ // the collision flood uses for a single BSP-shape part array fed as both
+ // `shapes` and `partArray`, and supplying a part array never touches the
+ // existing collision product.
// -------------------------------------------------------------------
[Fact]
- public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesComputeStaticRenderCells()
+ public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesCollisionFloodForTheSamePartArray()
{
var withPartArray = new ShadowObjectRegistry();
var withoutPartArray = new ShadowObjectRegistry();
@@ -113,11 +115,16 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.Equal(RetailCellArrayRoute.BoundingBox, withPartArray.GetRetailCellArrayRoute(entityId));
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
+ Assert.NotEmpty(retailCells); // the fixture must actually exercise a flood
- IReadOnlyList expected =
- withPartArray.ComputeStaticRenderCells(CellA, Pos, Quaternion.Identity, parts);
- Assert.Equal(expected, retailCells);
- Assert.NotEmpty(expected); // the fixture must actually exercise a flood
+ // Retail's bbox route and RegisterMultiPart's own BSP collision
+ // dispatch share one primitive (CPhysicsObj::find_bbox_cell_list
+ // 0x00510fc0) when fed the identical single-part array as both
+ // `shapes` and `partArray`, so the two products agree exactly. This
+ // is the independent ground truth now that ComputeStaticRenderCells
+ // (Campaign OVERHAUL S2 chunk 2) is deleted along with its only
+ // production consumer.
+ Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells);
// Supplying a part array must not perturb the pre-existing collision
// product — same registration, same result either way.