acdream/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
Erik c673f767e9 feat(rendering): port retail building degrade walk
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.
2026-09-04 22:29:18 +02:00

548 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>&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 (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;
}
}