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)

View file

@ -650,6 +650,8 @@ public static class LandblockPhysicsContentBuilder
entity.SourceGfxObjOrSetupId);
if (setup is null)
{
RegisterRenderOnlyStatic(
engine, entity, partArray, landblock, origin);
noCollision++;
continue;
}
@ -710,6 +712,8 @@ public static class LandblockPhysicsContentBuilder
// LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count == 0)
{
RegisterRenderOnlyStatic(
engine, entity, partArray, landblock, origin);
noCollision++;
continue;
}
@ -744,6 +748,41 @@ public static class LandblockPhysicsContentBuilder
noCollision);
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 closeout: a DAT static with visual parts
/// but no collision geometry is still a <c>CPhysicsObj</c> retail floods
/// into its cells — <c>calc_cross_cells_static</c> (0x00515160) takes the
/// <c>find_bbox_cell_list</c> route over the part array when there is no
/// cylsphere, and <c>add_shadows_to_cells</c> (0x00514ae0) publishes
/// <c>AddPartsShadow</c> rows for every part regardless of collision.
/// The walk draws only what the registry flooded, so the static registers
/// render-only (empty collision shapes, whole part array); a static with
/// no visual part either stays unregistered.
/// </summary>
private static void RegisterRenderOnlyStatic(
PhysicsEngine engine,
WorldEntity entity,
IReadOnlyList<ShadowShape> partArray,
LoadedLandblock landblock,
Vector3 origin)
{
if (partArray.Count == 0)
return;
engine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
Array.Empty<ShadowShape>(),
0u,
EntityCollisionFlags.None,
worldOffsetX: origin.X,
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true,
partArray: partArray);
}
private static T Require<T>(
PreparedCollisionReadResult<T> result,
string kind,

View file

@ -506,6 +506,20 @@ public sealed class ShadowObjectRegistry
RetailCellArrayRoute route,
IReadOnlyList<ShadowShape> partArray)
{
// Campaign OVERHAUL S2 review fix (arch F3): retail never floods an
// attached child — add_shadows_to_cells (0x00514ae0) passes the
// ROOT's CELLARRAY down through `children`. A re-registration or move
// of an entity that is currently attached (an equipped item's
// appearance update through ReplaceMultiPartPayload/RegisterMultiPart)
// only refreshes the part array it inherits the root's cells with.
if (_childParent.ContainsKey(entityId))
{
if (partArray.Count != 0)
_childPartArrays[entityId] = partArray;
_retailCellArrayRoutes[entityId] = route;
PublishChildEntries(entityId);
return;
}
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? previousCells))
{
RemoveRetailPartEntriesFromCells(entityId, previousCells);
@ -604,13 +618,71 @@ public sealed class ShadowObjectRegistry
cellIds[i],
out List<RetailPartEntry>? entries))
{
entries.RemoveAll(e => e.EntityId == entityId);
RemoveOwnerPartRows(entries, entityId);
if (entries.Count == 0)
_retailPartEntriesByCell.Remove(cellIds[i]);
}
}
}
/// <summary>The <see cref="RemoveOwnerRows"/> twin for the retail part
/// rows: a reverse index loop, no closure — this runs per cell on every
/// accepted move (arch review F7).</summary>
private static void RemoveOwnerPartRows(
List<RetailPartEntry> entries,
uint entityId)
{
for (int index = entries.Count - 1; index >= 0; index--)
{
if (entries[index].EntityId == entityId)
entries.RemoveAt(index);
}
}
private static ShadowEntry[] CollectOwnerRows(
List<ShadowEntry> entries,
uint entityId)
{
int count = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
count++;
}
if (count == 0)
return Array.Empty<ShadowEntry>();
var rows = new ShadowEntry[count];
int written = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
rows[written++] = entries[index];
}
return rows;
}
private static RetailPartEntry[] CollectOwnerPartRows(
List<RetailPartEntry> entries,
uint entityId)
{
int count = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
count++;
}
if (count == 0)
return Array.Empty<RetailPartEntry>();
var rows = new RetailPartEntry[count];
int written = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
rows[written++] = entries[index];
}
return rows;
}
/// <summary>
/// Publishes retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) rows
/// for one entity: for every cell in <paramref name="orderedCells"/>, in
@ -742,6 +814,12 @@ public sealed class ShadowObjectRegistry
_childPartArrays[childEntityId] = childPartArray;
PublishChildEntries(childEntityId);
// Campaign OVERHAUL S2 review fix (arch F2): an attach mutates shared
// per-cell rows, so it must invalidate any prepared SetPosition commit
// whose captured cell lists predate it (IsPreparedSetPositionCurrent
// keys on the mutation revision) — otherwise that commit's wholesale
// list install silently drops the child's rows.
AdvanceMutationRevision();
return true;
}
@ -786,6 +864,7 @@ public sealed class ShadowObjectRegistry
if (siblings.Count == 0)
_parentChildren.Remove(parentId);
}
AdvanceMutationRevision(); // see AttachChild (arch F2)
return true;
}
@ -1041,7 +1120,32 @@ public sealed class ShadowObjectRegistry
bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null)
{
if (shapes.Count == 0) { Deregister(entityId); return; }
if (shapes.Count == 0)
{
// Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose
// collision dispatch produced NO shapes (LiveEntityCollisionBuilder.
// Build's Shapes==[]) but whose Setup still has visual parts — the
// retail short-lived spell/visual effect-object case — is NOT a
// deregistration. Contract B's render membership
// (CPartArray::AddPartsShadow) does not require a successful
// collision dispatch; only the collision-side shadow_object_list
// does. Route it to the render-only registration path instead.
// A caller with no part array either (every pre-chunk-5 test
// fixture, and any genuinely shapeless entity) keeps the exact
// prior behavior: deregister.
if (partArray is { Count: > 0 })
{
RegisterRenderOnly(
entityId, entityWorldPos, entityWorldRot, state, flags,
worldOffsetX, worldOffsetY, landblockId, seedCellId,
isStatic, publishMutation, partArray);
}
else
{
Deregister(entityId);
}
return;
}
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
@ -1157,6 +1261,79 @@ public sealed class ShadowObjectRegistry
}
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 (item B): registers a live entity that
/// has NO collision shapes at all but DOES have a visual part array — the
/// retail short-lived spell/visual effect-object case
/// (<c>LiveEntityCollisionBuilder.Build</c> returns
/// <c>Shapes = []</c>, <c>RenderParts != []</c>). Retail's
/// <c>calc_cross_cells_static</c> dispatch (Contract A) never
/// special-cases "no collision shapes": the cylsphere-vs-bbox TEST reads
/// the collision shapes (none here, so the test is always false) and the
/// flood unconditionally falls through to the bbox route over the WHOLE
/// part array (<see cref="ComputeContractACellArray"/> with an empty
/// <c>collisionShapes</c> list). The entity therefore gets an exact
/// retail CELLARRAY and <see cref="RetailPartEntry"/> rows exactly like a
/// colliding entity, but never a <see cref="ShadowEntry"/> collision row
/// anywhere — Contract B's <c>shadow_object_list</c> receives one entry
/// per CELLARRAY cell only when the object HAS a part to shadow there;
/// this object contributes to <c>shadow_part_list</c> only.
/// <para>
/// <see cref="_entityShapes"/> is retained as an EMPTY (not absent) list
/// so every existing multi-part-dispatch site
/// (<see cref="UpdatePosition"/>, <see cref="ReplacePositionRows"/>,
/// <see cref="RefloodOwnerForLandblock"/>) takes the "multi-part, zero
/// shapes" branch on a later move/reflood instead of falling back to the
/// single-shape <see cref="Register"/> path or synthesizing a bogus
/// zero-radius shadow entry.
/// </para>
/// </summary>
private void RegisterRenderOnly(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
uint state,
EntityCollisionFlags flags,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId,
bool isStatic,
bool publishMutation,
IReadOnlyList<ShadowShape> partArray)
{
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
(IReadOnlyList<uint> cellSet, RetailCellArrayRoute retailRoute) =
ComputeContractACellArray(
seed,
entityWorldPos,
entityWorldRot,
state,
collisionShapes: Array.Empty<ShadowShape>(),
partArray,
isStatic);
if (cellSet.Count == 0) return; // keep-when-empty (pc:283540).
DeregisterCore(entityId, publishMutation: false);
_entityShapes[entityId] = Array.Empty<ShadowShape>();
_entityReg[entityId] = new RegistrationRecord(
seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
_entityRetailPartArrays[entityId] = partArray;
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray);
}
/// <summary>
/// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the
@ -1183,7 +1360,11 @@ public sealed class ShadowObjectRegistry
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart)
{
if (shapes.Count == 0)
// Campaign OVERHAUL S2 chunk 5 (item B): a not-yet-registered
// entity with no collision shapes but a real part array still
// needs registering (render-only) — RegisterMultiPart's own
// shapes.Count==0 branch now handles that dispatch.
if (shapes.Count == 0 && (partArray is null || partArray.Count == 0))
return;
RegisterMultiPart(
entityId,
@ -1217,7 +1398,10 @@ public sealed class ShadowObjectRegistry
// this replaces the retained part array WITHOUT re-flooding. It
// reuses the CURRENT retail CELLARRAY (if any) and only rewrites
// which parts occupy it, mirroring the collision payload swap below.
if (partArray is not null)
// Arch review F9: a non-null but EMPTY part array is "no part to
// shadow" — treated exactly like the null case rather than leaving a
// cell array with no drawable rows behind.
if (partArray is { Count: > 0 })
{
_entityRetailPartArrays[entityId] = partArray;
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells)
@ -2270,6 +2454,37 @@ public sealed class ShadowObjectRegistry
return;
}
// Campaign OVERHAUL S2 review fix (retail F3): a RENDER-ONLY owner
// (part array, no collision shapes, hence never any retained
// collision cells) still moves in retail — add_shadows_to_cells
// (0x00514ae0) adds the CShadowObj unconditionally (pc:282856) and
// gates only AddPartsShadow on part_array != 0, so a shapeless
// visual object keeps a cell array it travels with. Its transition
// carries no sphere, so that array is its destination cell alone
// (num_cells == 1 → AddPartsShadow without clip planes). AD-117
// records the single-cell reading until a cdb trace pins the
// zero-sphere transition's exact cell list.
if (!_entityToCells.ContainsKey(entityId)
&& !_suspendedEntityCells.ContainsKey(entityId)
&& seedCellId != 0u
&& _entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? renderPartArray)
&& renderPartArray.Count != 0)
{
_suspendedEntities.Remove(entityId);
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
_singleCellScratch[0] = seedCellId;
PublishRetailProductFromExactCells(entityId, _singleCellScratch);
BumpOwnerVersion(entityId);
return;
}
// Campaign OVERHAUL S2 chunk 4: retail's keep-when-empty gate
// (pc:283540) reached its terminal case — no retained collision
// cells at all to republish from — so BOTH products stay exactly as
@ -2285,6 +2500,8 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
}
private readonly uint[] _singleCellScratch = new uint[1];
private void ReplacePositionRows(
uint entityId,
RegistrationRecord registration,
@ -2326,12 +2543,12 @@ public sealed class ShadowObjectRegistry
exactCells.Add(cellId);
}
if (registration.IsMultiPart
&& _entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
IReadOnlyList<ShadowShape>? shapes = null;
bool isMultiPartDispatch = registration.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out shapes);
if (isMultiPartDispatch)
{
foreach (ShadowShape shape in shapes)
foreach (ShadowShape shape in shapes!)
{
Vector3 partWorldPosition = worldPosition
+ Vector3.Transform(shape.LocalPosition, worldRotation);
@ -2371,7 +2588,15 @@ public sealed class ShadowObjectRegistry
AddEntryToCell(entry, exactCells[index]);
}
if (exactCells.Count == 0)
// Campaign OVERHAUL S2 chunk 5 (item B): a render-only multi-part
// entity (registration.IsMultiPart with an EMPTY retained shapes
// list — see RegisterRenderOnly) writes NO ShadowEntry rows above.
// _entityToCells must stay absent for it too, or a consumer reading
// it (GetOwnerCells, DeregisterCore's cleanup walk) would see cell
// ids that carry no actual collision row — a membership claim this
// registry never backs with a shadow_object_list entry.
bool wroteCollisionEntries = !isMultiPartDispatch || shapes!.Count != 0;
if (exactCells.Count == 0 || !wroteCollisionEntries)
_entityToCells.Remove(entityId);
else
_entityToCells[entityId] = exactCells;
@ -2414,11 +2639,26 @@ public sealed class ShadowObjectRegistry
foreach (uint cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(entry => entry.EntityId == entityId);
RemoveOwnerRows(list, entityId);
}
_entityToCells.Remove(entityId);
}
// Campaign OVERHAUL S2 review fix (arch F1 / retail F2): retail's
// remove_shadows_from_cells (0x00511230) is ONE transaction over both
// products — per shadow cell it calls CObjCell::remove_shadow_object
// AND CPartArray::RemoveParts, then recurses through children. A
// suspended object therefore has no render membership either; the
// retained part array and route survive so the un-suspending move
// (ReplacePositionRows → PublishRetailProductFromExactCells)
// republishes from the transition's cells.
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells))
{
RemoveRetailPartEntriesFromCells(entityId, retailCells);
_retailCellArrays.Remove(entityId);
RepublishAttachedChildren(entityId);
}
_suspendedEntities.Add(entityId);
BumpOwnerVersion(entityId);
return true;
@ -2502,6 +2742,19 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out var withdrawnBeforeReflood);
// Campaign OVERHAUL S2 chunk 5 closeout: a reflood is retail's
// recalc_cross_cells over the SAME CPartArray — the render product
// (retail cell array + AddPartsShadow rows) is recomputed from the
// retained part array exactly like the movement path above does.
// Without this the walk (which reads ONLY GetRetailPartEntriesInCell
// since chunk 5) lost every reflooded owner: landblock replacement
// commits (PhysicsEngine.ApplyCommittedOwnerReplacement) and the
// Content builder's post-publication reflood both come through here.
_entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? retainedPartArray);
if (reg.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out var shapes))
{
@ -2517,7 +2770,8 @@ public sealed class ShadowObjectRegistry
lbPrefix,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
publishMutation: false,
partArray: retainedPartArray);
}
else
{
@ -2537,7 +2791,8 @@ public sealed class ShadowObjectRegistry
reg.Flags,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
publishMutation: false,
partArray: retainedPartArray);
}
// Register is also the authoritative movement/replacement API and
@ -2870,10 +3125,69 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Remove(eid);
}
}
// Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the retail
// render product ends with the landblock too — remove_shadows_from_
// cells (0x00511230) never removes the CShadowObj without the
// AddPartsShadow rows. Every part row in the prefix's cells goes;
// each owner's cell array loses those cells (a dynamic owner keeps
// its part array for the reload reflood, a static owner ends here —
// including a render-only static, which has no collision cells and
// so was invisible to the loops above).
RemoveRetailProductForPrefix(lbPrefix, touchedOwners);
foreach (uint entityId in touchedOwners)
BumpOwnerVersion(entityId);
}
private readonly List<uint> _prefixRemovalScratch = new();
private void RemoveRetailProductForPrefix(
uint lbPrefix,
HashSet<uint> touchedOwners)
{
_prefixRemovalScratch.Clear();
foreach (uint cellId in _retailPartEntriesByCell.Keys)
{
if ((cellId & 0xFFFF0000u) == lbPrefix)
_prefixRemovalScratch.Add(cellId);
}
for (int i = 0; i < _prefixRemovalScratch.Count; i++)
_retailPartEntriesByCell.Remove(_prefixRemovalScratch[i]);
_prefixRemovalScratch.Clear();
foreach (var (ownerId, cells) in _retailCellArrays)
{
for (int i = cells.Count - 1; i >= 0; i--)
{
if ((cells[i] & 0xFFFF0000u) == lbPrefix)
{
cells.RemoveAt(i);
touchedOwners.Add(ownerId);
}
}
if (cells.Count == 0)
_prefixRemovalScratch.Add(ownerId);
}
for (int i = 0; i < _prefixRemovalScratch.Count; i++)
{
uint ownerId = _prefixRemovalScratch[i];
_retailCellArrays.Remove(ownerId);
bool endsWithLandblock =
!_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration)
|| registration.IsStatic;
if (!endsWithLandblock)
continue;
_retailCellArrayRoutes.Remove(ownerId);
_entityRetailPartArrays.Remove(ownerId);
// A render-only static never had collision cells, so the static
// retirement loop above could not reach its registration.
_entityShapes.Remove(ownerId);
_entityReg.Remove(ownerId);
_suspendedEntities.Remove(ownerId);
_suspendedEntityCells.Remove(ownerId);
_withdrawnPrefixesByOwner.Remove(ownerId);
}
}
/// <summary>
/// Retires one logical owner's rows from a streamed-out prefix. This is
/// the owner-granular form used by the collision-generation retirement
@ -2895,10 +3209,39 @@ public sealed class ShadowObjectRegistry
AdvanceMutationRevision();
return;
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
bool touched = false;
// Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the owner's
// retail part rows leave the retired prefix's cells together with
// its collision rows (remove_shadows_from_cells 0x00511230 removes
// both per cell); the part array stays for the reload reflood.
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells))
{
for (int index = retailCells.Count - 1; index >= 0; index--)
{
uint cellId = retailCells[index];
if ((cellId & 0xFFFF0000u) != prefix)
continue;
touched = true;
retailCells.RemoveAt(index);
if (_retailPartEntriesByCell.TryGetValue(
cellId,
out List<RetailPartEntry>? partRows))
{
RemoveOwnerPartRows(partRows, entityId);
if (partRows.Count == 0)
_retailPartEntriesByCell.Remove(cellId);
}
}
if (retailCells.Count == 0)
_retailCellArrays.Remove(entityId);
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
if (touched)
BumpOwnerVersion(entityId);
return;
}
for (int index = cells.Count - 1; index >= 0; index--)
{
uint cellId = cells[index];
@ -3311,8 +3654,7 @@ public sealed class ShadowObjectRegistry
{
rows.Add(new PreparedShadowCellRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
CollectOwnerRows(entries, entityId)));
}
}
}
@ -3341,8 +3683,7 @@ public sealed class ShadowObjectRegistry
{
retailRows.Add(new PreparedShadowRetailPartRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
CollectOwnerPartRows(entries, entityId)));
}
}
}

