feat(render): S2 chunk 2 - render statics borrow the registry's retail cell array

WalkProductionWorldData no longer floods; its indoor and outdoor static
sweeps read ShadowObjectRegistry.TryGetRetailCellArray (retail's
calc_cross_cells_static @0x00515160 -> AddPartsShadow @0x00517e40
CELLARRAY, computed once at registration). The App-owned render flood
(ResolveStaticRenderCells, its fingerprint cache, the primitive-Setup
special case) and Core's ComputeStaticRenderCells are deleted. The one
remaining fallback, an entity the physics publisher has not registered yet
while the projection journal already published it, buckets to the authored
parent cell and is counted per frame (UnregisteredStaticRenderFallbackCount)
for chunk 5 to judge on the connected route.

The Facility stair pin now registers at the projection's own entity id
(the old pure-function test never carried identity) and reads the retail
array; the installed-DAT comparator compares retail against collision.

Gates (run in the implementer's isolated worktree at identical content):
Release build 0/0; Core Physics 2,202/2,202; App hermetic 6,760/6,760 (the
two added WalkProductionWorldData tests); installed-DAT walk/flood/stair
family 24/24; Runtime 1,884/1,884.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 22:31:46 +02:00
parent 2b40c0bb38
commit 707d2803a4
10 changed files with 392 additions and 227 deletions

View file

@ -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; draws its complete constructed shell. Portal polygons are not GPU mesh clips;
pixel occlusion comes from the ordered walk and depth buffer. pixel occlusion comes from the ordered walk and depth buffer.
**Retail static render-shadow cell membership (2026-08-31).** Indoor **Retail static render-shadow cell membership (2026-08-31; ownership moved
statics are not rendered solely from their authored parent EnvCell. Retail's 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, `CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static,
then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every
visual part in each crossed cell's `shadow_part_list`, including decorative visual part in each crossed cell's `shadow_part_list`, including decorative
GfxObjs that have no collision BSP. `WalkProductionWorldData` therefore owns GfxObjs that have no collision BSP. `ShadowObjectRegistry` owns this product
a distinct render-only per-cell index built from each MeshRef's prepared as a retained per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus
visual AABB and the existing exact static part transit walk. This must not be per-cell part entries (`GetRetailPartEntriesInCell`), computed once at the
collapsed into `ShadowObjectRegistry`'s collision rows: the two retail lists SAME registration transaction as the collision flood
share transit math but have different membership. A primitive-only Setup may (`RegisterMultiPart`/`Register`'s optional `partArray:` parameter) — not
reuse its already-authored cylsphere collision cell set; BSP-bearing and rebuilt per frame. `WalkProductionWorldData`'s indoor and outdoor static
pure-visual statics use the all-visual-part box walk. The Facility Hub stair sweeps BORROW that array whole per record; they no longer recompute
Setup `0x02000623` is the installed-DAT regression: its authored parent is membership from each MeshRef's prepared visual AABB (the deleted
`0x8A02015F`, while its visual parts also register in `0x8A02015E` and the `ResolveStaticRenderCells` / `ShadowObjectRegistry.ComputeStaticRenderCells`
adjoining vertical cells. 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 Moving multipart objects follow the sibling retail route rather than their
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every

View file

@ -37,7 +37,7 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
| Owner | Inputs | Output | Runs | Consumers | | 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 | | `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 | | `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` | | `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) | | `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, three answers side by side: retail CELLARRAY, current collision cells,
current render cells. Differences are evidence for chunks 2/3, not failures. current render cells. Differences are evidence for chunks 2/3, not failures.
**Chunk 2 — render statics cut over.** `WalkProductionWorldData` indoor and **Chunk 2 — render statics cut over (landed 2026-09-02).** `WalkProductionWorldData`
outdoor static buckets are filled from the registry's per-cell part entries indoor and outdoor static buckets are filled whole-record-per-cell from the
(grouped back to records for the existing draw path; per-part draw registry's retail CELLARRAY (`TryGetRetailCellArray`), not from its per-cell
admission is S3). `ResolveStaticRenderCells`, `ComputeStaticRenderCells`, part entries: per-part draw admission is S3's job, and today's draw path
the fingerprint cache, and App's use of `FromStaticRenderParts` are deleted. still consumes whole `RenderProjectionRecord`s, so grouping
The Facility stair pin moves to the registry. `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 **Chunk 3 — collision cut over.** `_entityToCells`/`_cells` are populated
from the retail CELLARRAY (collision shapes into every array cell). Physics from the retail CELLARRAY (collision shapes into every array cell). Physics

View file

@ -14,10 +14,23 @@ namespace AcDream.App.Rendering.Walk;
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep /// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep
/// over authored indoor statics, bucketed into every cell crossed by the /// over authored indoor statics, bucketed into every cell of
/// object's visual parts. This is retail's <c>CPartArray::AddPartsShadow</c> /// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>'s retail
/// render index, which deliberately includes non-colliding decorations and /// CELLARRAY. Campaign OVERHAUL S2 chunk 2: membership is BORROWED from that
/// is distinct from the physics shadow-object index.</item> /// registry — the sole owner of retail's <c>calc_cross_cells_static</c>
/// (0x00515160) → <c>CPartArray::AddPartsShadow</c> (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 (<see cref="ShadowObjectRegistry.GetOwnerCells"/>).
/// 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
/// <see cref="RenderSourceMetadata.ParentCellId"/> alone and is counted in
/// <see cref="UnregisteredStaticRenderFallbackCount"/>. Per-cell PART
/// entries (<see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>)
/// are not consumed here — buckets stay whole-record per cell, which equals
/// CELLARRAY membership; per-part draw admission is Campaign OVERHAUL S3.</item>
/// <item>Live dynamics — ONE global dynamic-index sweep, bucketed into every /// <item>Live dynamics — ONE global dynamic-index sweep, bucketed into every
/// cell in the object's retained physics <c>CELLARRAY</c>. Retail feeds that /// cell in the object's retained physics <c>CELLARRAY</c>. Retail feeds that
/// exact array to <c>CPhysicsObj::add_shadows_to_cells</c>, which calls /// exact array to <c>CPhysicsObj::add_shadows_to_cells</c>, which calls
@ -26,12 +39,12 @@ namespace AcDream.App.Rendering.Walk;
/// the head cell; indexing only by its authored parent makes individual body /// the head cell; indexing only by its authored parent makes individual body
/// parts disappear at the portal edge.</item> /// parts disappear at the portal edge.</item>
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> /// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
/// sweep bucketed by landscape cell id /// sweep bucketed the same way as cell statics — the registry's retail
/// (<c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's /// CELLARRAY first, falling back to
/// world position — the same encoding <see cref="IWalkEventSink.OnLandscapeCellTurn"/> /// <see cref="ShadowObjectRegistry.GetOwnerCells"/> (counted the same way)
/// computes), EXCLUDING building shells (they draw at their building's own /// when unregistered — EXCLUDING building shells (they draw at their
/// shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c> @0x0059f331, not /// building's own shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c>
/// at the cell's <c>DrawObjCell</c> turn).</item> /// @0x0059f331, not at the cell's <c>DrawObjCell</c> turn).</item>
/// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records /// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records
/// bucketed by <c>Source.BuildingShellAnchorCellId</c> for portal-bearing /// bucketed by <c>Source.BuildingShellAnchorCellId</c> for portal-bearing
/// buildings. Portal-less buildings have no interior anchor; retail still /// buildings. Portal-less buildings have no interior anchor; retail still
@ -82,10 +95,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private readonly Dictionary<RenderProjectionId, StaticRenderCellCacheEntry> private readonly Func<uint, (bool Found, IReadOnlyList<uint> Cells)> _tryGetRetailCellArray;
_staticRenderCellCache = new();
private ulong _staticRenderCellIndexRevision = ulong.MaxValue;
private RenderSceneGeneration _staticRenderCellGeneration;
private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256];
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256];
@ -103,10 +113,37 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_findParentLocalId = findParentLocalId ?? NoParentLocalId; _findParentLocalId = findParentLocalId ?? NoParentLocalId;
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
} }
private static uint? NoParentLocalId(uint _) => null; private static uint? NoParentLocalId(uint _) => null;
private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId)
{
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells);
return (found, cells);
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 2: how many indoor/outdoor static records
/// this frame fell back to a conservative single-cell membership
/// (indoor: the authored <see cref="RenderSourceMetadata.ParentCellId"/>;
/// outdoor: today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer) because
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> had no retail
/// CELLARRAY registered yet for the entity. Retail has no such gap —
/// <c>CEnvCell::init_static_objects</c> 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
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Reset to zero
/// at the start of every <see cref="BeginFrame"/>. Campaign OVERHAUL S2
/// chunk 5 decides this adaptation's fate against the connected-route
/// count.
/// </summary>
public int UnregisteredStaticRenderFallbackCount { get; private set; }
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the /// <summary>Rebuilds the frame's outdoor/shell buckets and clears the
/// per-cell cache. Call once per frame before the driver runs. /// per-cell cache. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/> /// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
@ -132,6 +169,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_outdoorDynamicsMaterialized.Clear(); _outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear(); _shellMaterialized.Clear();
_arenaLength = 0; _arenaLength = 0;
UnregisteredStaticRenderFallbackCount = 0;
foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values) foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values)
bucket.Clear(); bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _indoorDynamicsByCell.Values) foreach (List<RenderProjectionRecord> bucket in _indoorDynamicsByCell.Values)
@ -143,19 +181,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values) foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear(); 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 // Retail CEnvCell::init_static_objects does not leave an object solely
// in its authored parent cell. add_obj_to_cell -> // in its authored parent cell. add_obj_to_cell ->
// calc_cross_cells_static -> CPartArray::AddPartsShadow registers all // calc_cross_cells_static -> CPartArray::AddPartsShadow registers all
// visual parts in every crossed cell, including parts with no physics // 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); int required = _scene.IndexCounts.For(RenderSceneIndex.IndoorCellStatic);
if (required > _indoorSweepScratch.Length) if (required > _indoorSweepScratch.Length)
{ {
@ -168,7 +200,10 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i]; ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveStaticRenderCells(in record); IReadOnlyList<uint> renderCells = ResolveIndoorStaticRenderCells(
in record, _tryGetRetailCellArray, out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketIndoorRecord( BucketIndoorRecord(
in record, in record,
renderCells, renderCells,
@ -198,9 +233,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
shells.Add(record); shells.Add(record);
continue; continue;
} }
IReadOnlyList<uint> renderCells = ResolveOutdoorStaticRenderCells(
in record, _tryGetRetailCellArray, _shadows.GetOwnerCells, out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketOutdoorRecord( BucketOutdoorRecord(
in record, in record,
_shadows.GetOwnerCells(record.Source.LocalEntityId), renderCells,
_outdoorByCell, _outdoorByCell,
_renderCenterLbX, _renderCenterLbX,
_renderCenterLbY); _renderCenterLbY);
@ -282,87 +321,70 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return Array.Empty<uint>(); return Array.Empty<uint>();
} }
private IReadOnlyList<uint> ResolveStaticRenderCells( /// <summary>
in RenderProjectionRecord record) /// Campaign OVERHAUL S2 chunk 2: one authored indoor static's render-cell
/// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// — retail's <c>calc_cross_cells_static</c> (0x00515160) →
/// <c>CPartArray::AddPartsShadow</c> (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
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>), the LAST-RESORT
/// fallback is the authored parent cell alone, and
/// <paramref name="usedFallback"/> reports it so the caller can count it.
/// </summary>
internal static IReadOnlyList<uint> ResolveIndoorStaticRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
out bool usedFallback)
{ {
if (_staticRenderCellCache.TryGetValue( ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
record.Id,
out StaticRenderCellCacheEntry cached) (bool found, IReadOnlyList<uint> cells) =
&& cached.ParentCellId == record.Source.ParentCellId tryGetRetailCellArray(record.Source.LocalEntityId);
&& cached.TransformFingerprint == record.Source.TransformFingerprint if (found)
&& cached.GeometryFingerprint == record.Source.GeometryFingerprint)
{ {
return cached.Cells; usedFallback = false;
return cells;
} }
IReadOnlyList<uint> collisionCells = usedFallback = true;
_shadows.GetOwnerCells(record.Source.LocalEntityId); return record.Source.ParentCellId != 0u
IReadOnlyList<uint> cells = Array.Empty<uint>();
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<ShadowShape> visualParts =
ShadowShapeBuilder.FromStaticRenderParts(
meshRefs,
cache.GetGfxObj,
cache.GetVisualBounds,
out hasPhysicsBsp);
visualPartCount = visualParts.Count;
// 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);
}
}
if (cells.Count == 0)
cells = collisionCells.Count > 0
? collisionCells
: record.Source.ParentCellId != 0u
? new[] { record.Source.ParentCellId } ? new[] { record.Source.ParentCellId }
: Array.Empty<uint>(); : Array.Empty<uint>();
uint[] snapshot = cells as uint[] ?? cells.ToArray();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled
&& record.Source.SourceId == 0x020009A2u)
{
static string DescribeCells(IReadOnlyList<uint> 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, /// <summary>
record.Source.TransformFingerprint, /// Campaign OVERHAUL S2 chunk 2: one authored outdoor static's
record.Source.GeometryFingerprint, /// render-cell membership, borrowed from
snapshot); /// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> the same way
return snapshot; /// as <see cref="ResolveIndoorStaticRenderCells"/>. The fallback for an
/// entity the registry has not registered yet is today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer (itself
/// possibly empty — <see cref="BucketOutdoorRecord"/> already carries its
/// own root-position-cell fallback for that case), counted the same way
/// as the indoor path via <paramref name="usedFallback"/>.
/// </summary>
internal static IReadOnlyList<uint> ResolveOutdoorStaticRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
Func<uint, IReadOnlyList<uint>> getOwnerCells,
out bool usedFallback)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
usedFallback = false;
return cells;
}
usedFallback = true;
return getOwnerCells(record.Source.LocalEntityId);
} }
internal bool StaticBucketContains(uint cellId, uint sourceId) => internal bool StaticBucketContains(uint cellId, uint sourceId) =>
@ -759,10 +781,4 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
} }
return entry.WorldTransform; return entry.WorldTransform;
} }
private readonly record struct StaticRenderCellCacheEntry(
uint ParentCellId,
RenderSceneHash128 TransformFingerprint,
RenderSceneHash128 GeometryFingerprint,
uint[] Cells);
} }

