Add exact retail building degrade selection, shared FPS/degrade ownership, complete-body gating, selected-shell submission, ordinary ladder mesh residency, frame-scoped retry rearm, Config controls, installed-DAT census, and lifecycle/allocation proofs. Reviews: OpenAI retail pass 3 PASS; OpenAI production pass 5 PASS. Gates: Release 0W/0E; focused 285/285; hermetic 16811/16811; InstalledDat 469 pass, 10 documented fail, 1 documented skip; both manifests 30/30.
548 lines
26 KiB
C#
548 lines
26 KiB
C#
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
using System.Text;
|
||
using AcDream.App.Rendering.Scene;
|
||
using AcDream.Core.Physics;
|
||
|
||
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"/>.
|
||
///
|
||
/// <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>
|
||
///
|
||
/// <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>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) — 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
|
||
{
|
||
private readonly WalkBuildingRegistry _buildings;
|
||
private readonly ShadowObjectRegistry _shadows;
|
||
private RenderSceneQuery _scene;
|
||
private uint _tupleLandblockId;
|
||
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellObjectsCache = new();
|
||
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = 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> _outdoorObjectsMaterialized = new();
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
|
||
|
||
// 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];
|
||
|
||
// 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.
|
||
private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096];
|
||
private int _arenaLength;
|
||
|
||
internal WalkProductionWorldData(
|
||
WalkBuildingRegistry buildings,
|
||
ShadowObjectRegistry shadows)
|
||
{
|
||
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 UnregisteredRenderMembershipCount { get; private set; }
|
||
|
||
/// <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"/>
|
||
/// 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;
|
||
_cellCache.Clear();
|
||
_cellDynamicCache.Clear();
|
||
_cellObjectsCache.Clear();
|
||
_outdoorMaterialized.Clear();
|
||
_outdoorDynamicsMaterialized.Clear();
|
||
_outdoorObjectsMaterialized.Clear();
|
||
_shellMaterialized.Clear();
|
||
_arenaLength = 0;
|
||
UnregisteredRenderMembershipCount = 0;
|
||
_unregisteredEntitiesThisFrame.Clear();
|
||
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
|
||
bucket.Clear();
|
||
|
||
// 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)];
|
||
}
|
||
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)
|
||
continue;
|
||
uint anchor = BuildingShellBucketCellId(in record);
|
||
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
|
||
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
|
||
shells.Add(record);
|
||
}
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
IReadOnlyList<RetailPartEntry> entries =
|
||
_shadows.GetRetailPartEntriesInCell(cellId);
|
||
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)
|
||
&& record.Source.SourceId == sourceId)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetCellStatics(uint cellId)
|
||
{
|
||
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
|
||
EmitFacilityShadowProbe(records.Records, cellId, "static");
|
||
_cellCache[cellId] = records;
|
||
return records;
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetCellObjects(uint cellId)
|
||
{
|
||
if (_cellObjectsCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
|
||
EmitFacilityShadowProbe(records.Records, cellId, "combined");
|
||
_cellObjectsCache[cellId] = records;
|
||
return records;
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetCellDynamics(uint cellId)
|
||
{
|
||
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
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;
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetOutdoorObjects(uint cellId)
|
||
{
|
||
if (_outdoorObjectsMaterialized.TryGetValue(
|
||
cellId,
|
||
out WalkFrameStaticRecords cached))
|
||
{
|
||
return cached;
|
||
}
|
||
|
||
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: null);
|
||
_outdoorObjectsMaterialized[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 (dynamic.HasValue
|
||
&& IsDynamicProjectionClass(record.ProjectionClass) != dynamic.Value)
|
||
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
|
||
/// also appends each object's parts to every cell in its physics CELLARRAY
|
||
/// through CPhysicsObj::add_shadows_to_cells / CPartArray::AddPartsShadow.
|
||
/// This probe prints the authoritative physics owner set without changing
|
||
/// admission, so a correction is made only if the live staircase/player
|
||
/// registration proves that path is populated.
|
||
/// </summary>
|
||
private void EmitFacilityShadowProbe(
|
||
ReadOnlySpan<RenderProjectionRecord> records,
|
||
uint queriedCellId,
|
||
string route)
|
||
{
|
||
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
|
||
return;
|
||
|
||
for (int recordIndex = 0; recordIndex < records.Length; recordIndex++)
|
||
{
|
||
ref readonly RenderProjectionRecord record = ref records[recordIndex];
|
||
bool targetPlayer = record.EntityPayload.CasterIdentity
|
||
== RenderCasterIdentityKind.LocalPlayer;
|
||
bool targetStair = record.Source.SourceId == 0x02000623u;
|
||
bool targetCathedralStair =
|
||
record.Source.SourceId == 0x020009A2u;
|
||
|
||
if (!targetStair && !targetCathedralStair && !targetPlayer)
|
||
continue;
|
||
|
||
IReadOnlyList<uint> ownerCells =
|
||
_shadows.GetOwnerCells(record.Source.LocalEntityId);
|
||
var cells = new StringBuilder(ownerCells.Count * 11 + 2);
|
||
cells.Append('[');
|
||
for (int cellIndex = 0; cellIndex < ownerCells.Count; cellIndex++)
|
||
{
|
||
if (cellIndex != 0)
|
||
cells.Append(',');
|
||
cells.Append("0x").Append(ownerCells[cellIndex].ToString("X8"));
|
||
}
|
||
cells.Append(']');
|
||
|
||
string key = $"{route}:{queriedCellId:X8}:"
|
||
+ $"{record.Source.LocalEntityId:X8}";
|
||
string signature = $"{record.Source.ParentCellId:X8}:{cells}";
|
||
if (_facilityShadowProbeSignatures.TryGetValue(key, out string? prior)
|
||
&& string.Equals(prior, signature, StringComparison.Ordinal))
|
||
{
|
||
continue;
|
||
}
|
||
_facilityShadowProbeSignatures[key] = signature;
|
||
|
||
Console.WriteLine(
|
||
$"[facility-shadow] route={route} "
|
||
+ $"kind={(targetPlayer ? "player" : targetCathedralStair ? "cathedral-stair" : "stair")} "
|
||
+ $"guid=0x{record.Source.ServerGuid:X8} "
|
||
+ $"local=0x{record.Source.LocalEntityId:X8} "
|
||
+ $"query=0x{queriedCellId:X8} "
|
||
+ $"parent=0x{record.Source.ParentCellId:X8} "
|
||
+ $"owners={cells}");
|
||
}
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
|
||
{
|
||
uint anchor = BuildingShellBucketCellId(building);
|
||
if (anchor == 0)
|
||
return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||
if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
WalkFrameStaticRecords records =
|
||
_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)
|
||
&& shells.Count > 0
|
||
? new WalkFrameStaticRecords(
|
||
AppendToArena(CollectionsMarshal.AsSpan(shells)), _tupleLandblockId)
|
||
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||
_shellMaterialized[anchor] = records;
|
||
return records;
|
||
}
|
||
|
||
/// <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="_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
|
||
/// a segment across a frame boundary (see this type's own doc
|
||
/// comment).</summary>
|
||
private ArraySegment<RenderProjectionRecord> AppendToArena(
|
||
ReadOnlySpan<RenderProjectionRecord> source)
|
||
{
|
||
if (source.Length == 0)
|
||
return ArraySegment<RenderProjectionRecord>.Empty;
|
||
|
||
int required = _arenaLength + source.Length;
|
||
if (required > _arena.Length)
|
||
{
|
||
var grown = new RenderProjectionRecord[Math.Max(required, _arena.Length * 2)];
|
||
Array.Copy(_arena, grown, _arenaLength);
|
||
_arena = grown;
|
||
}
|
||
|
||
source.CopyTo(_arena.AsSpan(_arenaLength, source.Length));
|
||
var segment = new ArraySegment<RenderProjectionRecord>(_arena, _arenaLength, source.Length);
|
||
_arenaLength += source.Length;
|
||
return segment;
|
||
}
|
||
|
||
/// <summary>The building's authored shell anchor: its first non-exit
|
||
/// portal's destination cell — the SAME rule <c>LandblockLoader</c> used
|
||
/// when it stamped <c>BuildingShellAnchorCellId</c> on the shell entity.</summary>
|
||
internal static uint AnchorCellId(WalkBuilding building)
|
||
{
|
||
foreach (ref readonly WalkBldPortal portal in building.Portals.AsSpan())
|
||
{
|
||
if (portal.OtherCellId != 0xFFFFFFFFu)
|
||
return portal.OtherCellId;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Selects the retained-scene bucket for a shell record. Portal-bearing
|
||
/// buildings use their authored EnvCell anchor. A portal-less building has
|
||
/// no such anchor, but it is still a real <c>CBuildingObj</c>; retail's
|
||
/// <c>DrawSortCell</c> reaches it through the outdoor cell containing its
|
||
/// placement, represented by <c>EffectCellId</c> on the retained record.
|
||
/// </summary>
|
||
internal static uint BuildingShellBucketCellId(in RenderProjectionRecord record)
|
||
=> record.Source.BuildingShellAnchorCellId != 0
|
||
? record.Source.BuildingShellAnchorCellId
|
||
: record.Source.EffectCellId;
|
||
|
||
/// <summary>
|
||
/// Resolves the same bucket from the walk-side building. The authored
|
||
/// interior anchor wins when present; otherwise the landscape assembler's
|
||
/// exact position cell is the shell turn that retail uses.
|
||
/// </summary>
|
||
internal static uint BuildingShellBucketCellId(WalkBuilding building)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(building);
|
||
uint anchor = AnchorCellId(building);
|
||
return anchor != 0 ? anchor : building.PositionCellId;
|
||
}
|
||
|
||
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building)
|
||
{
|
||
if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"walk building 0x{building.PositionCellId:X8} has no committed registry entry");
|
||
}
|
||
return entry.PartZeroWorldTransform;
|
||
}
|
||
}
|