View file

@ -220,19 +220,15 @@ internal sealed class LiveEntityCollisionBuilder
effectivePartGfxObjIds: effectivePartGfxObjIds,
physicsBspBounds: _physicsBspBounds);
if (shapes.Count == 0 && !retainEmptyPayload)
return null;
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie;
if (spawn.ObjectDescriptionFlags is { } descriptionFlags)
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags);
if (spawn.ItemType == (uint)ItemType.Creature)
flags |= EntityCollisionFlags.IsCreature;
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every Setup part, colliding or not — beside the BSP-exclusive
// `shapes` collision dispatch above. A side product only; nothing
// consumes it yet.
// Campaign OVERHAUL S2 chunk 5 (item B): retail's WHOLE visual part
// array — every Setup part, colliding or not — computed BEFORE the
// empty-shapes gate below. A live entity whose Setup carries visual
// parts but no collision geometry at all (a short-lived spell/visual
// effect object) must still register for render: Contract B's
// CPartArray::AddPartsShadow membership is not gated on a successful
// collision-shape dispatch, only on a non-null part array. Distinct
// from `shapes`, which stays the BSP-exclusive COLLISION dispatch
// (AP-152).
IReadOnlyList<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts(
setup,
scale,
@ -241,6 +237,21 @@ internal sealed class LiveEntityCollisionBuilder
_getGfxObj,
_getVisualBounds);
// Campaign OVERHAUL S2 chunk 5: a Setup with NEITHER a collision
// shape NOR a visual part yields no registration at all — retail
// synthesizes nothing for a truly shapeless object (see this
// method's class remarks). `retainEmptyPayload` (ObjDesc updates
// that must be able to clear a prior collision payload down to
// nothing) still forces a registration through even here.
if (shapes.Count == 0 && renderParts.Count == 0 && !retainEmptyPayload)
return null;
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie;
if (spawn.ObjectDescriptionFlags is { } descriptionFlags)
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags);
if (spawn.ItemType == (uint)ItemType.Creature)
flags |= EntityCollisionFlags.IsCreature;
return new LiveEntityCollisionRegistration(
entity.Id,
entity.SourceGfxObjOrSetupId,