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:
parent
f30039d3d9
commit
c94a1a407e
20 changed files with 2141 additions and 1026 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 & 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>>= 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue