feat(render): Campaign OVERHAUL S2 chunk 5 + closeout — registry is the only render membership owner

Chunk 5 (consumer cutover): WalkProductionWorldData's per-cell views are
borrowed from ShadowObjectRegistry.GetRetailPartEntriesInCell and resolved
through RenderSceneQuery.TryGetByLocalEntityId; every render-side sweep,
bucket, parent-cell and root-position fallback is deleted (AD-116 for the
one-frame registry→scene window, counted in UnregisteredRenderMembershipCount).
A live entity with visual parts but no collision geometry registers
render-only (LiveEntityCollisionBuilder computes the part array before the
empty-shapes gate).

Closeout fixes found while landing it:
- RefloodOwnerForLandblock forwards the retained part array — a reflood is
  retail's recalc_cross_cells over the SAME CPartArray; without it every owner
  touched by a landblock replacement commit lost its render membership.
- Non-colliding DAT statics register render-only from BOTH publishers
  (LandblockPhysicsPublisher.PublishStaticEntity,
  LandblockPhysicsContentBuilder.RegisterRenderOnlyStatic). The G2 self-gate
  pixel diff caught them vanishing (Facility Hub wall panels): retail floods
  every object regardless of collision (CEnvCell::init_static_objects
  0x0052c350, add_shadows_to_cells 0x00514ae0).
- S2 dual review fix batch (arch + retail lens, lead-verified):
  Suspend clears the retail product (remove_shadows_from_cells 0x00511230 is
  one transaction); AttachChild/DetachChild advance the mutation revision so
  a prepared SetPosition cannot clobber a child's rows; an attached child
  never floods on its own re-registration; RemoveLandblock and the non-rooted
  RetireOwnerFromLandblock prune retail rows (render-only statics end with
  their landblock); a render-only owner's no-cell-array commit republishes at
  its destination cell (AD-117); an empty non-null part array is treated as
  null; per-move closures/LINQ replaced by index loops; EnvCell shells stay
  out of the scene's LocalEntityId index (payload-less records); the index
  predicate compares the id; the dead per-cell scene indices are deleted.

Register: AD-116 (chunk 5), AD-117 (four residual Contract A/B readings).
Evidence: s2-membership-ownership-map.md §8 (chunk 5) and §9 (closeout).

Gates (Release): Core 4,984/4,984; Content 214/214; Runtime 1,884/1,884;
App hermetic lane 6,760/6,760; App InstalledDat lane 217 pass / 1 skip /
2 pre-existing #383 layout-fixture failures; App Windows lane 1/1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 01:01:47 +02:00
parent f30039d3d9
commit c94a1a407e
20 changed files with 2141 additions and 1026 deletions

View file

@ -31,10 +31,11 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private readonly HashSet<RenderProjectionId> _selectable = [];
private readonly HashSet<RenderProjectionId> _lightCandidates = [];
private readonly HashSet<RenderProjectionId> _dirty = [];
private readonly Dictionary<uint, HashSet<RenderProjectionId>>
_cellStatics = [];
private readonly Dictionary<uint, HashSet<RenderProjectionId>>
_cellDynamics = [];
// Campaign OVERHAUL S2 chunk 5: presentation-side lookup by the entity's
// stable LocalEntityId — see IRenderSceneQuerySource.TryGetByLocalEntityId.
// Maintained at the same register/update/unregister points as the cell
// indices above; never a membership source.
private readonly Dictionary<uint, RenderProjectionId> _byLocalEntityId = [];
private ArchWorld _world;
private RenderProjectionCounts _counts;
private ulong _lastAppliedJournalSequence;
@ -434,6 +435,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return false;
}
bool IRenderSceneQuerySource.TryGetByLocalEntityId(
RenderSceneGeneration generation,
uint localEntityId,
out RenderProjectionRecord record)
{
EnsureQueryGeneration(generation);
if (_byLocalEntityId.TryGetValue(localEntityId, out RenderProjectionId id)
&& _entries.TryGetValue(id, out SceneEntry entry))
{
record = ReadRecord(in entry);
return true;
}
record = default;
return false;
}
int IRenderSceneQuerySource.CopyById(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
@ -499,31 +517,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return CopyIdsTo(source, destination);
}
int IRenderSceneQuerySource.GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic)
{
EnsureQueryGeneration(generation);
Dictionary<uint, HashSet<RenderProjectionId>> index =
dynamic ? _cellDynamics : _cellStatics;
return index.TryGetValue(fullCellId, out var ids) ? ids.Count : 0;
}
int IRenderSceneQuerySource.CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination)
{
EnsureQueryGeneration(generation);
Dictionary<uint, HashSet<RenderProjectionId>> index =
dynamic ? _cellDynamics : _cellStatics;
return index.TryGetValue(fullCellId, out var ids)
? CopyIdsTo(ids, destination)
: 0;
}
private static ArchWorld CreateWorld() =>
ArchWorld.Create(
archetypeCapacity: InitialArchetypeCapacity,
@ -809,21 +802,14 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
if (staticProjection)
{
if (indoor)
{
_indoorCellStatics.Add(record.Id);
AddCell(_cellStatics, record.Residency.FullCellId, record.Id);
}
else
{
_outdoorStatics.Add(record.Id);
}
}
else
{
_dynamics.Add(record.Id);
if (indoor)
AddCell(_cellDynamics, record.Residency.FullCellId, record.Id);
else
if (!indoor)
_outdoorDynamics.Add(record.Id);
if ((record.Flags & RenderProjectionFlags.PortalStraddling) != 0)
_portalStraddlingDynamics.Add(record.Id);
@ -837,6 +823,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_lightCandidates.Add(record.Id);
if (record.DirtyMask != RenderDirtyMask.None)
_dirty.Add(record.Id);
// An EnvCell shell projection carries its CELL id in the entity-id
// slot (StaticRenderProjectionJournal.ProjectEnvCellShell); cell ids
// with a 0x8_ high nibble alias the procedural-scenery entity
// namespace, so shells never enter this index (S2 review F5) — the
// walk reaches them through the leaf-renderer shell path, never by
// entity id. A shell is the one record built WITHOUT an entity
// payload (every static/dynamic entity record carries its MeshRefs
// from RenderProjectionRecordFactory); the projection class cannot
// tell them apart because interior statics share IndoorCellStatic.
if (record.Source.LocalEntityId != 0 && !IsEnvCellShell(in record))
_byLocalEntityId[record.Source.LocalEntityId] = record.Id;
AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision();
}
@ -852,12 +849,24 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_selectable.Remove(record.Id);
_lightCandidates.Remove(record.Id);
_dirty.Remove(record.Id);
RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id);
RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id);
if (record.Source.LocalEntityId != 0
&& _byLocalEntityId.TryGetValue(
record.Source.LocalEntityId,
out RenderProjectionId mapped)
&& mapped == record.Id)
{
_byLocalEntityId.Remove(record.Source.LocalEntityId);
}
AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision();
}
/// <summary>The record is an EnvCell shell (cell geometry), not an
/// entity: no entity payload was ever attached. See AddToIndices.</summary>
private static bool IsEnvCellShell(in RenderProjectionRecord record) =>
record.ProjectionClass == RenderProjectionClass.IndoorCellStatic
&& record.EntityPayload.MeshRefs is null;
private static bool IndexMembershipEquals(
in RenderProjectionRecord left,
in RenderProjectionRecord right)
@ -870,6 +879,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
| RenderProjectionFlags.SpatiallyResident;
return left.ProjectionClass == right.ProjectionClass
// _byLocalEntityId is keyed by this field (S2 review F6).
&& left.Source.LocalEntityId == right.Source.LocalEntityId
&& left.Source.ParentCellId == right.Source.ParentCellId
&& left.Residency.FullCellId == right.Residency.FullCellId
&& (left.Flags & indexedFlags) == (right.Flags & indexedFlags)
@ -1141,33 +1152,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
private static void AddCell(
Dictionary<uint, HashSet<RenderProjectionId>> index,
uint fullCellId,
RenderProjectionId id)
{
if (fullCellId == 0)
return;
if (!index.TryGetValue(fullCellId, out HashSet<RenderProjectionId>? ids))
{
ids = [];
index.Add(fullCellId, ids);
}
ids.Add(id);
}
private static void RemoveCell(
Dictionary<uint, HashSet<RenderProjectionId>> index,
uint fullCellId,
RenderProjectionId id)
{
if (!index.TryGetValue(fullCellId, out HashSet<RenderProjectionId>? ids))
return;
ids.Remove(id);
if (ids.Count == 0)
index.Remove(fullCellId);
}
private HashSet<RenderProjectionId> Index(RenderSceneIndex index) =>
index switch
{
@ -1223,13 +1207,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
+ _selectable.EnsureCapacity(0)
+ _lightCandidates.EnsureCapacity(0)
+ _dirty.EnsureCapacity(0);
foreach (HashSet<RenderProjectionId> ids in _cellStatics.Values)
slots += ids.EnsureCapacity(0);
foreach (HashSet<RenderProjectionId> ids in _cellDynamics.Values)
slots += ids.EnsureCapacity(0);
long cellLookupSlots =
_cellStatics.EnsureCapacity(0)
+ _cellDynamics.EnsureCapacity(0);
long cellLookupSlots = _byLocalEntityId.EnsureCapacity(0);
return checked(slots * 24L + cellLookupSlots * 40L);
}
@ -1244,8 +1222,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_selectable.Clear();
_lightCandidates.Clear();
_dirty.Clear();
_cellStatics.Clear();
_cellDynamics.Clear();
_byLocalEntityId.Clear();
}
private void IncrementCount(RenderProjectionClass projectionClass)

View file

