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.