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

@ -14,10 +14,23 @@ namespace AcDream.App.Rendering.Walk;
///
/// <list type="bullet">
/// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep
/// over authored indoor statics, bucketed into every cell crossed by the
/// object's visual parts. This is retail's <c>CPartArray::AddPartsShadow</c>
/// render index, which deliberately includes non-colliding decorations and
/// is distinct from the physics shadow-object index.</item>
/// over authored indoor statics, bucketed into every cell of
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>'s retail
/// CELLARRAY. Campaign OVERHAUL S2 chunk 2: membership is BORROWED from that
/// 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
/// 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
@ -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.</item>
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
/// sweep bucketed by landscape cell id
/// (<c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's
/// world position — the same encoding <see cref="IWalkEventSink.OnLandscapeCellTurn"/>
/// computes), EXCLUDING building shells (they draw at their building's own
/// shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c> @0x0059f331, not
/// at the cell's <c>DrawObjCell</c> turn).</item>
/// sweep bucketed the same way as cell statics — the registry's retail
/// CELLARRAY first, falling back to
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> (counted the same way)
/// when unregistered — EXCLUDING building shells (they draw at their
/// building's own shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c>
/// @0x0059f331, not at the cell's <c>DrawObjCell</c> turn).</item>
/// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records
/// bucketed by <c>Source.BuildingShellAnchorCellId</c> 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<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private readonly Dictionary<RenderProjectionId, StaticRenderCellCacheEntry>
_staticRenderCellCache = new();
private ulong _staticRenderCellIndexRevision = ulong.MaxValue;
private RenderSceneGeneration _staticRenderCellGeneration;
private readonly Func<uint, (bool Found, IReadOnlyList<uint> 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<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
/// per-cell cache. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
@ -132,6 +169,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear();
_arenaLength = 0;
UnregisteredStaticRenderFallbackCount = 0;
foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _indoorDynamicsByCell.Values)
@ -143,19 +181,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
foreach (List<RenderProjectionRecord> 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<uint> renderCells = ResolveStaticRenderCells(in record);
IReadOnlyList<uint> 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<uint> 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<uint>();
}
private IReadOnlyList<uint> ResolveStaticRenderCells(
in RenderProjectionRecord record)
/// <summary>
/// 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(
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<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
return cached.Cells;
usedFallback = false;
return cells;
}
IReadOnlyList<uint> collisionCells =
_shadows.GetOwnerCells(record.Source.LocalEntityId);
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;
usedFallback = true;
return record.Source.ParentCellId != 0u
? new[] { record.Source.ParentCellId }
: Array.Empty<uint>();
}
// 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);
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 2: one authored outdoor static's
/// render-cell membership, borrowed from
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> the same way
/// 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;
}
if (cells.Count == 0)
cells = collisionCells.Count > 0
? collisionCells
: record.Source.ParentCellId != 0u
? new[] { record.Source.ParentCellId }
: 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,
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);
}

View file

@ -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<ShadowShape> partArray =
ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs,

View file

@ -400,47 +400,6 @@ public sealed class ShadowObjectRegistry
private PhysicsDataCache? _fallback;
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>
/// 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
@ -473,8 +432,7 @@ public sealed class ShadowObjectRegistry
/// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c>
/// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) —
/// 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 <see cref="ComputeStaticRenderCells"/> already uses.</item>
/// part array with no authored CylSpheres at all.</item>
/// </list>
/// </summary>
private void RecomputeRetailCellArray(

View file

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