View file

@ -957,9 +957,11 @@ public sealed class LandblockPhysicsPublisher
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array — // Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every visual part of this static, colliding or not — beside the // every visual part of this static, colliding or not — beside the
// BSP-only/primitive-only COLLISION dispatch above and below. The // BSP-only/primitive-only COLLISION dispatch above and below. The
// SAME resolver pair WalkProductionWorldData.ResolveStaticRenderCells // SAME resolver pair this landblock's PhysicsDataCache already
// already reads off this landblock's PhysicsDataCache. A side // exposes. Chunk 2 (2026-09-02) made WalkProductionWorldData's
// product only: no consumer reads it yet. // indoor/outdoor static sweeps the consumer, via
// ShadowObjectRegistry.TryGetRetailCellArray — this call is what
// populates that registry product, not a side channel any more.
IReadOnlyList<ShadowShape> partArray = IReadOnlyList<ShadowShape> partArray =
ShadowShapeBuilder.FromStaticRenderParts( ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs, entity.MeshRefs,

View file

@ -400,47 +400,6 @@ public sealed class ShadowObjectRegistry
private PhysicsDataCache? _fallback; private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache; private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
/// <summary>
/// Computes retail's static PartArray render-cell membership without
/// publishing collision rows. Static visual parts and collision shapes
/// share the same <c>find_bbox_cell_list</c> portal walk, but retail keeps
/// them in separate cell lists (<c>shadow_part_list</c> versus
/// <c>shadow_object_list</c>). Decorative meshes therefore need this path
/// even when <see cref="GetOwnerCells"/> is empty.
///
/// <para>
/// Campaign OVERHAUL S2 chunk 1: <c>RecomputeRetailCellArray</c>'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 <see cref="TryGetRetailCellArray"/>.
/// </para>
/// </summary>
public IReadOnlyList<uint> ComputeStaticRenderCells(
uint seedCellId,
Vector3 entityWorldPosition,
Quaternion entityWorldRotation,
IReadOnlyList<ShadowShape> visualParts)
{
if (seedCellId == 0u || visualParts.Count == 0)
return Array.Empty<uint>();
List<ShadowPartBox> boxes = BuildFloodPartBoxes(
entityWorldPosition,
entityWorldRotation,
visualParts);
List<DatReaderWriter.Types.Sphere> spheres = BuildBspPartSpheres(
entityWorldPosition,
entityWorldRotation,
visualParts);
return CellTransit.BuildShadowCellSetFromParts(
FloodCache,
seedCellId,
boxes,
spheres,
isStatic: true);
}
/// <summary> /// <summary>
/// Retail's exact <c>CObjCell::find_cell_list</c>/<c>find_bbox_cell_list</c> /// Retail's exact <c>CObjCell::find_cell_list</c>/<c>find_bbox_cell_list</c>
/// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra /// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
@ -473,8 +432,7 @@ public sealed class ShadowObjectRegistry
/// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c> /// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c>
/// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) — /// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) —
/// every other case, including a BSP-bearing object (state bit set) and a /// 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 /// part array with no authored CylSpheres at all.</item>
/// primitive <see cref="ComputeStaticRenderCells"/> already uses.</item>
/// </list> /// </list>
/// </summary> /// </summary>
private void RecomputeRetailCellArray( private void RecomputeRetailCellArray(

View file

@ -81,10 +81,10 @@ internal sealed class LiveEntityCollisionBuilder
/// <summary> /// <summary>
/// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape /// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape
/// <c>WalkProductionWorldData.ResolveStaticRenderCells</c> passes to /// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> takes for a
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> — a static and a /// landblock-baked static — a static and a live Setup sharing a GfxObj id
/// live Setup sharing a GfxObj id can never disagree about its render /// can never disagree about its render geometry. Production:
/// geometry. Production: <c>PhysicsDataCache.GetGfxObj</c>. /// <c>PhysicsDataCache.GetGfxObj</c>.
/// </summary> /// </summary>
private readonly Func<uint, GfxObjPhysics?> _getGfxObj; private readonly Func<uint, GfxObjPhysics?> _getGfxObj;

View file

@ -33,16 +33,19 @@ namespace AcDream.App.Tests.Physics;
/// <c>PublishStaticEntity</c> branches, plus the /// <c>PublishStaticEntity</c> branches, plus the
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> part-array input — /// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> part-array input —
/// against a registry backed by the real <see cref="PhysicsDataCache"/>, then /// against a registry backed by the real <see cref="PhysicsDataCache"/>, then
/// prints and compares three answers per fixture: retail's CELLARRAY /// prints and compares two answers per fixture: retail's CELLARRAY
/// (<see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>), today's /// (<see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>) and today's
/// COLLISION cells (<see cref="ShadowObjectRegistry.GetOwnerCells"/>), and /// COLLISION cells (<see cref="ShadowObjectRegistry.GetOwnerCells"/>). Chunk
/// today's OLD RENDER cells /// 2 deleted the third "old render cells" column
/// (<see cref="ShadowObjectRegistry.ComputeStaticRenderCells"/> over the /// (<c>ShadowObjectRegistry.ComputeStaticRenderCells</c>) along with its
/// SAME part array and seed). Chunk 1's ownership-map claim is that retail /// only production consumer (<c>WalkProductionWorldData.ResolveStaticRenderCells</c>).
/// and old-render share one primitive for a BSP-routed static, so this /// Every fixture here is BSP-bearing, and chunk 1's evidence
/// asserts that equality; a retail-vs-collision difference is EXPECTED /// (`docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md` §5)
/// evidence for chunk 3 (decorative, non-colliding parts) and is printed, /// found retail == collision == old-render exactly for all five as a
/// never asserted. /// 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.
/// </summary> /// </summary>
[Trait("Lane", "InstalledDat")] [Trait("Lane", "InstalledDat")]
public sealed class RetailCellArrayComparatorInstalledDatTests public sealed class RetailCellArrayComparatorInstalledDatTests
@ -118,11 +121,10 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
/// <summary> /// <summary>
/// Verbatim copy of <c>ShadowObjectRegistry.DeriveOutdoorSeed</c> (private /// Verbatim copy of <c>ShadowObjectRegistry.DeriveOutdoorSeed</c> (private
/// there) — needed here so the SAME resolved seed feeds both /// there) — needed here so an outdoor fixture with no explicit
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> and the /// <c>seedCellId</c> resolves the SAME seed
/// independent <see cref="ShadowObjectRegistry.ComputeStaticRenderCells"/> /// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> would derive
/// recompute below (that method requires a non-zero seed directly; it /// internally, for printing alongside the registered result.
/// does not derive one).
/// </summary> /// </summary>
private static uint DeriveOutdoorSeedForTest( private static uint DeriveOutdoorSeedForTest(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId) Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId)
@ -141,7 +143,6 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
uint EntityId, uint EntityId,
IReadOnlyList<uint> RetailCells, IReadOnlyList<uint> RetailCells,
IReadOnlyList<uint> CollisionCells, IReadOnlyList<uint> CollisionCells,
IReadOnlyList<uint> OldRenderCells,
RetailCellArrayRoute Route, RetailCellArrayRoute Route,
int PartCount, int PartCount,
int BspShapeCount, int BspShapeCount,
@ -154,7 +155,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses — plus the /// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses — plus the
/// chunk-1b whole-part-array side product /// chunk-1b whole-part-array side product
/// (<see cref="ShadowShapeBuilder.FromStaticRenderParts"/>), then reads /// (<see cref="ShadowShapeBuilder.FromStaticRenderParts"/>), then reads
/// back and prints all three comparator answers. /// back and prints both comparator answers.
/// </summary> /// </summary>
private ComparatorOutcome RegisterAndCompare( private ComparatorOutcome RegisterAndCompare(
string label, string label,
@ -232,20 +233,16 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
} }
} }
IReadOnlyList<uint> oldRenderCells = resolvedSeed != 0u && partArray.Count > 0
? engine.ShadowObjects.ComputeStaticRenderCells(resolvedSeed, worldPos, worldRot, partArray)
: Array.Empty<uint>();
engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells); engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells);
IReadOnlyList<uint> collisionCells = engine.ShadowObjects.GetOwnerCells(entityId); IReadOnlyList<uint> collisionCells = engine.ShadowObjects.GetOwnerCells(entityId);
RetailCellArrayRoute route = engine.ShadowObjects.GetRetailCellArrayRoute(entityId); RetailCellArrayRoute route = engine.ShadowObjects.GetRetailCellArrayRoute(entityId);
PrintOutcome( PrintOutcome(
label, entityId, retailCells, collisionCells, oldRenderCells, route, label, entityId, retailCells, collisionCells, route,
partArray.Count, bspShapes.Count, hasPhysicsBsp, registered, resolvedSeed); partArray.Count, bspShapes.Count, hasPhysicsBsp, registered, resolvedSeed);
return new ComparatorOutcome( return new ComparatorOutcome(
label, entityId, retailCells, collisionCells, oldRenderCells, route, label, entityId, retailCells, collisionCells, route,
partArray.Count, bspShapes.Count, registered); partArray.Count, bspShapes.Count, registered);
} }
@ -254,7 +251,6 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
uint entityId, uint entityId,
IReadOnlyList<uint> retailCells, IReadOnlyList<uint> retailCells,
IReadOnlyList<uint> collisionCells, IReadOnlyList<uint> collisionCells,
IReadOnlyList<uint> oldRenderCells,
RetailCellArrayRoute route, RetailCellArrayRoute route,
int partCount, int partCount,
int bspShapeCount, int bspShapeCount,
@ -271,7 +267,6 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
$" parts={partCount} bspShapes={bspShapeCount} hasPhysicsBsp={hasPhysicsBsp} collisionRegistered={collisionRegistered} route={route}")); $" parts={partCount} bspShapes={bspShapeCount} hasPhysicsBsp={hasPhysicsBsp} collisionRegistered={collisionRegistered} route={route}"));
_out.WriteLine($" retail n={retailCells.Count} {CellSet(retailCells)}"); _out.WriteLine($" retail n={retailCells.Count} {CellSet(retailCells)}");
_out.WriteLine($" collision n={collisionCells.Count} {CellSet(collisionCells)}"); _out.WriteLine($" collision n={collisionCells.Count} {CellSet(collisionCells)}");
_out.WriteLine($" oldRender n={oldRenderCells.Count} {CellSet(oldRenderCells)}");
foreach (uint cellId in retailCells) foreach (uint cellId in retailCells)
{ {
@ -287,7 +282,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
if (retailOnly.Any() || collisionOnly.Any()) if (retailOnly.Any() || collisionOnly.Any())
{ {
_out.WriteLine( _out.WriteLine(
$" retail-vs-collision difference (EXPECTED evidence, not asserted): " $" retail-vs-collision difference: "
+ $"retailOnly={CellSet(retailOnly)} collisionOnly={CellSet(collisionOnly)}"); + $"retailOnly={CellSet(retailOnly)} collisionOnly={CellSet(collisionOnly)}");
} }
} }
@ -316,10 +311,10 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
worldPos, worldRot, worldOffsetX, worldOffsetY, landblockId, seedCellId); worldPos, worldRot, worldOffsetX, worldOffsetY, landblockId, seedCellId);
} }
private static void AssertSameCells(IReadOnlyList<uint> retail, IReadOnlyList<uint> oldRender) private static void AssertSameCells(IReadOnlyList<uint> retail, IReadOnlyList<uint> collision)
{ {
Assert.Equal( Assert.Equal(
oldRender.OrderBy(id => id).ToArray(), collision.OrderBy(id => id).ToArray(),
retail.OrderBy(id => id).ToArray()); retail.OrderBy(id => id).ToArray());
} }
@ -328,7 +323,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
// --------------------------------------------------------------- // ---------------------------------------------------------------
[Fact] [Fact]
public void FacilityHubStair_RetailCellArrayMatchesOldRenderCells() public void FacilityHubStair_RetailCellArrayMatchesCollisionCells()
{ {
string? datDir = ResolveDatDir(); string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage); if (datDir is null) Assert.Fail(SkipMessage);
@ -366,7 +361,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
Assert.Contains(FacilityHub | 0x015Fu, outcome.RetailCells); Assert.Contains(FacilityHub | 0x015Fu, outcome.RetailCells);
Assert.Contains(FacilityHub | 0x015Eu, 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] [Fact]
public void CathedralRamp_RetailCellArrayMatchesOldRenderCells() public void CathedralRamp_RetailCellArrayMatchesCollisionCells()
{ {
string? datDir = ResolveDatDir(); string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage); if (datDir is null) Assert.Fail(SkipMessage);
@ -434,7 +429,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
landblockId: Cathedral, landblockId: Cathedral,
seedCellId: parentCellId); seedCellId: parentCellId);
AssertSameCells(outcome.RetailCells, outcome.OldRenderCells); AssertSameCells(outcome.RetailCells, outcome.CollisionCells);
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -442,7 +437,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
// --------------------------------------------------------------- // ---------------------------------------------------------------
[Fact] [Fact]
public void NeftetFormation_RetailCellArrayMatchesOldRenderCells() public void NeftetFormation_RetailCellArrayMatchesCollisionCells()
{ {
string? datDir = ResolveDatDir(); string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage); if (datDir is null) Assert.Fail(SkipMessage);
@ -475,7 +470,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
landblockId: NeftetLandblock, landblockId: NeftetLandblock,
seedCellId: 0u); 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 // square_length (#334) — the sphere is typically much looser (a first
// pass keyed on BoundingSphere.Radius picked the #334 Neftet formation // 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 // 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 // which already covers that object; this scan explicitly excludes it so
// fixture 4 stays independent of fixture 3). // fixture 4 stays independent of fixture 3).
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -521,7 +516,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
} }
[Fact] [Fact]
public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesOldRenderCells() public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesCollisionCells()
{ {
string? datDir = ResolveDatDir(); string? datDir = ResolveDatDir();
if (datDir is null) Assert.Fail(SkipMessage); if (datDir is null) Assert.Fail(SkipMessage);
@ -574,7 +569,7 @@ public sealed class RetailCellArrayComparatorInstalledDatTests
$" cells in a NEIGHBOR landblock (edge-crossing evidence): " $" cells in a NEIGHBOR landblock (edge-crossing evidence): "
+ $"[{string.Join(",", foreignLandblockCells.Select(id => FormattableString.Invariant($"0x{id:X8}")))}]"); + $"[{string.Join(",", foreignLandblockCells.Select(id => FormattableString.Invariant($"0x{id:X8}")))}]");
AssertSameCells(outcome.RetailCells, outcome.OldRenderCells); AssertSameCells(outcome.RetailCells, outcome.CollisionCells);
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------