@ -557,6 +557,22 @@ internal interface IRenderSceneQuerySource
RenderProjectionId id,
out RenderProjectionRecord record);
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: a presentation-side lookup by the
/// entity's stable <see cref="RenderSourceMetadata.LocalEntityId"/> —
/// the SAME id <see cref="ShadowObjectRegistry"/>'s retail CELLARRAY and
/// per-cell <c>RetailPartEntry</c> rows are keyed by. This is derived
/// presentation state (maintained alongside the existing per-cell/
/// per-class indices at the same register/update/unregister points),
/// never a second membership source: cell membership still comes from
/// the registry alone, this only resolves an entity id back to its
/// current projected record.
/// </summary>
bool TryGetByLocalEntityId(
RenderSceneGeneration generation,
uint localEntityId,
out RenderProjectionRecord record);
int CopyById(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
@ -572,16 +588,10 @@ internal interface IRenderSceneQuerySource
RenderSceneIndex index,
Span<RenderProjectionRecord> destination);
int GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic);
int CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination);
// Campaign OVERHAUL S2 review fix (arch F10): the per-cell scene indices
// (GetCellCount/CopyCellTo, keyed by the single authored FullCellId) are
// deleted — the registry's retail CELLARRAY is the ONLY render-membership
// source (ShadowObjectRegistry.GetRetailPartEntriesInCell).
}
internal readonly struct RenderSceneQuery
@ -626,6 +636,11 @@ internal readonly struct RenderSceneQuery
out RenderProjectionRecord record) =>
Source.TryGet(Generation, id, out record);
public bool TryGetByLocalEntityId(
uint localEntityId,
out RenderProjectionRecord record) =>
Source.TryGetByLocalEntityId(Generation, localEntityId, out record);
public int CopyById(
ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination) =>
@ -644,30 +659,6 @@ internal readonly struct RenderSceneQuery
Span<RenderProjectionRecord> destination) =>
Source.CopyIndexTo(Generation, index, destination);
public int GetCellStaticCount(uint fullCellId) =>
Source.GetCellCount(Generation, fullCellId, dynamic: false);
public int CopyCellStaticsTo(
uint fullCellId,
Span<RenderProjectionRecord> destination) =>
Source.CopyCellTo(
Generation,
fullCellId,
dynamic: false,
destination);
public int GetCellDynamicCount(uint fullCellId) =>
Source.GetCellCount(Generation, fullCellId, dynamic: true);
public int CopyCellDynamicsTo(
uint fullCellId,
Span<RenderProjectionRecord> destination) =>
Source.CopyCellTo(
Generation,
fullCellId,
dynamic: true,
destination);
private IRenderSceneQuerySource Source =>
_source
?? throw new InvalidOperationException("The render-scene query is uninitialized.");

View file