View file

@ -167,8 +167,29 @@ public class Issue177StairDescentCameraFloodTests
} }
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 2: retargeted from the deleted
/// <c>ShadowObjectRegistry.ComputeStaticRenderCells</c> pure-function
/// side channel to the production
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> transaction
/// (the SAME BSP-collision + <c>partArray:</c> recipe
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses, and the
/// same one <c>RetailCellArrayComparatorInstalledDatTests</c>' fixture 1
/// exercises for this exact Setup) plus
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>. The entity
/// id now MUST match the projection's
/// <see cref="RenderSourceMetadata.LocalEntityId"/> — chunk 2's
/// <see cref="WalkProductionWorldData"/> 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 <c>GetOwnerCells</c> for this BSP-bearing
/// Setup reflects that registration — and, per the comparator's fixture-1
/// evidence, equals the retail CELLARRAY exactly.
/// </summary>
[Fact] [Fact]
public void FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows() public void FacilityStairAssembly_RegisterAcross015FTo015E()
{ {
string? datDir = ResolveDatDir(); string? datDir = ResolveDatDir();
if (datDir is null) if (datDir is null)
@ -199,6 +220,8 @@ public class Issue177StairDescentCameraFloodTests
stair.Frame.Origin.Z); stair.Frame.Origin.Z);
Quaternion rootRotation = stair.Frame.Orientation; Quaternion rootRotation = stair.Frame.Orientation;
List<ShadowShape> bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
meshRefs, isBuildingShell: false, cache.GetGfxObj);
List<ShadowShape> parts = ShadowShapeBuilder.FromStaticRenderParts( List<ShadowShape> parts = ShadowShapeBuilder.FromStaticRenderParts(
meshRefs, meshRefs,
cache.GetGfxObj, cache.GetGfxObj,
@ -206,14 +229,25 @@ public class Issue177StairDescentCameraFloodTests
out bool hasPhysicsBsp); out bool hasPhysicsBsp);
Assert.True(hasPhysicsBsp); Assert.True(hasPhysicsBsp);
Assert.NotEmpty(parts); Assert.NotEmpty(parts);
Assert.NotEmpty(bspShapes);
const uint syntheticOwner = 0x7F00DEADu; const uint entityId = 0x48A02000u;
Assert.Empty(engine.ShadowObjects.GetOwnerCells(syntheticOwner)); engine.ShadowObjects.RegisterMultiPart(
IReadOnlyList<uint> cells = engine.ShadowObjects.ComputeStaticRenderCells( entityId,
FacilityHub | 0x015Fu,
rootPosition, rootPosition,
rootRotation, 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<uint> cells));
ShadowShape part = parts[0]; ShadowShape part = parts[0];
ShadowPartBox box = ShadowPartBox.FromShape( ShadowPartBox box = ShadowPartBox.FromShape(
@ -229,11 +263,21 @@ public class Issue177StairDescentCameraFloodTests
Assert.Contains(FacilityHub | 0x015Fu, cells); Assert.Contains(FacilityHub | 0x015Fu, cells);
Assert.Contains(FacilityHub | 0x015Eu, 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); RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation); using var scene = new ArchRenderScene(generation);
RenderProjectionId projectionId = RenderProjectionId.FromRaw(0x48A02000u); RenderProjectionId projectionId = RenderProjectionId.FromRaw(entityId);
RenderTransform transform = RenderTransform.FromRoot( RenderTransform transform = RenderTransform.FromRoot(
rootPosition, rootPosition,
rootRotation, rootRotation,
@ -252,7 +296,7 @@ public class Issue177StairDescentCameraFloodTests
Flags = RenderProjectionFlags.Draw, Flags = RenderProjectionFlags.Draw,
Source = new RenderSourceMetadata() with Source = new RenderSourceMetadata() with
{ {
LocalEntityId = 0x48A02000u, LocalEntityId = entityId,
SourceId = setupId, SourceId = setupId,
ParentCellId = FacilityHub | 0x015Fu, ParentCellId = FacilityHub | 0x015Fu,
}, },
@ -272,6 +316,7 @@ public class Issue177StairDescentCameraFloodTests
Assert.Contains( Assert.Contains(
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records, worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
record => record.Id == projectionId); record => record.Id == projectionId);
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
} }
/// <summary> /// <summary>

View file

@ -1,6 +1,8 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Rendering.Walk; using AcDream.App.Rendering.Walk;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Rendering.Walk; namespace AcDream.App.Tests.Rendering.Walk;
@ -269,6 +271,121 @@ public sealed class WalkProductionWorldDataTests
Assert.Equal([0xF4180106u, 0xF4180104u], cells); 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<ShadowShape> 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<uint> 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) => private static RenderProjectionRecord Record(uint id, Vector3 position) =>
new RenderProjectionRecord() with new RenderProjectionRecord() with
{ {

View file

@ -92,12 +92,14 @@ public class ShadowObjectRegistryRetailCellArrayTests
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// The bbox route is ComputeStaticRenderCells's own primitive, and // The bbox route (find_bbox_cell_list 0x00510fc0) is the SAME primitive
// supplying a part array never touches the existing collision product. // 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] [Fact]
public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesComputeStaticRenderCells() public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesCollisionFloodForTheSamePartArray()
{ {
var withPartArray = new ShadowObjectRegistry(); var withPartArray = new ShadowObjectRegistry();
var withoutPartArray = new ShadowObjectRegistry(); var withoutPartArray = new ShadowObjectRegistry();
@ -113,11 +115,16 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.Equal(RetailCellArrayRoute.BoundingBox, withPartArray.GetRetailCellArrayRoute(entityId)); Assert.Equal(RetailCellArrayRoute.BoundingBox, withPartArray.GetRetailCellArrayRoute(entityId));
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells)); Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
Assert.NotEmpty(retailCells); // the fixture must actually exercise a flood
IReadOnlyList<uint> expected = // Retail's bbox route and RegisterMultiPart's own BSP collision
withPartArray.ComputeStaticRenderCells(CellA, Pos, Quaternion.Identity, parts); // dispatch share one primitive (CPhysicsObj::find_bbox_cell_list
Assert.Equal(expected, retailCells); // 0x00510fc0) when fed the identical single-part array as both
Assert.NotEmpty(expected); // the fixture must actually exercise a flood // `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 // Supplying a part array must not perturb the pre-existing collision
// product — same registration, same result either way. // product — same registration, same result either way.