@ -9,70 +9,70 @@ namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.2b-2: the production <see cref="IWalkFrameWorldData"/> over
/// the retained scene (<see cref="RenderSceneQuery"/>) and the FW3.1
/// <see cref="WalkBuildingRegistry"/>. Rebuilt facts per frame via
/// <see cref="BeginFrame"/>:
/// <see cref="WalkBuildingRegistry"/>.
///
/// <list type="bullet">
/// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep
/// 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
/// <c>CPartArray::AddPartsShadow</c> for every member cell. A creature crossing
/// a stair portal must therefore remain drawable from both the feet cell and
/// 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 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
/// draws them at their landscape position-cell turn, so those records use
/// <c>Source.EffectCellId</c>, matching <see cref="WalkBuilding.PositionCellId"/>.</item>
/// </list>
/// <para>
/// Campaign OVERHAUL S2 chunk 5: cell membership is a BORROWED VIEW over
/// <see cref="ShadowObjectRegistry"/>'s retail per-cell part-entry product
/// (<see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> — retail's
/// <c>CPartArray::AddPartsShadow</c> 0x00517e40 output, keyed by the SAME
/// CELLARRAY <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// answers). This class no longer sweeps the scene's static/dynamic indices
/// to REBUILD membership: for a queried cell id, <see cref="ResolveCellView"/>
/// walks the registry's per-cell entries (already in retail
/// CELLARRAY-then-part-array insertion order), collapses them to their
/// distinct owning entity ids in that same order, and resolves each id back
/// to its current <see cref="RenderProjectionRecord"/> through
/// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/> — a presentation-side
/// lookup by the SAME <see cref="RenderSourceMetadata.LocalEntityId"/> the
/// registry is keyed by (chunk 5's App-side addition to
/// <c>ArchRenderScene</c>). Statics vs. dynamics are the same
/// <c>ProjectionClass</c> split <c>ArchRenderScene</c> itself uses (a live
/// dynamic root or an equipped child is "dynamic"; every other class is
/// "static"), and building shells are excluded — they draw at their own
/// building's shell turn (retail <c>CPhysicsPart::Draw(parts, 0)</c>
/// @0x0059f331), never at the cell's ordinary <c>DrawObjCell</c> turn.
/// </para>
///
/// The tuple landblock id handed to the classifier is the frame's player
/// landblock, matching the production walk's retained-scene query convention.
/// <para>
/// An entity the registry HAS flooded into this cell but whose projected
/// record the presentation journal has not applied yet this frame (the
/// transient race between <c>LiveEntityRuntime</c>'s projection journal and
/// <c>ShadowObjectRegistry</c>'s physics-side registration, both driven off
/// the same Create/appearance edge but landing through independent
/// incremental pipelines) contributes to NO cell — retail draws nothing for
/// an object not yet in a cell; there is no second, conservative fallback.
/// Every DISTINCT entity id this happens for in one frame is counted once in
/// <see cref="UnregisteredRenderMembershipCount"/>, regardless of how many
/// cells its CELLARRAY touches or how many <c>Get*</c> calls observe it.
/// </para>
///
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetOutdoorStatics"/>,
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result
/// with <c>_cellScratch[..count]</c> / <c>[.. bucket]</c> — a FRESH
/// <c>RenderProjectionRecord[]</c> allocation per distinct cell/anchor per
/// frame. At a town-density frame (dozens of cells) that was the single
/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation
/// regression (1.9 MB/frame p50). <see cref="_arena"/> replaces it: a
/// grow-only buffer, reset to length 0 once per frame in
/// <see cref="BeginFrame"/>, that every materialization call
/// <see cref="AppendToArena"/>s its records into instead of snapshotting a
/// new array — after the arena reaches its steady-state size (a few frames
/// of warmup, same shape as <see cref="_sweepScratch"/>/<see cref="_cellScratch"/>'s
/// existing grow-on-demand pattern), zero further heap allocation occurs
/// here. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
/// <para>Building shells are the ONE exception left to a per-frame sweep:
/// retail's shell/portal machinery is out of S2's scope (the landcell
/// building channel), so <see cref="GetBuildingShellStatics"/> still reads a
/// small per-frame bucket (<c>_shellsByAnchor</c>) filled by a single narrow
/// <see cref="RenderSceneIndex.OutdoorStatic"/> sweep in
/// <see cref="BeginFrame"/> that keeps only <c>IsBuildingShell</c> records —
/// it never resolves ordinary static/dynamic membership.</para>
///
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetCellDynamics"/>,
/// <see cref="GetOutdoorStatics"/>, <see cref="GetOutdoorDynamics"/>, and
/// <see cref="GetBuildingShellStatics"/> materialize their result into
/// <see cref="_arena"/> — a grow-only buffer reset to length 0 once per frame
/// in <see cref="BeginFrame"/> — rather than a fresh <c>RenderProjectionRecord[]</c>
/// allocation per distinct cell/anchor per frame (the single largest
/// contributor to the FW3.4 perf checkpoint's 14× frame-allocation
/// regression, 1.9 MB/frame p50, before the arena existed).
/// <see cref="_cellViewScratch"/> is the matching grow-on-demand scratch
/// buffer <see cref="ResolveCellView"/> collects one cell's filtered records
/// into before a single <see cref="AppendToArena"/> call — the same
/// steady-state-zero-allocation shape <see cref="_sweepScratch"/> already
/// has. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
/// STRICTLY per-frame scratch — nothing holds one across a frame boundary
/// (the driver/populator consume it immediately, matching
/// <see cref="_sweepScratch"/>'s existing lifetime contract) — so reusing the
/// same backing array's memory next frame is safe.</para>
/// (the driver/populator consume it immediately) — so reusing the same
/// backing array's memory next frame is safe. Per-cell RESULTS
/// (<see cref="_cellCache"/> etc.) are cleared and re-materialized once per
/// frame on first ask, same as before chunk 5.</para>
/// </summary>
internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
@ -80,24 +80,32 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly ShadowObjectRegistry _shadows;
private RenderSceneQuery _scene;
private uint _tupleLandblockId;
private int _renderCenterLbX;
private int _renderCenterLbY;
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _indoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _indoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private readonly Func<uint, (bool Found, IReadOnlyList<uint> Cells)> _tryGetRetailCellArray;
private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256];
// Campaign OVERHAUL S2 chunk 5: every distinct entity id counted into
// UnregisteredRenderMembershipCount this frame, so the SAME entity
// touching several cells (or being asked for through both the static and
// dynamic Get* pair) is counted once — not once per cell visit. Cleared
// in BeginFrame alongside the counter itself.
private readonly HashSet<uint> _unregisteredEntitiesThisFrame = new();
// The one surviving per-frame sweep: building shells only (see this
// type's own doc comment). Statics/dynamics no longer sweep the scene at
// all — membership is a borrowed, on-demand view over the registry.
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256];
// Campaign OVERHAUL S2 chunk 5: ResolveCellView's grow-on-demand scratch
// buffer — collects one cell's filtered records before a single
// AppendToArena call. Starts small: a cell's real membership is usually
// a handful of parts, not the hundreds an old full-scene sweep held.
private RenderProjectionRecord[] _cellViewScratch = new RenderProjectionRecord[64];
// Campaign FW3.4a: the per-frame, grow-only materialization arena — see
// this type's own doc comment.
@ -110,455 +118,135 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
}
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.
/// Campaign OVERHAUL S2 chunk 5: how many DISTINCT entities this frame
/// were present in the registry's retail CELLARRAY (so
/// <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> named
/// them) but had no resolvable <see cref="RenderProjectionRecord"/> yet
/// through <see cref="RenderSceneQuery.TryGetByLocalEntityId"/> — the
/// transient streaming window where the physics publisher's registration
/// outran the presentation journal's applied projection for the same
/// Create/appearance edge. 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 racing during streaming, not
/// steady-state behavior; filed as AD-116, citing the same residency-race
/// reasoning AD-49 already accepts for
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>'s own outdoor
/// seed. Such an entity
/// contributes to NO cell this frame — there is no second, conservative
/// fallback (chunk 5 removed the authored-parent-cell and
/// root-position-cell fallbacks chunk 2/4 carried). Reset to zero at the
/// start of every <see cref="BeginFrame"/>, after that PRIOR frame's
/// value has already been reported by the diagnostic line below.
/// </summary>
public int UnregisteredStaticRenderFallbackCount { get; private set; }
public int UnregisteredRenderMembershipCount { 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.
/// <summary>Rebuilds the frame's building-shell bucket and clears the
/// per-cell caches. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
/// are the streaming recenter origin: record positions are
/// RENDER-ORIGIN-RELATIVE (each landblock's entities carry
/// <c>(lbX CenterX)·192</c> offsets), so mapping a position back to
/// its TRUE landblock byte needs the center added back — the first
/// connected gate of the FW3.2b-2 cutover shipped without this and most
/// outdoor scenery landed in garbage buckets no walk turn ever reads.</summary>
/// were the streaming recenter origin the deleted position-based
/// fallbacks used to convert a render-origin-relative position back to
/// its true landblock byte (<c>LandscapeCellId</c>). Campaign OVERHAUL
/// S2 chunk 5 deleted every position-based fallback — the registry's
/// CELLARRAY is already expressed in exact cell ids — so these two
/// parameters are accepted (unchanged call-site signature) but no
/// longer stored or read.</summary>
internal void BeginFrame(
RenderSceneQuery scene,
uint tupleLandblockId,
int renderCenterLbX,
int renderCenterLbY)
{
// Campaign OVERHAUL S2 chunk 5: report the PRIOR frame's count
// before resetting it — the count is only final once that frame's
// walk (a sequence of on-demand Get* calls this class has no other
// "frame is done" hook for) has finished, so this is necessarily a
// one-frame-delayed report, matching every other per-second/per-
// change probe in this file family (print-only; never influences
// admission).
if (UnregisteredRenderMembershipCount > 0
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
{
Console.WriteLine(
$"[walk-membership] unregistered={UnregisteredRenderMembershipCount} "
+ $"tupleLandblock=0x{_tupleLandblockId:X8}");
}
_scene = scene;
_tupleLandblockId = tupleLandblockId;
_renderCenterLbX = renderCenterLbX;
_renderCenterLbY = renderCenterLbY;
_cellCache.Clear();
_cellDynamicCache.Clear();
_outdoorMaterialized.Clear();
_outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear();
_arenaLength = 0;
UnregisteredStaticRenderFallbackCount = 0;
foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _indoorDynamicsByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorDynamicsByCell.Values)
bucket.Clear();
UnregisteredRenderMembershipCount = 0;
_unregisteredEntitiesThisFrame.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear();
// 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. 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)
{
_indoorSweepScratch = new RenderProjectionRecord[
Math.Max(required, _indoorSweepScratch.Length * 2)];
}
int count = _scene.CopyIndexTo(
RenderSceneIndex.IndoorCellStatic,
_indoorSweepScratch);
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveIndoorStaticRenderCells(
in record, _tryGetRetailCellArray, out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketIndoorRecord(
in record,
renderCells,
_indoorByCell,
_outdoorByCell);
}
// CopyIndexTo THROWS on an undersized destination (ArchRenderScene
// validates up front — the first connected gate run of the FW3.2b-2
// cutover crashed on exactly this at Aerlinthe's 5,040 outdoor
// statics), so presize from the query's own index counts.
required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
// Building shells are the landcell BUILDING channel (out of S2):
// retail draws a shell at its own DrawBuilding turn, never through
// the cell's ordinary shadow_part_list walk (Contract C), so this
// is the one sweep that survives chunk 5 — it exists ONLY to bucket
// IsBuildingShell records by their authored anchor, never to
// resolve ordinary static/dynamic cell membership.
int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
if (required > _sweepScratch.Length)
{
_sweepScratch = new RenderProjectionRecord[
Math.Max(required, _sweepScratch.Length * 2)];
}
count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch);
int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch);
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _sweepScratch[i];
if (record.EntityPayload.IsBuildingShell)
{
uint anchor = BuildingShellBucketCellId(in record);
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record);
if (!record.EntityPayload.IsBuildingShell)
continue;
}
IReadOnlyList<uint> renderCells = ResolveOutdoorStaticRenderCells(
in record, _tryGetRetailCellArray, _shadows.GetOwnerCells, out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketOutdoorRecord(
in record,
renderCells,
_outdoorByCell,
_renderCenterLbX,
_renderCenterLbY);
}
// Retail CPhysicsObj::add_shadows_to_cells installs every PartArray in
// every cell from calc_cross_cells' retained CELLARRAY. The scene's
// parent-cell dictionary cannot represent that membership, so consume
// the global dynamic index once and rebuild both indoor and outdoor
// render buckets from ShadowObjectRegistry's exact retained array.
required = _scene.IndexCounts.For(RenderSceneIndex.Dynamic);
if (required > _dynamicSweepScratch.Length)
{
_dynamicSweepScratch = new RenderProjectionRecord[
Math.Max(required, _dynamicSweepScratch.Length * 2)];
}
count = _scene.CopyIndexTo(
RenderSceneIndex.Dynamic,
_dynamicSweepScratch);
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
in record,
_tryGetRetailCellArray,
_shadows.GetOwnerCells,
out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketDynamicRecord(
in record,
renderCells,
_indoorDynamicsByCell,
_outdoorDynamicsByCell,
_renderCenterLbX,
_renderCenterLbY);
uint anchor = BuildingShellBucketCellId(in record);
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record);
}
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell
/// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// — the SAME direct read <see cref="ResolveOutdoorStaticRenderCells"/>
/// uses. An equipped child's retail array is now published by
/// <see cref="ShadowObjectRegistry.AttachChild"/> at the registry (retail
/// Contract B's <c>add_shadows_to_cells</c> child-inheritance recursion),
/// so this no longer needs its own render-side parent-chain walk — the
/// registry already resolved a nested attachment to its ultimate root.
/// When the registry has no retail array yet for this entity (the same
/// streaming-window race <see cref="ResolveIndoorStaticRenderCells"/>
/// documents), the fallback is today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer, counted the
/// same way as the static fallbacks via <paramref name="usedFallback"/>.
/// </summary>
internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
Func<uint, IReadOnlyList<uint>> getOwnerCells,
out bool usedFallback)
/// <summary>Facility Hub / cathedral discriminator: does the given cell's
/// borrowed retail render membership contain a record with this exact
/// authored SourceId? Reimplemented directly over the registry's
/// per-cell entries (chunk 5) rather than reading a swept bucket
/// dictionary — <see cref="RetailPViewRenderer"/>'s
/// <c>ProbeFacilityStairsEnabled</c> block is the only caller.</summary>
internal bool StaticBucketContains(uint cellId, uint sourceId)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId);
uint previousEntityId = 0;
bool havePrevious = false;
for (int i = 0; i < entries.Count; i++)
{
usedFallback = false;
return cells;
}
usedFallback = true;
return getOwnerCells(record.Source.LocalEntityId);
}
/// <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)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
usedFallback = false;
return cells;
}
usedFallback = true;
return record.Source.ParentCellId != 0u
? new[] { record.Source.ParentCellId }
: Array.Empty<uint>();
}
/// <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;
}
usedFallback = true;
return getOwnerCells(record.Source.LocalEntityId);
}
internal bool StaticBucketContains(uint cellId, uint sourceId) =>
_indoorByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? records)
&& records.Exists(record => record.Source.SourceId == sourceId);
/// <summary>
/// Buckets one authored indoor static into the render cell lists produced
/// by retail's cross-cell PartArray walk. Outdoor cells are routed to the
/// landscape turn because a visual part may cross an exit portal.
/// </summary>
internal static void BucketIndoorRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> renderCells,
Dictionary<uint, List<RenderProjectionRecord>> indoorBuckets,
Dictionary<uint, List<RenderProjectionRecord>> outdoorBuckets)
{
ArgumentNullException.ThrowIfNull(renderCells);
ArgumentNullException.ThrowIfNull(indoorBuckets);
ArgumentNullException.ThrowIfNull(outdoorBuckets);
bool added = false;
for (int i = 0; i < renderCells.Count; i++)
{
uint cellId = renderCells[i];
uint low = cellId & 0xFFFFu;
if (low is >= 1u and <= 64u)
{
AddToBucket(in record, cellId, outdoorBuckets);
added = true;
}
else if (low >= 0x100u)
{
AddToBucket(in record, cellId, indoorBuckets);
added = true;
}
}
if (!added && record.Source.ParentCellId != 0u)
AddToBucket(in record, record.Source.ParentCellId, indoorBuckets);
}
/// <summary>
/// Installs one live PartArray into every cell in retail's retained
/// <c>CELLARRAY</c>. Interior and landscape memberships can coexist while
/// crossing a building exit. If the collision owner is not registered yet
/// (or the object is a visual-only effect), the authored interior parent
/// or outdoor root-position cell remains the conservative fallback.
/// </summary>
internal static void BucketDynamicRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> shadowCells,
Dictionary<uint, List<RenderProjectionRecord>> indoorBuckets,
Dictionary<uint, List<RenderProjectionRecord>> outdoorBuckets,
int renderCenterLbX,
int renderCenterLbY)
{
ArgumentNullException.ThrowIfNull(shadowCells);
ArgumentNullException.ThrowIfNull(indoorBuckets);
ArgumentNullException.ThrowIfNull(outdoorBuckets);
bool added = false;
for (int i = 0; i < shadowCells.Count; i++)
{
uint cellId = shadowCells[i];
uint low = cellId & 0xFFFFu;
if (low is >= 1u and <= 64u)
{
AddToBucket(in record, cellId, outdoorBuckets);
added = true;
}
else if (low >= 0x100u)
{
AddToBucket(in record, cellId, indoorBuckets);
added = true;
}
}
if (added)
return;
uint parentLow = record.Source.ParentCellId & 0xFFFFu;
if (record.Source.ParentCellId != 0u && parentLow >= 0x100u)
{
AddToBucket(in record, record.Source.ParentCellId, indoorBuckets);
return;
}
uint outdoorCell = LandscapeCellId(
record.Transform.Position,
renderCenterLbX,
renderCenterLbY);
AddToBucket(in record, outdoorCell, outdoorBuckets);
}
/// <summary>
/// Installs one outdoor object's render shadow in every outdoor cell of
/// its authoritative physics <c>CELLARRAY</c>. This is retail's
/// <c>CPhysicsObj::add_shadows_to_cells</c> →
/// <c>CPartArray::AddPartsShadow</c> path: a large object straddling a
/// landblock edge must remain reachable when its origin cell leaves the
/// landscape walk. Objects without a collision registration (notably
/// short-lived visual effects) retain the root-position fallback.
/// </summary>
internal static void BucketOutdoorRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> shadowCells,
Dictionary<uint, List<RenderProjectionRecord>> buckets,
int renderCenterLbX,
int renderCenterLbY)
{
ArgumentNullException.ThrowIfNull(shadowCells);
ArgumentNullException.ThrowIfNull(buckets);
bool added = false;
for (int i = 0; i < shadowCells.Count; i++)
{
uint cellId = shadowCells[i];
uint cellIndex = cellId & 0xFFFFu;
if (cellIndex is < 1u or > 64u)
uint entityId = entries[i].EntityId;
if (havePrevious && entityId == previousEntityId)
continue;
previousEntityId = entityId;
havePrevious = true;
AddToBucket(in record, cellId, buckets);
added = true;
if (_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record)
&& record.Source.SourceId == sourceId)
{
return true;
}
}
if (!added)
{
uint cellId = LandscapeCellId(
record.Transform.Position,
renderCenterLbX,
renderCenterLbY);
AddToBucket(in record, cellId, buckets);
}
}
private static void AddToBucket(
in RenderProjectionRecord record,
uint cellId,
Dictionary<uint, List<RenderProjectionRecord>> buckets)
{
if (!buckets.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket))
{
buckets[cellId] = bucket = new List<RenderProjectionRecord>();
}
bucket.Add(record);
}
/// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position
/// — retail's 24 m cell grid inside the 192 m landblock, producing the
/// same TRUE <c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> encoding
/// the walk's landscape turn emits: the relative block index
/// (<c>floor(p/192)</c>) plus the streaming center recovers the true
/// landblock byte, because entity positions carry
/// <c>(lbX CenterX)·192</c> world offsets
/// (<c>LandblockBuildFactory</c>'s <c>worldOffset</c>).</summary>
internal static uint LandscapeCellId(
Vector3 relativePosition, int renderCenterLbX, int renderCenterLbY)
{
int relBlockX = (int)MathF.Floor(relativePosition.X / 192f);
int relBlockY = (int)MathF.Floor(relativePosition.Y / 192f);
float localX = relativePosition.X - relBlockX * 192f;
float localY = relativePosition.Y - relBlockY * 192f;
int cellX = Math.Clamp((int)(localX / 24f), 0, 7);
int cellY = Math.Clamp((int)(localY / 24f), 0, 7);
uint landblock =
((uint)(byte)(renderCenterLbX + relBlockX) << 24)
| ((uint)(byte)(renderCenterLbY + relBlockY) << 16);
return landblock | (uint)(cellX * 8 + cellY + 1);
return false;
}
public WalkFrameStaticRecords GetCellStatics(uint cellId)
{
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_indoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
EmitFacilityShadowProbe(records.Records, cellId, "static");
_cellCache[cellId] = records;
return records;
@ -568,19 +256,112 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_indoorDynamicsByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
EmitFacilityShadowProbe(records.Records, cellId, "dynamic");
_cellDynamicCache[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
{
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
_outdoorMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId)
{
if (_outdoorDynamicsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
_outdoorDynamicsMaterialized[cellId] = records;
return records;
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: one cell id's borrowed retail render
/// membership. Reads <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>
/// — already in retail CELLARRAY-then-part-array insertion order —
/// collapses adjacent entries down to their distinct owning entity ids
/// (an entity's own entries for one cell are always written as one
/// contiguous run: <c>PublishRetailPartEntries</c> removes then re-adds a
/// whole entity's rows atomically, never interleaving two entities'
/// rows), resolves each id to its current projected record through
/// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/>, and keeps only
/// the records matching <paramref name="dynamic"/>'s static/dynamic
/// class and excluding building shells. Indoor vs. outdoor is entirely a
/// property of WHICH cellId the caller passes (an indoor cell id's low
/// word is <c>&gt;= 0x100</c>, an outdoor one is in <c>[1, 64]</c> —
/// retail's own two id spaces): the registry already published this
/// entity into every crossed cell under its own true id, so no
/// additional indoor/outdoor dispatch is needed here.
/// </summary>
private WalkFrameStaticRecords ResolveCellView(uint cellId, bool dynamic)
{
IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId);
if (entries.Count == 0)
return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
int written = 0;
uint previousEntityId = 0;
bool havePrevious = false;
for (int i = 0; i < entries.Count; i++)
{
uint entityId = entries[i].EntityId;
if (havePrevious && entityId == previousEntityId)
continue;
previousEntityId = entityId;
havePrevious = true;
if (!_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record))
{
// The transient streaming window (this type's own doc
// comment): the registry already flooded this entity into
// its retail CELLARRAY, but the presentation journal has
// not applied its projected record yet this frame. Retail
// draws nothing for an object not yet in a cell — no second
// fallback.
if (_unregisteredEntitiesThisFrame.Add(entityId))
UnregisteredRenderMembershipCount++;
continue;
}
if (record.EntityPayload.IsBuildingShell)
continue; // buildings draw at their own shell turn.
if (IsDynamicProjectionClass(record.ProjectionClass) != dynamic)
continue;
if (written == _cellViewScratch.Length)
{
var grown = new RenderProjectionRecord[_cellViewScratch.Length * 2];
Array.Copy(_cellViewScratch, grown, written);
_cellViewScratch = grown;
}
_cellViewScratch[written++] = record;
}
return written == 0
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
: new WalkFrameStaticRecords(
AppendToArena(_cellViewScratch.AsSpan(0, written)), _tupleLandblockId);
}
/// <summary>The same static/dynamic split <c>ArchRenderScene</c>'s own
/// internal indexing uses: a live dynamic root or an equipped child is
/// "dynamic"; every other <see cref="RenderProjectionClass"/> (including
/// <see cref="RenderProjectionClass.ActiveAnimatedStatic"/>) is
/// "static".</summary>
private static bool IsDynamicProjectionClass(RenderProjectionClass projectionClass) =>
projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
/// <summary>
/// Facility Hub discriminator for retail's cross-cell render-shadow path.
/// The scene query is currently keyed by authored parent cell, while retail
@ -643,41 +424,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
}
}
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
{
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_outdoorMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId)
{
if (_outdoorDynamicsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records =
_outdoorDynamicsByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_outdoorDynamicsMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
{
uint anchor = BuildingShellBucketCellId(building);
@ -698,7 +444,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
/// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at
/// its current length, growing the arena first if needed (doubling, or
/// exactly enough for an unusually large sweep — the same growth shape
/// <see cref="_sweepScratch"/>/<see cref="_cellScratch"/> already use),
/// <see cref="_sweepScratch"/>/<see cref="_cellViewScratch"/> already use),
/// and returns the segment the copy landed in. A prior frame's growth can
/// leave an earlier-returned segment pointing at a retired backing array
/// — harmless, since that array's content stays valid and nothing reads

View file

@ -1084,6 +1084,34 @@ public sealed class LandblockPhysicsPublisher
}
}
// Campaign OVERHAUL S2 chunk 5 closeout: a DAT static with visual
// parts but NO collision geometry (decorative trim, wall panels,
// banners) is still a CPhysicsObj retail floods into its cells —
// calc_cross_cells_static (0x00515160) takes the find_bbox_cell_list
// route over the part array when there is no cylsphere, and
// add_shadows_to_cells (0x00514ae0) publishes AddPartsShadow rows
// for every part regardless of collision. Since chunk 5 the walk
// draws ONLY what the registry flooded (the parent-cell fallback is
// deleted), so such a static must register render-only here or it
// vanishes (G2 self-gate: the Facility Hub's purple-lit wall panels).
if (entityBspCount == 0 && entityCylinderCount == 0
&& partArray.Count > 0)
{
publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
Array.Empty<ShadowShape>(),
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true,
partArray: partArray);
}
if (entityBspCount > 0)
publication.BspOwnerCount++;
if (entityCylinderCount > 0)