acdream/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
Erik 13fcf38138 fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:07:06 +02:00

2854 lines
108 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.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-cell shadow-object index — the collision-query side of retail's
/// <c>CObjCell.shadow_object_list</c> (acclient.h:30916-30936). Each entity
/// registers into the EXACT cells its collision footprint overlaps, computed
/// at registration time by the sphere-overlap portal flood
/// (<see cref="CellTransit.BuildShadowCellSet"/> = retail
/// <c>CObjCell::find_cell_list</c>, Ghidra 0x0052b4e0, as invoked by
/// <c>calc_cross_cells(_static)</c> 0x00515230/0x00515160). The Transition
/// system queries strictly per cell (<see cref="GetObjectsInCell"/> = retail
/// <c>CObjCell::find_obj_collisions</c> iterating only
/// <c>this-&gt;shadow_object_list</c>, Ghidra 0x0052b750).
///
/// <para>
/// BR-7 / A6.P4 (2026-06-11): this replaces the previous outdoor 24-m XY
/// grid-rectangle placement + 9-landblock radial query sweep. There is no
/// spatial radius anywhere in retail's query path; cell membership IS the
/// broad phase. The b3ce505 indoor-primary gate, the isViewer exemption,
/// and the +5 m query pad all existed to compensate the grid approximation
/// and are deleted with it.
/// </para>
/// </summary>
public sealed class ShadowObjectRegistry
{
private CollisionWorldStateSlot _collisionWorld;
private Dictionary<uint, List<ShadowEntry>> _cells =>
_collisionWorld.Current.ShadowCells;
private Dictionary<uint, List<uint>> _entityToCells =>
_collisionWorld.Current.ShadowEntityCells; // for deregistration
private HashSet<uint> _suspendedEntities =>
_collisionWorld.Current.SuspendedShadowEntities;
private Dictionary<uint, List<uint>> _suspendedEntityCells =>
_collisionWorld.Current.SuspendedShadowEntityCells;
// Rows withdrawn because a touched landblock streamed out. The owner may
// be seeded in an adjacent still-resident landblock, so its remaining rows
// cannot by themselves tell RefloodLandblock that this prefix needs repair.
private Dictionary<uint, HashSet<uint>> _withdrawnPrefixesByOwner =>
_collisionWorld.Current.WithdrawnPrefixesByOwner;
/// <summary>
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
/// the entity moves. Cleared by <see cref="Deregister"/>.
/// </summary>
private Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes =>
_collisionWorld.Current.ShadowEntityShapes;
/// <summary>
/// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is
/// <c>CObjCell::init_objects → recalc_cross_cells</c> on cell load
/// (Ghidra 0x0052b420 / 0x00515a30): the flood can only traverse loaded
/// cells, so an object registered before its neighbourhood streams in
/// gets its cell set recomputed afterwards. <see cref="RefloodLandblock"/>
/// is the streaming-side trigger.
/// </summary>
private Dictionary<uint, RegistrationRecord> _entityReg =>
_collisionWorld.Current.ShadowEntityRegistrations;
private Dictionary<uint, ulong> _ownerVersions =>
_collisionWorld.Current.ShadowOwnerVersions;
private Dictionary<uint, HashSet<uint>> _ownerPrefixes =>
_collisionWorld.Current.ShadowOwnerPrefixes;
private Dictionary<uint, List<uint>> _prefixOwnerSlots =>
_collisionWorld.Current.ShadowPrefixOwnerSlots;
private Dictionary<uint, Dictionary<uint, int>> _prefixOwnerIndices =>
_collisionWorld.Current.ShadowPrefixOwnerIndices;
private Dictionary<uint, Stack<int>> _prefixFreeSlots =>
_collisionWorld.Current.ShadowPrefixFreeSlots;
private List<uint> _ownerSlots =>
_collisionWorld.Current.ShadowOwnerSlots;
private Dictionary<uint, int> _ownerIndices =>
_collisionWorld.Current.ShadowOwnerIndices;
private Stack<int> _ownerFreeSlots =>
_collisionWorld.Current.ShadowOwnerFreeSlots;
private readonly HashSet<uint> _prefixScratch = new();
private readonly List<uint> _removedPrefixScratch = new();
private ulong _mutationRevision;
private ulong _nextPreparedSetPositionCommitId;
private ulong _lastAppliedSetPositionCommitId;
private readonly HashSet<ulong> _pendingSetPositionDispatches = [];
private long _setPositionDispatchFailureCount;
internal event Action<uint, ulong>? OwnerMutated;
internal event Action<uint, uint>? OwnerPrefixMembershipChanged;
public ShadowObjectRegistry()
: this(new CollisionWorldStateSlot())
{
}
internal ShadowObjectRegistry(CollisionWorldStateSlot collisionWorld)
{
_collisionWorld = collisionWorld
?? throw new ArgumentNullException(nameof(collisionWorld));
}
internal void AttachCollisionWorld(CollisionWorldStateSlot collisionWorld)
{
ArgumentNullException.ThrowIfNull(collisionWorld);
if (_cells.Count != 0 || _entityReg.Count != 0)
{
throw new InvalidOperationException(
"A populated shadow registry cannot change collision roots.");
}
_collisionWorld = collisionWorld;
AdvanceMutationRevision();
}
internal sealed record RegistrationRecord(
uint SeedCellId,
Vector3 EntityWorldPos,
Quaternion EntityWorldRot,
uint State,
EntityCollisionFlags Flags,
bool IsStatic,
bool IsMultiPart,
// Single-shape fields (IsMultiPart == false):
uint GfxObjId,
float Radius,
ShadowCollisionType CollisionType,
float CylHeight,
float Scale);
internal ulong GetOwnerVersion(uint entityId) =>
_ownerVersions.TryGetValue(entityId, out ulong version)
? version
: 0UL;
/// <summary>
/// Monotonic authority for every logical mutation of the active shadow
/// collision world. SetPosition evaluation receipts seal this value so a
/// later owner insert, removal, move, state change, suspension, reflood,
/// or cell-row replacement cannot be committed against a different world.
/// </summary>
internal ulong MutationRevision => _mutationRevision;
private void AdvanceMutationRevision() =>
_mutationRevision = checked(_mutationRevision + 1UL);
private void BumpOwnerVersion(uint entityId)
{
AdvanceMutationRevision();
ulong version = checked(GetOwnerVersion(entityId) + 1UL);
_ownerVersions[entityId] = version;
RefreshOwnerPrefixIndex(entityId);
OwnerMutated?.Invoke(entityId, version);
}
internal RetainedRefloodOwnerScan CreateRetainedRefloodOwnerScan(
uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? slots);
return new RetainedRefloodOwnerScan(this, prefix, slots);
}
internal sealed class RetainedRefloodOwnerScan : IDisposable
{
private readonly ShadowObjectRegistry _owner;
private readonly uint _prefix;
private readonly List<uint>? _slots;
private readonly int _limit;
private int _index;
private bool _completed;
internal RetainedRefloodOwnerScan(
ShadowObjectRegistry owner,
uint prefix,
List<uint>? slots)
{
_owner = owner;
_prefix = prefix;
_slots = slots;
_limit = slots?.Count ?? 0;
}
internal RetainedRefloodOwnerScanStep Advance()
{
if (_completed)
{
return new RetainedRefloodOwnerScanStep(
Completed: true,
HasOwner: false,
OwnerId: 0u);
}
if (_slots is null || _index >= _limit)
{
_completed = true;
return new RetainedRefloodOwnerScanStep(
Completed: true,
HasOwner: false,
OwnerId: 0u);
}
uint ownerId = _slots[_index++];
bool retained = _owner.IsRetainedRefloodOwner(ownerId, _prefix);
return new RetainedRefloodOwnerScanStep(
Completed: false,
HasOwner: retained,
OwnerId: retained ? ownerId : 0u);
}
public void Dispose() { }
}
private void RefreshOwnerPrefixIndex(uint entityId)
{
if (!_entityReg.ContainsKey(entityId))
{
RemoveOwnerPrefixMembership(entityId);
return;
}
EnsureOwnerSlot(entityId);
_prefixScratch.Clear();
if (_entityReg.TryGetValue(entityId, out RegistrationRecord? registration))
_prefixScratch.Add(registration.SeedCellId & 0xFFFF0000u);
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
for (int index = 0; index < cells.Count; index++)
_prefixScratch.Add(cells[index] & 0xFFFF0000u);
}
if (_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn))
{
foreach (uint prefix in withdrawn)
_prefixScratch.Add(prefix & 0xFFFF0000u);
}
if (!_ownerPrefixes.TryGetValue(entityId, out HashSet<uint>? current))
{
current = new HashSet<uint>();
_ownerPrefixes[entityId] = current;
}
_removedPrefixScratch.Clear();
foreach (uint prefix in current)
{
if (!_prefixScratch.Contains(prefix))
_removedPrefixScratch.Add(prefix);
}
for (int index = 0; index < _removedPrefixScratch.Count; index++)
{
uint prefix = _removedPrefixScratch[index];
current.Remove(prefix);
if (_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary<uint, int>? indices)
&& indices.Remove(entityId, out int slotIndex))
{
_prefixOwnerSlots[prefix][slotIndex] = 0u;
_prefixFreeSlots[prefix].Push(slotIndex);
ReleaseEmptyPrefixContainer(prefix, indices);
}
OwnerPrefixMembershipChanged?.Invoke(entityId, prefix);
}
foreach (uint prefix in _prefixScratch)
{
if (!current.Add(prefix))
continue;
if (!_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? slots))
{
slots = new List<uint>();
_prefixOwnerSlots[prefix] = slots;
_prefixOwnerIndices[prefix] = new Dictionary<uint, int>();
_prefixFreeSlots[prefix] = new Stack<int>();
}
Dictionary<uint, int> indices = _prefixOwnerIndices[prefix];
if (indices.ContainsKey(entityId))
continue;
Stack<int> free = _prefixFreeSlots[prefix];
if (free.TryPop(out int freeIndex))
{
slots[freeIndex] = entityId;
indices[entityId] = freeIndex;
}
else
{
indices[entityId] = slots.Count;
slots.Add(entityId);
}
OwnerPrefixMembershipChanged?.Invoke(entityId, prefix);
}
}
private void RemoveOwnerPrefixMembership(uint entityId)
{
if (_ownerPrefixes.Remove(entityId, out HashSet<uint>? prefixes))
{
foreach (uint prefix in prefixes)
{
if (!_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary<uint, int>? indices)
|| !indices.Remove(entityId, out int slotIndex))
{
continue;
}
_prefixOwnerSlots[prefix][slotIndex] = 0u;
_prefixFreeSlots[prefix].Push(slotIndex);
ReleaseEmptyPrefixContainer(prefix, indices);
OwnerPrefixMembershipChanged?.Invoke(entityId, prefix);
}
}
if (_ownerIndices.Remove(entityId, out int ownerSlot))
{
_ownerSlots[ownerSlot] = 0u;
_ownerFreeSlots.Push(ownerSlot);
}
}
private void EnsureOwnerSlot(uint entityId)
{
if (_ownerIndices.ContainsKey(entityId))
return;
if (_ownerFreeSlots.TryPop(out int freeIndex))
{
_ownerSlots[freeIndex] = entityId;
_ownerIndices[entityId] = freeIndex;
return;
}
_ownerIndices[entityId] = _ownerSlots.Count;
_ownerSlots.Add(entityId);
}
private void ReleaseEmptyPrefixContainer(
uint prefix,
Dictionary<uint, int> indices)
{
if (indices.Count != 0)
return;
// An in-flight scan retains its captured List reference and observes
// only tombstones. A future owner gets a fresh compact container.
_prefixOwnerSlots.Remove(prefix);
_prefixOwnerIndices.Remove(prefix);
_prefixFreeSlots.Remove(prefix);
}
internal readonly record struct RetainedRefloodOwnerScanStep(
bool Completed,
bool HasOwner,
uint OwnerId);
/// <summary>
/// The flood's data source (cells, buildings, terrain origins). Wired by
/// <see cref="PhysicsEngine"/> when its own <c>DataCache</c> is set.
/// A bare registry (unit tests) floods against an empty cache: outdoor
/// seeds still produce the overlapped landcells (pure LandDefs math);
/// indoor seeds resolve to just the seed cell.
/// </summary>
public PhysicsDataCache? DataCache { get; set; }
private PhysicsDataCache _fallbackCache => _fallback ??= new PhysicsDataCache();
private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
/// <summary>
/// Register a single-shape entity. <paramref name="seedCellId"/> is the
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
/// derive the outdoor landcell under <paramref name="worldPos"/>
/// (landblock-baked statics whose position is implicitly outdoor).
///
/// <para>
/// For <see cref="ShadowCollisionType.Cylinder"/> shapes the flood
/// sphere is the cylinder BASE point with the cylinder radius — retail
/// globalizes CylSphere <c>low_pt</c> (overload Ghidra 0x0052b9f0).
/// For BSP shapes it is the part bounding sphere (retail's
/// sorting-sphere fallback).
/// </para>
/// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f, float scale = 1.0f,
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None,
uint seedCellId = 0u,
bool isStatic = true,
bool publishMutation = true)
{
// Flood FIRST: retail keeps the previous shadows when the new cell
// array would be empty (SetPositionInternal num_cells gate,
// pc:283540) — so the old registration must survive a failed flood.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
var spheres = new[]
{
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius },
};
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, spheres, spheres.Length, isStatic);
if (cellSet.Count == 0) return;
DeregisterCore(entityId, publishMutation: false);
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius,
collisionType, cylHeight, scale, state, flags);
var cellIds = new List<uint>(cellSet.Count);
foreach (uint cellId in cellSet)
{
AddEntryToCell(entry, cellId);
cellIds.Add(cellId);
}
_entityToCells[entityId] = cellIds;
_entityReg[entityId] = new RegistrationRecord(
seed, worldPos, rotation, state, flags, isStatic,
IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
}
/// <summary>
/// Register one logical entity composed of multiple collision shapes
/// (A6.P4 door fix, 2026-05-24). All emitted <see cref="ShadowEntry"/>
/// rows share <paramref name="entityId"/>; the shape list is cached so
/// <see cref="UpdatePosition"/> can recompose part transforms.
///
/// <para>
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
/// per OBJECT, not per part). WHICH flood is retail's own exclusive
/// dispatch on <c>HAS_PHYSICS_BSP_PS</c>
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230,
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c>):
/// </para>
/// <list type="bullet">
/// <item>BSP-bearing → <c>find_bbox_cell_list</c> @0x00510fc0, ported as
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Each part
/// contributes its authored BOUNDING BOX
/// (<see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c>), and the
/// outdoor expansion is the FILLED CELL RECTANGLE that box spans —
/// crossing landblock boundaries freely. Before #334 these objects
/// were routed through the sphere flood below, whose outdoor reach
/// is a fixed 3×3 (±24 m) regardless of radius, so any formation
/// wider than one land cell simply was not registered in its outer
/// cells.</item>
/// <item>otherwise → <see cref="BuildFloodSpheres"/> +
/// <see cref="CellTransit.BuildShadowCellSet"/>, retail's
/// cylsphere and sorting-sphere branches, byte-identical to before
/// #334 for every object that legitimately is spherical.</item>
/// </list>
/// <para>
/// Every shape row is then written into every flooded cell, mirroring
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
/// </para>
/// </summary>
public void RegisterMultiPart(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u,
bool isStatic = false,
bool publishMutation = true)
{
if (shapes.Count == 0) { Deregister(entityId); return; }
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
// Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
// (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
// physics BSP, and calc_cross_cells (0x00515285) branches on that bit.
// AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and
// "is a BSP object" coincide exactly as the cached retail flag does.
bool hasBsp = false;
for (int i = 0; i < shapes.Count; i++)
{
if (shapes[i].CollisionType == ShadowCollisionType.BSP)
{
hasBsp = true;
break;
}
}
IReadOnlyList<uint> cellSet;
if (hasBsp)
{
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seed, partBoxes, partSpheres, isStatic);
}
else
{
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
}
if (cellSet.Count == 0) return;
DeregisterCore(entityId, publishMutation: false);
_entityShapes[entityId] = shapes;
var allCells = new List<uint>(cellSet.Count);
foreach (var shape in shapes)
{
var rotatedLocal = Vector3.Transform(shape.LocalPosition, entityWorldRot);
var partWorldPos = entityWorldPos + rotatedLocal;
var partWorldRot = entityWorldRot * shape.LocalRotation;
var entry = new ShadowEntry(
EntityId: entityId,
GfxObjId: shape.GfxObjId,
Position: partWorldPos,
Rotation: partWorldRot,
Radius: shape.Radius,
CollisionType: shape.CollisionType,
CylHeight: shape.CylHeight,
Scale: shape.Scale,
State: state,
Flags: flags,
LocalPosition: shape.LocalPosition,
LocalRotation: shape.LocalRotation);
foreach (uint cellId in cellSet)
AddEntryToCell(entry, cellId);
}
foreach (uint cellId in cellSet)
allCells.Add(cellId);
_entityToCells[entityId] = allCells;
_entityReg[entityId] = new RegistrationRecord(
seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
}
/// <summary>
/// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the
/// part read by later collision tests; it does not recalculate cross-cells.
/// A suspended owner updates only its retained payload. A newly shaped
/// owner may perform its first flood, then immediately suspend when its
/// canonical projection is Hidden, attached, or cell-less.
/// </summary>
public void ReplaceMultiPartPayload(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId = 0u,
bool isStatic = false,
bool suspendIfNew = false)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart)
{
if (shapes.Count == 0)
return;
RegisterMultiPart(
entityId,
entityWorldPos,
entityWorldRot,
shapes,
state,
flags,
worldOffsetX,
worldOffsetY,
landblockId,
seedCellId,
isStatic);
if (suspendIfNew)
Suspend(entityId);
return;
}
bool suspended = _suspendedEntities.Contains(entityId);
_entityShapes[entityId] = shapes;
_entityReg[entityId] = prior with
{
EntityWorldPos = entityWorldPos,
EntityWorldRot = entityWorldRot,
State = state,
Flags = flags,
};
if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
BumpOwnerVersion(entityId);
return;
}
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
entries.RemoveAll(entry => entry.EntityId == entityId);
}
foreach (ShadowShape shape in shapes)
{
Vector3 partWorldPos = entityWorldPos
+ Vector3.Transform(shape.LocalPosition, entityWorldRot);
Quaternion partWorldRot = entityWorldRot * shape.LocalRotation;
var entry = new ShadowEntry(
EntityId: entityId,
GfxObjId: shape.GfxObjId,
Position: partWorldPos,
Rotation: partWorldRot,
Radius: shape.Radius,
CollisionType: shape.CollisionType,
CylHeight: shape.CylHeight,
Scale: shape.Scale,
State: state,
Flags: flags,
LocalPosition: shape.LocalPosition,
LocalRotation: shape.LocalRotation);
foreach (uint cellId in cells)
AddEntryToCell(entry, cellId);
}
BumpOwnerVersion(entityId);
}
/// <summary>
/// Flood spheres for an object with NO physics BSP — retail's cylsphere
/// and sorting-sphere branches of <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230, both of which sit BELOW the <c>HAS_PHYSICS_BSP_PS</c> jump
/// at <c>0x0051528f jne 0x515305</c> and are unreachable from it:
///
/// <list type="number">
/// <item>cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
/// each contributes one sphere at its world BASE point with the cylinder
/// radius, capped at 10.</item>
/// <item>else the sorting sphere (<c>0x005152dc</c> →
/// <c>CPartArray::GetSortingSphere</c> @0x00518b00 →
/// <c>CObjCell::find_cell_list</c> @0x0052b990).</item>
/// </list>
///
/// <para>
/// #334: there is no BSP arm here any more, and there must not be one.
/// The BSP branch is a structurally different algorithm over BOXES
/// (<see cref="CellTransit.BuildShadowCellSetFromParts"/>), and
/// <see cref="RegisterMultiPart"/> routes to it before this method is
/// reached. The arm this method used to carry — "a BSP part contributes
/// its ROOT BOUNDING SPHERE placed at its real center" — described
/// retail's INDOOR portal reject, not its outdoor expansion, and using it
/// for both is what capped every BSP object's outdoor reach at a 3×3
/// neighbourhood. A BSP shape reaching this method would be a dispatch
/// bug; it is skipped rather than flooded from, so it cannot silently
/// produce the wrong cells (the #98 / #168 symptom class).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
const int RetailSphereCap = 10;
var spheres = new List<DatReaderWriter.Types.Sphere>();
bool anyCyl = false;
foreach (var s in shapes)
{
if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
}
// Retail's branch, chosen once: cylspheres, else the sorting sphere
// (which acdream approximates with the Sphere shapes — AP-157).
ShadowCollisionType only =
anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere;
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
// 0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa, and that clamp is a
// fixed static-buffer capacity (the destination array at
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy.
//
// Sorting-sphere branch: int.MaxValue is NOT a retail port and the
// addresses above do not justify it. Retail's overload @0x0052b990
// pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored
// CSetup::sorting_sphere. acdream floods from every Sphere shape
// instead — a different DAT field with a different cardinality, which
// is AP-157, filed and open. Capping at 1 HERE would not move toward
// retail: it would take Spheres[0], which is not the sorting sphere.
// int.MaxValue keeps the substitution in its safe (over-inclusive)
// direction until AP-157 ports the real field. Inert over installed
// data — max 5 Spheres on any Setup (0x020016F7).
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
foreach (var s in shapes)
{
if (s.CollisionType != only)
continue;
if (spheres.Count >= cap)
break;
// A primitive's LocalPosition already IS its centre, so
// BoundsCenter is Zero; the composition is kept identical to the
// emitted ShadowEntry rows so the flood and the geometry can never
// disagree about where the shape is.
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = world,
Radius = s.Radius,
});
}
return spheres;
}
/// <summary>
/// #334: the per-part world-placed authored boxes retail's
/// <c>CLandCell::add_all_outside_cells</c> @0x00533360 divides by
/// <c>square_length</c>. Composed exactly as the emitted
/// <see cref="ShadowEntry"/> rows are, so the flood rectangle and the
/// collision geometry describe the same placement.
/// </summary>
private static List<ShadowPartBox> BuildFloodPartBoxes(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
var boxes = new List<ShadowPartBox>(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot));
}
return boxes;
}
/// <summary>
/// The per-part BSP ROOT bounding spheres retail's part-array
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 loads at
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms through the part's own
/// Position (<c>0x0052cb4c</c> / <c>Position::localtolocal</c>) and reads
/// the radius from at <c>0x0052cb65 fadd [esi+0xc]</c>.
///
/// <para>
/// These drive ONLY the indoor half of the BSP flood and the outdoor
/// building bridge (<c>CEnvCell::check_building_transit</c> @0x0052c5d0),
/// which still use the sphere traversal — the AP-159 residual. The
/// outdoor expansion uses <see cref="BuildFloodPartBoxes"/> and never
/// these. No cap: <c>find_bbox_cell_list</c> walks every part, bounded
/// only by <c>num_parts</c> (7 installed Setups carry more than 10
/// physics-BSP parts, max 49 on Setup 0x02001A91).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildBspPartSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
var spheres = new List<DatReaderWriter.Types.Sphere>(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot),
Radius = s.Radius,
});
}
return spheres;
}
/// <summary>
/// Derive the outdoor landcell id under a world position — the implicit
/// seed for landblock-baked statics registered without a cell id
/// (retail: their m_position resolves outdoor via adjust_to_outside).
/// </summary>
private static uint DeriveOutdoorSeed(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId)
{
// C3c-F3: only a genuinely-absent landblock id (0) has no seed —
// prefix 0x00000000 is landblock (0,0), the map corner, whose
// outdoor cells 0x00000001..0x40 are as real as any other block's.
// The old prefix-0 sentinel silently dropped every landblock-baked
// static in the corner block.
if (landblockId == 0u) return 0u;
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int cx = (int)System.Math.Clamp(localX / 24f, 0f, 7f);
int cy = (int)System.Math.Clamp(localY / 24f, 0f, 7f);
uint lbPrefix = landblockId & 0xFFFF0000u;
// The clamp only anchors the SEED id; AddAllOutsideCells re-seats the
// actual flood cells from the sphere centers via LandDefs.AdjustToOutside
// (block-crossing), so an out-of-block position still floods correctly.
return lbPrefix | (uint)(cx * 8 + cy + 1);
}
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
/// list, creating the list if needed.</summary>
private void AddEntryToCell(ShadowEntry entry, uint cellId)
{
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List<ShadowEntry>();
_cells[cellId] = list;
}
list.Add(entry);
}
/// <summary>
/// Update an already-registered entity's world position + rotation,
/// preserving its <see cref="ShadowEntry.State"/>,
/// <see cref="ShadowEntry.Flags"/>, and shape parameters.
///
/// <para>
/// Retail re-registers every moved object per successful transition step
/// (SetPositionInternal tail, Ghidra 0x00515330: remove_shadows_from_cells
/// + add_shadows_to_cells with the transition's cell array) and on
/// server-driven SetPosition via calc_cross_cells. Remote entities have
/// no local transition, so this runs the same flood from their reported
/// cell (<paramref name="seedCellId"/> = the wire position's full cell
/// id). Retail keeps the previous shadows when the new array would be
/// EMPTY (the <c>num_cells != 0</c> gate at pc:283540) — mirrored here
/// by skipping the re-registration when no seed resolves.
/// </para>
/// </summary>
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u)
{
if (!_entityReg.TryGetValue(entityId, out var reg))
return; // not registered — no-op (callers don't have to gate)
// Keep-when-empty (retail pc:283540): no resolvable seed → leave the
// previous registration in place.
if (seedCellId == 0u
&& DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u)
return;
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(entityId, worldPos, rotation, shapes,
reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId,
seedCellId, reg.IsStatic);
return;
}
Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius,
worldOffsetX, worldOffsetY, landblockId,
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, seedCellId, reg.IsStatic);
}
/// <summary>
/// Installs the shadow-list suffix of one canonical retail
/// <c>CPhysicsObj::SetPositionInternal</c> commit. The transition has
/// already selected whether retail recalculates, replaces, or preserves
/// the owner's cross-cell list; this method consumes that decision
/// without running a second placement/flood oracle.
/// </summary>
internal void CommitSetPosition(
uint entityId,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
float worldOffsetX,
float worldOffsetY,
PhysicsShadowCommitAction action,
System.Collections.Immutable.ImmutableArray<uint> crossCellIds)
{
if (!_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration))
{
return;
}
switch (action)
{
case PhysicsShadowCommitAction.None:
RefreshPositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId);
return;
case PhysicsShadowCommitAction.Recalculate:
UpdatePosition(
entityId,
worldPosition,
worldRotation,
worldOffsetX,
worldOffsetY,
landblockId: seedCellId & 0xFFFF0000u,
seedCellId);
return;
case PhysicsShadowCommitAction.Replace:
if (crossCellIds.IsDefaultOrEmpty)
{
RefreshPositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId);
return;
}
ReplacePositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId,
crossCellIds);
return;
case PhysicsShadowCommitAction.Preserve:
RefreshPositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId);
return;
default:
throw new ArgumentOutOfRangeException(nameof(action));
}
}
/// <summary>
/// Immutable owner-local shadow transaction prepared before Runtime's
/// SetPosition publication tail. The prepared rows are built against an
/// isolated registry; applying them never re-runs the flood oracle.
/// </summary>
internal sealed record PreparedSetPositionShadowCommit(
ulong CommitId,
uint EntityId,
ulong ExpectedMutationRevision,
ulong ExpectedOwnerVersion,
ulong FinalMutationRevision,
ulong FinalOwnerVersion,
bool ProvenShapeless,
PreparedShadowOwnerState? OwnerState,
PreparedShadowCellReplacement[] CellReplacements,
PreparedShadowPrefixReplacement[] PrefixReplacements,
HashSet<uint>? OwnerPrefixes,
uint[] ChangedPrefixes);
internal sealed record PreparedShadowCellReplacement(
uint CellId,
List<ShadowEntry> Entries);
internal sealed record PreparedShadowPrefixReplacement(
uint Prefix,
bool Remove,
List<uint>? Slots,
Dictionary<uint, int>? Indices,
Stack<int>? FreeSlots);
internal readonly record struct SetPositionShadowCommitReceipt(
ulong CommitId,
uint EntityId,
ulong OwnerVersion,
uint[] ChangedPrefixes,
bool Mutated)
{
internal bool IsValid => CommitId != 0UL && EntityId != 0u;
}
/// <summary>
/// Prepares the complete owner-row replacement without touching the active
/// collision world. A missing registration is accepted only when the
/// caller carries an explicit proven-shapeless disposition; absence alone
/// is not evidence because an authored BSP payload may still be pending.
/// </summary>
internal bool TryPrepareSetPosition(
uint entityId,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
float worldOffsetX,
float worldOffsetY,
PhysicsShadowCommitAction action,
System.Collections.Immutable.ImmutableArray<uint> crossCellIds,
bool provenShapeless,
bool suspendOwner,
out PreparedSetPositionShadowCommit? prepared)
{
prepared = null;
ulong expectedMutation = _mutationRevision;
ulong expectedOwner = GetOwnerVersion(entityId);
bool hasOwner = TryCaptureOwnerState(
entityId,
out PreparedShadowOwnerState? source);
if (!hasOwner)
{
if (!provenShapeless)
return false;
_pendingSetPositionDispatches.EnsureCapacity(
_pendingSetPositionDispatches.Count + 1);
prepared = new PreparedSetPositionShadowCommit(
checked(++_nextPreparedSetPositionCommitId),
entityId,
expectedMutation,
expectedOwner,
expectedMutation,
expectedOwner,
ProvenShapeless: true,
OwnerState: null,
CellReplacements: [],
PrefixReplacements: [],
OwnerPrefixes: null,
ChangedPrefixes: Array.Empty<uint>());
return _mutationRevision == expectedMutation
&& GetOwnerVersion(entityId) == expectedOwner
&& !HasLogicalOwner(entityId);
}
if (provenShapeless || source is null)
return false;
var staging = new ShadowObjectRegistry
{
DataCache = DataCache,
};
staging.InstallOwnerState(source);
staging.CommitSetPosition(
entityId,
worldPosition,
worldRotation,
seedCellId,
worldOffsetX,
worldOffsetY,
action,
crossCellIds);
if (suspendOwner && !staging.Suspend(entityId))
return false;
if (!staging.TryCaptureOwnerState(
entityId,
out PreparedShadowOwnerState? replacement)
|| replacement is null)
{
return false;
}
uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement);
PreparedShadowCellReplacement[] cellReplacements =
PrepareCellReplacements(entityId, source, replacement);
HashSet<uint> replacementPrefixes = CapturePrefixes(replacement);
PreparedShadowPrefixReplacement[] prefixReplacements =
PreparePrefixReplacements(
entityId,
CapturePrefixes(source),
replacementPrefixes,
changedPrefixes);
ulong finalMutation = checked(expectedMutation + 1UL);
ulong finalOwner = checked(expectedOwner + 1UL);
// Reserve dictionary capacity before the non-fallible publication
// suffix. Row/list payloads themselves were allocated in staging.
_cells.EnsureCapacity(_cells.Count + replacement.Rows.Count);
_entityToCells.EnsureCapacity(_entityToCells.Count + 1);
_entityReg.EnsureCapacity(_entityReg.Count + 1);
_entityShapes.EnsureCapacity(_entityShapes.Count + 1);
_suspendedEntityCells.EnsureCapacity(_suspendedEntityCells.Count + 1);
_withdrawnPrefixesByOwner.EnsureCapacity(
_withdrawnPrefixesByOwner.Count + 1);
_ownerVersions.EnsureCapacity(_ownerVersions.Count + 1);
_ownerPrefixes.EnsureCapacity(_ownerPrefixes.Count + 1);
_prefixOwnerSlots.EnsureCapacity(
_prefixOwnerSlots.Count + changedPrefixes.Length);
_prefixOwnerIndices.EnsureCapacity(
_prefixOwnerIndices.Count + changedPrefixes.Length);
_prefixFreeSlots.EnsureCapacity(
_prefixFreeSlots.Count + changedPrefixes.Length);
_suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1);
_pendingSetPositionDispatches.EnsureCapacity(
_pendingSetPositionDispatches.Count + 1);
prepared = new PreparedSetPositionShadowCommit(
checked(++_nextPreparedSetPositionCommitId),
entityId,
expectedMutation,
expectedOwner,
finalMutation,
finalOwner,
ProvenShapeless: false,
replacement,
cellReplacements,
prefixReplacements,
replacementPrefixes,
changedPrefixes);
return _mutationRevision == expectedMutation
&& GetOwnerVersion(entityId) == expectedOwner
&& HasLogicalOwner(entityId);
}
/// <summary>
/// Applies a previously prepared owner-local row swap with callbacks
/// suppressed. Runtime dispatches the returned exact notification only
/// after the complete SetPosition state suffix is visible.
/// </summary>
internal bool TryApplySetPosition(
PreparedSetPositionShadowCommit prepared,
out SetPositionShadowCommitReceipt receipt)
{
ArgumentNullException.ThrowIfNull(prepared);
receipt = default;
if (prepared.CommitId <= _lastAppliedSetPositionCommitId
|| _mutationRevision != prepared.ExpectedMutationRevision
|| GetOwnerVersion(prepared.EntityId)
!= prepared.ExpectedOwnerVersion
|| HasLogicalOwner(prepared.EntityId)
== prepared.ProvenShapeless)
{
return false;
}
if (prepared.ProvenShapeless)
{
receipt = new SetPositionShadowCommitReceipt(
prepared.CommitId,
prepared.EntityId,
prepared.ExpectedOwnerVersion,
Array.Empty<uint>(),
Mutated: false);
_lastAppliedSetPositionCommitId = prepared.CommitId;
_pendingSetPositionDispatches.Add(prepared.CommitId);
return true;
}
if (prepared.OwnerState is null)
return false;
for (int index = 0; index < prepared.CellReplacements.Length; index++)
{
PreparedShadowCellReplacement replacement =
prepared.CellReplacements[index];
_cells[replacement.CellId] = replacement.Entries;
}
PreparedShadowOwnerState state = prepared.OwnerState;
_entityReg[prepared.EntityId] = state.Registration;
ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes);
if (state.Suspended)
_suspendedEntities.Add(prepared.EntityId);
else
_suspendedEntities.Remove(prepared.EntityId);
ReplaceOwnerValue(
_suspendedEntityCells,
prepared.EntityId,
state.SuspendedCellIds);
ReplaceOwnerValue(
_withdrawnPrefixesByOwner,
prepared.EntityId,
state.WithdrawnPrefixes);
ReplaceOwnerValue(
_entityToCells,
prepared.EntityId,
state.CellIds);
if (prepared.OwnerPrefixes is not null)
_ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes;
for (int index = 0; index < prepared.PrefixReplacements.Length; index++)
{
PreparedShadowPrefixReplacement replacement =
prepared.PrefixReplacements[index];
if (replacement.Remove)
{
_prefixOwnerSlots.Remove(replacement.Prefix);
_prefixOwnerIndices.Remove(replacement.Prefix);
_prefixFreeSlots.Remove(replacement.Prefix);
continue;
}
_prefixOwnerSlots[replacement.Prefix] = replacement.Slots!;
_prefixOwnerIndices[replacement.Prefix] = replacement.Indices!;
_prefixFreeSlots[replacement.Prefix] = replacement.FreeSlots!;
}
_mutationRevision = prepared.FinalMutationRevision;
_ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
_lastAppliedSetPositionCommitId = prepared.CommitId;
_pendingSetPositionDispatches.Add(prepared.CommitId);
receipt = new SetPositionShadowCommitReceipt(
prepared.CommitId,
prepared.EntityId,
prepared.FinalOwnerVersion,
prepared.ChangedPrefixes,
Mutated: true);
return true;
}
internal bool IsPreparedSetPositionCurrent(
PreparedSetPositionShadowCommit prepared)
{
ArgumentNullException.ThrowIfNull(prepared);
return prepared.CommitId > _lastAppliedSetPositionCommitId
&& _mutationRevision == prepared.ExpectedMutationRevision
&& GetOwnerVersion(prepared.EntityId)
== prepared.ExpectedOwnerVersion
&& HasLogicalOwner(prepared.EntityId)
!= prepared.ProvenShapeless;
}
internal void DispatchSetPositionCommit(
in SetPositionShadowCommitReceipt receipt)
{
if (!receipt.IsValid
|| receipt.CommitId > _lastAppliedSetPositionCommitId
|| !_pendingSetPositionDispatches.Remove(receipt.CommitId))
return;
if (!receipt.Mutated)
return;
ulong currentOwnerVersion = GetOwnerVersion(receipt.EntityId);
if (!HasLogicalOwner(receipt.EntityId)
|| currentOwnerVersion != receipt.OwnerVersion)
{
return;
}
for (int index = 0; index < receipt.ChangedPrefixes.Length; index++)
{
if (!HasLogicalOwner(receipt.EntityId)
|| GetOwnerVersion(receipt.EntityId) != receipt.OwnerVersion)
{
return;
}
DispatchSetPositionPrefixObservers(
receipt.EntityId,
receipt.ChangedPrefixes[index]);
}
if (!HasLogicalOwner(receipt.EntityId))
return;
currentOwnerVersion = GetOwnerVersion(receipt.EntityId);
if (currentOwnerVersion != receipt.OwnerVersion)
return;
DispatchSetPositionOwnerObservers(
receipt.EntityId,
currentOwnerVersion);
}
internal bool DiscardSetPositionCommit(
in SetPositionShadowCommitReceipt receipt) =>
receipt.IsValid
&& _pendingSetPositionDispatches.Remove(receipt.CommitId);
internal int PendingSetPositionDispatchCount =>
_pendingSetPositionDispatches.Count;
internal long SetPositionDispatchFailureCount =>
_setPositionDispatchFailureCount;
private void DispatchSetPositionPrefixObservers(uint owner, uint prefix)
{
Action<uint, uint>? observers = OwnerPrefixMembershipChanged;
if (observers is null)
return;
foreach (Action<uint, uint> observer in observers.GetInvocationList())
{
try
{
observer(owner, prefix);
}
catch
{
_setPositionDispatchFailureCount++;
}
}
}
private void DispatchSetPositionOwnerObservers(uint owner, ulong version)
{
Action<uint, ulong>? observers = OwnerMutated;
if (observers is null)
return;
foreach (Action<uint, ulong> observer in observers.GetInvocationList())
{
try
{
observer(owner, version);
}
catch
{
_setPositionDispatchFailureCount++;
}
}
}
private static uint[] CaptureChangedPrefixes(
PreparedShadowOwnerState before,
PreparedShadowOwnerState after)
{
HashSet<uint> oldPrefixes = CapturePrefixes(before);
HashSet<uint> newPrefixes = CapturePrefixes(after);
var changed = new List<uint>();
foreach (uint prefix in oldPrefixes)
{
if (!newPrefixes.Contains(prefix))
changed.Add(prefix);
}
foreach (uint prefix in newPrefixes)
{
if (!oldPrefixes.Contains(prefix))
changed.Add(prefix);
}
changed.Sort();
return changed.ToArray();
}
private static HashSet<uint> CapturePrefixes(
PreparedShadowOwnerState state)
{
var prefixes = new HashSet<uint>
{
state.Registration.SeedCellId & 0xFFFF0000u,
};
if (state.CellIds is not null)
{
for (int index = 0; index < state.CellIds.Count; index++)
prefixes.Add(state.CellIds[index] & 0xFFFF0000u);
}
if (state.WithdrawnPrefixes is not null)
{
foreach (uint prefix in state.WithdrawnPrefixes)
prefixes.Add(prefix & 0xFFFF0000u);
}
return prefixes;
}
private PreparedShadowCellReplacement[] PrepareCellReplacements(
uint entityId,
PreparedShadowOwnerState before,
PreparedShadowOwnerState after)
{
var touched = new HashSet<uint>();
AddCells(touched, before.CellIds);
AddCells(touched, after.CellIds);
var afterRows = new Dictionary<uint, ShadowEntry[]>();
for (int index = 0; index < after.Rows.Count; index++)
{
PreparedShadowCellRows row = after.Rows[index];
touched.Add(row.CellId);
afterRows[row.CellId] = row.Entries;
}
for (int index = 0; index < before.Rows.Count; index++)
touched.Add(before.Rows[index].CellId);
uint[] ordered = touched.ToArray();
Array.Sort(ordered);
var result = new PreparedShadowCellReplacement[ordered.Length];
for (int index = 0; index < ordered.Length; index++)
{
uint cellId = ordered[index];
_cells.TryGetValue(cellId, out List<ShadowEntry>? active);
afterRows.TryGetValue(cellId, out ShadowEntry[]? ownerRows);
int retainedCount = 0;
if (active is not null)
{
for (int row = 0; row < active.Count; row++)
{
if (active[row].EntityId != entityId)
retainedCount++;
}
}
var replacement = new List<ShadowEntry>(
retainedCount + (ownerRows?.Length ?? 0));
if (active is not null)
{
for (int row = 0; row < active.Count; row++)
{
if (active[row].EntityId != entityId)
replacement.Add(active[row]);
}
}
if (ownerRows is not null)
replacement.AddRange(ownerRows);
result[index] = new PreparedShadowCellReplacement(
cellId,
replacement);
}
return result;
}
private PreparedShadowPrefixReplacement[] PreparePrefixReplacements(
uint entityId,
HashSet<uint> before,
HashSet<uint> after,
uint[] changedPrefixes)
{
var result = new PreparedShadowPrefixReplacement[
changedPrefixes.Length];
for (int index = 0; index < changedPrefixes.Length; index++)
{
uint prefix = changedPrefixes[index];
bool removeOwner = before.Contains(prefix)
&& !after.Contains(prefix);
_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? oldSlots);
_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary<uint, int>? oldIndices);
_prefixFreeSlots.TryGetValue(prefix, out Stack<int>? oldFree);
var slots = oldSlots is null ? [] : new List<uint>(oldSlots);
var indices = oldIndices is null
? new Dictionary<uint, int>()
: new Dictionary<uint, int>(oldIndices);
Stack<int> free = CloneStack(oldFree);
if (removeOwner)
{
if (indices.Remove(entityId, out int ownerSlot))
{
slots[ownerSlot] = 0u;
free.Push(ownerSlot);
}
result[index] = indices.Count == 0
? new PreparedShadowPrefixReplacement(
prefix,
Remove: true,
Slots: null,
Indices: null,
FreeSlots: null)
: new PreparedShadowPrefixReplacement(
prefix,
Remove: false,
slots,
indices,
free);
continue;
}
if (!indices.ContainsKey(entityId))
{
if (free.TryPop(out int freeIndex))
{
slots[freeIndex] = entityId;
indices[entityId] = freeIndex;
}
else
{
indices[entityId] = slots.Count;
slots.Add(entityId);
}
}
result[index] = new PreparedShadowPrefixReplacement(
prefix,
Remove: false,
slots,
indices,
free);
}
return result;
}
private static Stack<int> CloneStack(Stack<int>? source) =>
source is null
? new Stack<int>()
: new Stack<int>(source.Reverse());
private static void AddCells(HashSet<uint> destination, List<uint>? cells)
{
if (cells is null)
return;
for (int index = 0; index < cells.Count; index++)
destination.Add(cells[index]);
}
private static void ReplaceOwnerValue<T>(
Dictionary<uint, T> destination,
uint entityId,
T? value)
where T : class
{
if (value is null)
destination.Remove(entityId);
else
destination[entityId] = value;
}
private void RefreshPositionRows(
uint entityId,
RegistrationRecord registration,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId)
{
if ((_entityToCells.TryGetValue(
entityId,
out List<uint>? retainedCells)
|| _suspendedEntityCells.TryGetValue(
entityId,
out retainedCells))
&& retainedCells.Count != 0)
{
ReplacePositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId,
retainedCells);
return;
}
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
BumpOwnerVersion(entityId);
}
private void ReplacePositionRows(
uint entityId,
RegistrationRecord registration,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
IReadOnlyList<uint> cellIds)
{
if (_entityToCells.TryGetValue(
entityId,
out List<uint>? previousCells))
{
for (int index = 0; index < previousCells.Count; index++)
{
if (_cells.TryGetValue(
previousCells[index],
out List<ShadowEntry>? entries))
{
RemoveOwnerRows(entries, entityId);
}
}
}
_suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId);
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
var exactCells = new List<uint>(cellIds.Count);
for (int index = 0; index < cellIds.Count; index++)
{
uint cellId = cellIds[index];
if (cellId == 0u || exactCells.Contains(cellId))
continue;
exactCells.Add(cellId);
}
if (registration.IsMultiPart
&& _entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
{
foreach (ShadowShape shape in shapes)
{
Vector3 partWorldPosition = worldPosition
+ Vector3.Transform(shape.LocalPosition, worldRotation);
Quaternion partWorldRotation = worldRotation
* shape.LocalRotation;
var entry = new ShadowEntry(
entityId,
shape.GfxObjId,
partWorldPosition,
partWorldRotation,
shape.Radius,
shape.CollisionType,
shape.CylHeight,
shape.Scale,
registration.State,
registration.Flags,
shape.LocalPosition,
shape.LocalRotation);
for (int index = 0; index < exactCells.Count; index++)
AddEntryToCell(entry, exactCells[index]);
}
}
else
{
var entry = new ShadowEntry(
entityId,
registration.GfxObjId,
worldPosition,
worldRotation,
registration.Radius,
registration.CollisionType,
registration.CylHeight,
registration.Scale,
registration.State,
registration.Flags);
for (int index = 0; index < exactCells.Count; index++)
AddEntryToCell(entry, exactCells[index]);
}
if (exactCells.Count == 0)
_entityToCells.Remove(entityId);
else
_entityToCells[entityId] = exactCells;
if (_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn))
{
for (int index = 0; index < exactCells.Count; index++)
withdrawn.Remove(exactCells[index] & 0xFFFF0000u);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
BumpOwnerVersion(entityId);
}
/// <summary>
/// Removes an entity from every cell collision list while retaining the
/// exact registration and shape payload needed to restore it later.
/// This is the registry counterpart of retail
/// <c>CPhysicsObj::remove_shadows_from_cells</c> during temporary
/// leave-world/pending-cell residence; it is deliberately not logical
/// teardown.
/// </summary>
public bool Suspend(uint entityId)
{
if (!_entityReg.ContainsKey(entityId))
return false;
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
_suspendedEntityCells[entityId] = new List<uint>(cellIds);
foreach (uint cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(entry => entry.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
_suspendedEntities.Add(entityId);
BumpOwnerVersion(entityId);
return true;
}
/// <summary>
/// BR-7 streaming hook — re-run the flood for every entity whose seed
/// cell or current cell set touches <paramref name="landblockId"/>'s
/// prefix. Retail's equivalent runs per loaded cell
/// (<c>CObjCell::init_objects → recalc_cross_cells</c>, Ghidra
/// 0x0052b420/0x00515a30); per-landblock granularity matches our
/// streaming unit. Covers both race directions: entity registered
/// before its neighbourhood hydrated (flood couldn't traverse), and
/// cells hydrated after a server spawn landed.
/// </summary>
public void RefloodLandblock(uint landblockId)
{
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
for (int i = 0; i < owners.Length; i++)
RefloodOwnerForLandblock(owners[i], landblockId);
}
/// <summary>
/// Captures the stable ordered owner set touched by one landblock reflood.
/// App-layer streaming can retain this receipt and advance one owner per
/// frame without changing the collision registry's ownership rules.
/// </summary>
public uint[] CaptureRefloodOwnersForLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new HashSet<uint>();
foreach (var kvp in _entityReg)
{
if (_suspendedEntities.Contains(kvp.Key))
continue;
if ((kvp.Value.SeedCellId & 0xFFFF0000u) == lbPrefix)
{
toReflood.Add(kvp.Key);
continue;
}
if (_entityToCells.TryGetValue(kvp.Key, out var cells))
{
foreach (uint c in cells)
{
if ((c & 0xFFFF0000u) == lbPrefix)
{
toReflood.Add(kvp.Key);
break;
}
}
}
if (_withdrawnPrefixesByOwner.TryGetValue(kvp.Key, out var prefixes)
&& prefixes.Contains(lbPrefix))
{
toReflood.Add(kvp.Key);
}
}
uint[] ordered = toReflood.ToArray();
Array.Sort(ordered);
return ordered;
}
/// <summary>
/// Re-runs one owner from a retained landblock-reflood receipt. A removed,
/// suspended, or otherwise superseded owner is an idempotent no-op.
/// </summary>
public void RefloodOwnerForLandblock(uint entityId, uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
if (_suspendedEntities.Contains(entityId)
|| !_entityReg.TryGetValue(
entityId,
out RegistrationRecord? reg))
{
return;
}
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out var withdrawnBeforeReflood);
if (reg.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(
entityId,
reg.EntityWorldPos,
reg.EntityWorldRot,
shapes,
reg.State,
reg.Flags,
0f,
0f,
lbPrefix,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
}
else
{
Register(
entityId,
reg.GfxObjId,
reg.EntityWorldPos,
reg.EntityWorldRot,
reg.Radius,
0f,
0f,
lbPrefix,
reg.CollisionType,
reg.CylHeight,
reg.Scale,
reg.State,
reg.Flags,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
}
// Register is also the authoritative movement/replacement API and
// therefore clears obsolete markers. Only this streaming reflood
// operation preserves the still-missing prefixes across replacement.
if (withdrawnBeforeReflood is not null)
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
&& refreshedCells.Exists(cell =>
(cell & 0xFFFF0000u) == lbPrefix)
&& _withdrawnPrefixesByOwner.TryGetValue(
entityId,
out var withdrawn))
{
withdrawn.Remove(lbPrefix);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
BumpOwnerVersion(entityId);
}
/// <summary>
/// Update the cached <see cref="ShadowEntry.State"/> bits for an
/// already-registered entity. Called by the inbound
/// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
/// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use, so the
/// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
/// the new state on the next resolve.
///
/// <para>
/// Retail equivalent: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// — direct write `this->state = arg2`. Retail also fires side-effect
/// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden)
/// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1
/// scopes to the bare state-write.
/// </para>
///
/// <para>
/// Implementation: <see cref="ShadowEntry"/> is a value-type record
/// copied into per-cell lists, so we rewrite the copy in each cell the
/// entity occupies. Unregistered entities are a no-op (callers don't
/// have to gate).
/// </para>
/// </summary>
public void UpdatePhysicsState(uint entityId, uint newState)
{
// Suspended dynamic objects have no cell rows, but their retained
// registration must still receive authoritative state changes.
bool retained = _entityReg.TryGetValue(
entityId,
out RegistrationRecord? retainedRegistration);
if (retained)
{
_entityReg[entityId] = retainedRegistration! with { State = newState };
}
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
{
if (retained)
BumpOwnerVersion(entityId);
return; // not registered — no-op
}
foreach (var cellId in cellIds)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
for (int i = 0; i < list.Count; i++)
{
if (list[i].EntityId == entityId)
list[i] = list[i] with { State = newState };
}
}
if (retained)
BumpOwnerVersion(entityId);
}
/// <summary>
/// #297 (target-side refresh): rewrites only the PWD-bitfield-derived
/// subset of a registered entity's <see cref="EntityCollisionFlags"/>
/// (<see cref="EntityCollisionFlagsExt.PwdBitfieldDerivedMask"/>) from a
/// fresh <c>PublicWeenieDesc._bitfield</c> value, leaving
/// <see cref="EntityCollisionFlags.IsCreature"/> and
/// <see cref="EntityCollisionFlags.HasWeenie"/> untouched. Without this,
/// <c>CollisionExemption.ShouldSkip</c>'s target-side read would stay
/// frozen at whatever <c>CreateObject</c> captured, even after the
/// mover-side value refreshes live via
/// <see cref="EntityCollisionFlagsExt.ResolveMoverPvpState"/>. Mirrors
/// <see cref="UpdatePhysicsState"/>'s per-cell rewrite shape; a no-op for
/// an entity with no live registration.
/// </summary>
/// <remarks>
/// F3 (review round 2): callers are expected to fire this on every
/// <c>ObjectUpdated</c>, not only a genuine PK-status change, so an
/// equality short-circuit here is load-bearing — without it, an
/// unrelated property update (e.g. an inventory move bumping
/// EncumbranceVal) would still call <see cref="BumpOwnerVersion"/> /
/// <see cref="AdvanceMutationRevision"/>, and that revision is a commit
/// gate a prepared <c>SetPosition</c> checks before applying — an
/// unrelated bump landing between prepare and apply would invalidate an
/// otherwise-valid commit.
/// </remarks>
public void UpdatePwdBitfieldFlags(uint entityId, uint pwdBitfield)
{
EntityCollisionFlags decoded = EntityCollisionFlagsExt.FromPwdBitfield(pwdBitfield);
bool retained = _entityReg.TryGetValue(
entityId,
out RegistrationRecord? retainedRegistration);
if (retained)
{
EntityCollisionFlags merged =
(retainedRegistration!.Flags & ~EntityCollisionFlagsExt.PwdBitfieldDerivedMask)
| decoded;
if (merged == retainedRegistration.Flags)
return; // idempotency guard — no real PK-status change
_entityReg[entityId] = retainedRegistration with { Flags = merged };
}
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
{
if (retained)
BumpOwnerVersion(entityId);
return; // not registered — no-op
}
foreach (var cellId in cellIds)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
for (int i = 0; i < list.Count; i++)
{
if (list[i].EntityId == entityId)
{
EntityCollisionFlags merged =
(list[i].Flags & ~EntityCollisionFlagsExt.PwdBitfieldDerivedMask)
| decoded;
list[i] = list[i] with { Flags = merged };
}
}
}
if (retained)
BumpOwnerVersion(entityId);
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
=> DeregisterCore(entityId, publishMutation: true);
private void DeregisterCore(uint entityId, bool publishMutation)
{
bool existed = _entityReg.ContainsKey(entityId)
|| _entityToCells.ContainsKey(entityId)
|| _entityShapes.ContainsKey(entityId)
|| _suspendedEntities.Contains(entityId)
|| _suspendedEntityCells.ContainsKey(entityId);
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
RemoveOwnerRows(list, entityId);
}
_entityToCells.Remove(entityId);
}
_entityShapes.Remove(entityId);
_entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId);
if (existed && publishMutation)
{
BumpOwnerVersion(entityId);
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
}
}
private static void RemoveOwnerRows(
List<ShadowEntry> entries,
uint entityId)
{
for (int index = entries.Count - 1; index >= 0; index--)
{
if (entries[index].EntityId == entityId)
entries.RemoveAt(index);
}
}
/// <summary>
/// Logically tear down every static object owned by a landblock, including
/// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners
/// are deliberately retained for spatial reflood after streaming changes.
/// </summary>
public void DeregisterStaticOwnersForLandblock(uint landblockId)
{
uint[] owners = CaptureStaticOwnersForLandblock(landblockId);
for (int i = 0; i < owners.Length; i++)
DeregisterStaticOwnerForLandblock(owners[i], landblockId);
}
/// <summary>
/// Captures a stable ordered receipt for static collision owners rooted in
/// one landblock.
/// </summary>
public uint[] CaptureStaticOwnersForLandblock(uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
var owners = new List<uint>();
foreach (var (entityId, registration) in _entityReg)
{
if (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
{
owners.Add(entityId);
}
}
owners.Sort();
return owners.ToArray();
}
/// <summary>
/// Removes one static owner from a retained landblock receipt. If the
/// owner was already removed or rebound elsewhere, the operation no-ops.
/// </summary>
public void DeregisterStaticOwnerForLandblock(
uint entityId,
uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
{
Deregister(entityId);
}
}
/// <summary>
/// Remove all entities belonging to a landblock. With flood-driven
/// registration an entity's cells can span landblock prefixes, so entries
/// under other prefixes survive. A static registration ends when its final
/// streamed cell disappears. A dynamic/server-live registration retains
/// its exact payload with zero cell rows so a later load can reflood it;
/// only its logical owner calls <see cref="Deregister"/> (retail's
/// per-object remove_shadows_from_cells, Ghidra 0x00511230).
/// </summary>
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List<uint>();
var touchedOwners = new HashSet<uint>();
foreach (var (entityId, cells) in _entityToCells)
{
if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix))
continue;
touchedOwners.Add(entityId);
if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
{
withdrawn = new HashSet<uint>();
_withdrawnPrefixesByOwner[entityId] = withdrawn;
}
withdrawn.Add(lbPrefix);
}
foreach (var kvp in _cells)
{
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
toRemove.Add(kvp.Key);
}
foreach (var cellId in toRemove)
_cells.Remove(cellId);
// Clean up entity-to-cell map
var entitiesToRemove = new List<uint>();
foreach (var kvp in _entityToCells)
{
kvp.Value.RemoveAll(c => (c & 0xFFFF0000u) == lbPrefix);
if (kvp.Value.Count == 0)
entitiesToRemove.Add(kvp.Key);
}
foreach (var eid in entitiesToRemove)
{
_entityToCells.Remove(eid);
// A streamed-out server-live object is still logically alive.
// Preserve dynamic registration/shape payload for the reload
// reflood; static owners end with their landblock.
if (!_entityReg.TryGetValue(eid, out var registration)
|| registration.IsStatic)
{
_entityShapes.Remove(eid);
_entityReg.Remove(eid);
_suspendedEntities.Remove(eid);
_suspendedEntityCells.Remove(eid);
_withdrawnPrefixesByOwner.Remove(eid);
}
}
foreach (uint entityId in touchedOwners)
BumpOwnerVersion(entityId);
}
/// <summary>
/// Retires one logical owner's rows from a streamed-out prefix. This is
/// the owner-granular form used by the collision-generation retirement
/// cursor; it preserves the same static/dynamic lifetime rules as
/// <see cref="RemoveLandblock"/> without scanning the complete registry.
/// </summary>
internal void RetireOwnerFromLandblock(uint entityId, uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
{
DeregisterCore(entityId, publishMutation: false);
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
AdvanceMutationRevision();
return;
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
bool touched = false;
for (int index = cells.Count - 1; index >= 0; index--)
{
uint cellId = cells[index];
if ((cellId & 0xFFFF0000u) != prefix)
continue;
touched = true;
cells.RemoveAt(index);
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
{
RemoveOwnerRows(entries, entityId);
if (entries.Count == 0)
_cells.Remove(cellId);
}
}
if (!touched)
return;
if (!_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn))
{
withdrawn = new HashSet<uint>();
_withdrawnPrefixesByOwner[entityId] = withdrawn;
}
withdrawn.Add(prefix);
if (cells.Count == 0)
_entityToCells.Remove(entityId);
BumpOwnerVersion(entityId);
}
/// <summary>
/// All objects registered in a specific cell — retail
/// <c>CObjCell::find_obj_collisions</c> iterating only
/// <c>this-&gt;shadow_object_list</c> (Ghidra 0x0052b750). THE query
/// surface: the Transition system calls this per cell in its transit
/// cell array (primary via the insert, others via check_other_cells).
/// </summary>
public IReadOnlyList<ShadowEntry> GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return System.Array.Empty<ShadowEntry>();
}
public int TotalRegistered => _entityToCells.Count;
/// <summary>
/// Total logical shadow registrations, including dynamic live objects
/// suspended with zero cell rows while their landblock is unavailable.
/// Lifecycle stress gates must inspect this value as well as
/// <see cref="TotalRegistered"/> so a retained collision payload cannot
/// survive its owning live-object incarnation unnoticed.
/// </summary>
public int RetainedRegistrationCount => _entityReg.Count;
/// <summary>Number of owner/prefix repair markers awaiting a future reflood.</summary>
public int WithdrawnPrefixMarkerCount
{
get
{
int count = 0;
foreach (var prefixes in _withdrawnPrefixesByOwner.Values)
count += prefixes.Count;
return count;
}
}
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
public int SuspendedRegistrationCount => _suspendedEntities.Count;
public bool HasOwnerRowsInLandblock(uint ownerId, uint landblockId) =>
_entityToCells.TryGetValue(ownerId, out List<uint>? cells)
&& cells.Exists(cell =>
(cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u));
/// <summary>
/// Mirrors one ordinary active-world mutation into an off-side generation.
/// Target-prefix owners may subsequently be reflooded against the staged
/// topology; unrelated owners retain these exact active rows.
/// </summary>
internal void MirrorOwnerFrom(
ShadowObjectRegistry source,
uint entityId)
{
ArgumentNullException.ThrowIfNull(source);
DeregisterCore(entityId, publishMutation: false);
if (source.TryCaptureOwnerState(
entityId,
out PreparedShadowOwnerState? state)
&& state is not null)
{
InstallOwnerState(state);
_ownerVersions[entityId] = source.GetOwnerVersion(entityId);
}
else
{
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
}
}
internal int CaptureOwnerSlotLimit() => _ownerSlots.Count;
internal uint GetOwnerSlot(int index) => _ownerSlots[index];
/// <summary>
/// O3 (2026-08-02): commit-time owner application for one landblock
/// delta, replacing the deleted staged whole-world reflood context.
/// Retail hydrates a cell and refloods the objects associated with it —
/// <c>CObjCell::init_objects</c> (0x0052B420) →
/// <c>CPhysicsObj::recalc_cross_cells</c> (0x00515A30); the per-landblock
/// streaming analogue is: adopt each staged owner's registration/shape
/// payload and recalculate its cross-cells against the live post-delta
/// world, retire the outgoing generation's authored statics that were not
/// re-authored, and re-run the flood for every retained live owner
/// touching the replaced landblock.
/// </summary>
internal void ApplyCommittedOwnerReplacement(
ShadowObjectRegistry stagingSource,
uint ownerId,
uint landblockId)
{
ArgumentNullException.ThrowIfNull(stagingSource);
if (stagingSource.HasLogicalOwner(ownerId))
{
// Staged owner (authored target static, or an owner registered
// directly into the generation): adopt its payload, then
// recalc_cross_cells against the live world — the staged flood
// only saw the target-only staging root, so a seam footprint
// completes here.
MirrorOwnerFrom(stagingSource, ownerId);
RefloodOwnerForLandblock(ownerId, landblockId);
return;
}
if (IsStaticOwnerRootedIn(ownerId, landblockId))
{
// Outgoing generation's authored static, not re-authored by the
// replacement: it ends with its landblock (same lifetime rule as
// RetireOwnerFromLandblock's static branch).
DeregisterCore(ownerId, publishMutation: false);
RemoveOwnerPrefixMembership(ownerId);
_ownerVersions.Remove(ownerId);
AdvanceMutationRevision();
return;
}
// Retained live owner touching the replaced landblock: recalculate its
// cross-cells against the new topology. Suspended or since-removed
// owners no-op inside the reflood.
RefloodOwnerForLandblock(ownerId, landblockId);
}
/// <summary>
/// O3 (2026-08-02): retail <c>CObjCell::init_objects</c> refloods every
/// object associated with the hydrated cell at hydration time. Owners
/// that became associated with the replaced landblock after the sealed
/// capture (a mover entering the prefix mid-publication) are present in
/// the live prefix-owner slots but absent from the sealed owner list;
/// recalculate their cross-cells against the just-installed topology.
/// </summary>
internal void RefloodPrefixOwnersAfterReplacement(
uint landblockId,
IReadOnlyList<uint> sealedOwnerIds)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? slots))
return;
var applied = new HashSet<uint>(sealedOwnerIds);
int limit = slots.Count;
for (int index = 0; index < limit; index++)
{
uint ownerId = slots[index];
if (ownerId == 0u || !applied.Add(ownerId))
continue;
RefloodOwnerForLandblock(ownerId, landblockId);
}
}
/// <summary>
/// Refreshes one staging owner from the exact active payload, then floods
/// it against the staging generation's complete cell graph. The returned
/// source version is the commit-time freshness token.
/// </summary>
internal bool RefreshRetainedOwnerFrom(
ShadowObjectRegistry source,
uint entityId,
uint landblockId,
out ulong sourceVersion)
{
ArgumentNullException.ThrowIfNull(source);
sourceVersion = source.GetOwnerVersion(entityId);
if (!source._entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
|| source._suspendedEntities.Contains(entityId)
|| !source.OwnerTouchesLandblock(entityId, landblockId))
{
// A target-local refresh is not a global owner deletion. Preserve
// the exact active rows when the live owner has moved elsewhere.
MirrorOwnerFrom(source, entityId);
return false;
}
if (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u))
{
// Target statics come from the staged landblock itself.
return false;
}
DeregisterCore(entityId, publishMutation: false);
if (registration.IsMultiPart
&& source._entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
{
RegisterMultiPart(
entityId,
registration.EntityWorldPos,
registration.EntityWorldRot,
shapes,
registration.State,
registration.Flags,
0f,
0f,
landblockId,
registration.SeedCellId,
isStatic: registration.IsStatic,
publishMutation: false);
}
else
{
Register(
entityId,
registration.GfxObjId,
registration.EntityWorldPos,
registration.EntityWorldRot,
registration.Radius,
0f,
0f,
landblockId,
registration.CollisionType,
registration.CylHeight,
registration.Scale,
registration.State,
registration.Flags,
registration.SeedCellId,
isStatic: registration.IsStatic,
publishMutation: false);
}
if (source._withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? sourceWithdrawn))
{
var retainedWithdrawn = new HashSet<uint>(sourceWithdrawn);
uint prefix = landblockId & 0xFFFF0000u;
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells)
&& cells.Exists(cell => (cell & 0xFFFF0000u) == prefix))
{
retainedWithdrawn.Remove(prefix);
}
if (retainedWithdrawn.Count != 0)
_withdrawnPrefixesByOwner[entityId] = retainedWithdrawn;
}
RefreshOwnerPrefixIndex(entityId);
_ownerVersions[entityId] = sourceVersion;
return true;
}
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
ShadowObjectRegistry staging,
uint landblockId,
IReadOnlyList<uint> expectedRetainedOwners) => new(
this,
staging,
landblockId,
expectedRetainedOwners);
internal bool OwnerTouchesLandblock(uint entityId, uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record))
return false;
if ((record.SeedCellId & 0xFFFF0000u) == prefix)
return true;
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells)
&& cells.Exists(cell => (cell & 0xFFFF0000u) == prefix))
{
return true;
}
return _withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn)
&& withdrawn.Contains(prefix);
}
internal bool IsStaticOwnerRootedIn(uint entityId, uint landblockId) =>
_entityReg.TryGetValue(entityId, out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u);
internal bool TryGetStaticOwnerRootPrefix(
uint entityId,
out uint landblockPrefix)
{
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
&& registration.IsStatic)
{
landblockPrefix = registration.SeedCellId & 0xFFFF0000u;
return true;
}
landblockPrefix = 0u;
return false;
}
/// <summary>
/// True while <paramref name="entityId"/> owns a logical shadow
/// registration (suspended or live). Public since C3c: the graphical
/// host reports the truthful
/// local-player shadow disposition (authored payload vs proven
/// shapeless) into the Runtime first-entry activation, which
/// <see cref="TryPrepareSetPosition"/> validates against this exact
/// registry state.
/// </summary>
public bool HasLogicalOwner(uint entityId) =>
_entityReg.ContainsKey(entityId);
/// <summary>
/// Returns the exact collision identity consumed by retail
/// <c>CPhysicsObj::track_object_collision</c>. Reporting must classify
/// an encountered object from the same retained shadow registration that
/// produced the collision; presence of an entity id alone is not enough
/// to infer either a static/environment collision or physics state.
/// </summary>
internal bool TryGetCollisionOwner(
uint entityId,
out uint physicsState,
out bool isStatic)
{
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration))
{
physicsState = registration.State;
isStatic = registration.IsStatic;
return true;
}
physicsState = 0u;
isStatic = false;
return false;
}
public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) =>
_prefixOwnerSlots.TryGetValue(
landblockId & 0xFFFF0000u,
out List<uint>? slots)
? slots.Count
: 0;
public int OwnerVersionCountForDiagnostics => _ownerVersions.Count;
public int PrefixOwnerContainerCountForDiagnostics =>
_prefixOwnerSlots.Count;
private bool TryCaptureOwnerState(
uint entityId,
out PreparedShadowOwnerState? state)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? registration))
{
state = null;
return false;
}
_entityToCells.TryGetValue(entityId, out List<uint>? cells);
_suspendedEntityCells.TryGetValue(
entityId,
out List<uint>? suspendedCells);
_entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes);
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn);
var rows = new List<PreparedShadowCellRows>();
if (cells is not null)
{
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
{
rows.Add(new PreparedShadowCellRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
}
}
}
state = new PreparedShadowOwnerState(
entityId,
registration,
shapes,
cells is null ? null : new List<uint>(cells),
rows,
_suspendedEntities.Contains(entityId),
suspendedCells is null ? null : new List<uint>(suspendedCells),
withdrawn is null ? null : new HashSet<uint>(withdrawn));
return true;
}
private void InstallOwnerState(PreparedShadowOwnerState state)
{
_entityReg[state.EntityId] = state.Registration;
if (state.Shapes is not null)
_entityShapes[state.EntityId] = state.Shapes;
if (state.Suspended)
_suspendedEntities.Add(state.EntityId);
if (state.SuspendedCellIds is not null)
_suspendedEntityCells[state.EntityId] = state.SuspendedCellIds;
if (state.WithdrawnPrefixes is not null)
{
_withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes;
}
if (state.CellIds is not null)
_entityToCells[state.EntityId] = state.CellIds;
for (int rowIndex = 0; rowIndex < state.Rows.Count; rowIndex++)
{
PreparedShadowCellRows row = state.Rows[rowIndex];
for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++)
AddEntryToCell(row.Entries[entryIndex], row.CellId);
}
BumpOwnerVersion(state.EntityId);
}
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly ShadowObjectRegistry _active;
private readonly ShadowObjectRegistry _staging;
private readonly uint _prefix;
private readonly IReadOnlyList<uint> _expected;
private readonly List<uint>? _activeSlots;
private readonly List<uint>? _stagingSlots;
private readonly int _activeSlotLimit;
private readonly int _stagingSlotLimit;
private readonly HashSet<uint> _owners = new();
private readonly List<uint> _ownerIds = new();
private readonly List<PreparedShadowOwnerSlot> _states = new();
private readonly Dictionary<uint, int> _stateIndex = new();
private int _expectedIndex;
private int _activeSlotIndex;
private int _stagingSlotIndex;
private int _ownerIndex;
private int _phase;
internal LandblockReplacementBuilder(
ShadowObjectRegistry active,
ShadowObjectRegistry staging,
uint landblockId,
IReadOnlyList<uint> expected)
{
_active = active;
_staging = staging;
_prefix = landblockId & 0xFFFF0000u;
_expected = expected;
active._prefixOwnerSlots.TryGetValue(
_prefix,
out _activeSlots);
staging._prefixOwnerSlots.TryGetValue(
_prefix,
out _stagingSlots);
_activeSlotLimit = _activeSlots?.Count ?? 0;
_stagingSlotLimit = _stagingSlots?.Count ?? 0;
}
internal int WorkUnits { get; private set; }
internal PreparedLandblockShadowReplacement? Prepared { get; private set; }
internal bool Advance()
{
switch (_phase)
{
case 0:
if (_expectedIndex < _expected.Count)
{
AddOwner(_expected[_expectedIndex++]);
WorkUnits++;
return false;
}
_phase++;
return false;
case 1:
if (_activeSlotIndex < _activeSlotLimit)
{
uint ownerId = _activeSlots![_activeSlotIndex++];
if (_active._entityReg.TryGetValue(
ownerId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == _prefix)
{
AddOwner(ownerId);
}
WorkUnits++;
return false;
}
_phase++;
return false;
case 2:
if (_stagingSlotIndex < _stagingSlotLimit)
{
uint ownerId = _stagingSlots![_stagingSlotIndex++];
// O2 (2026-08-02): every staged logical owner in the
// target's prefix slots — authored statics AND owners
// registered directly into the staging generation —
// must reach the active world through the delta
// commit's owner installs; the old whole-root transfer
// carried them implicitly. Mirrored owners identical
// to their active state reinstall in place, so the
// wider filter stays exact and prefix-scoped.
if (_staging._entityReg.ContainsKey(ownerId))
AddOwner(ownerId);
WorkUnits++;
return false;
}
_phase++;
return false;
case 3:
if (_ownerIndex < _ownerIds.Count)
{
uint ownerId = _ownerIds[_ownerIndex++];
_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state);
_stateIndex[ownerId] = _states.Count;
_states.Add(new PreparedShadowOwnerSlot(ownerId, state));
WorkUnits++;
return false;
}
Prepared = new PreparedLandblockShadowReplacement(
_prefix,
_ownerIds,
_states);
_phase++;
return true;
default:
return true;
}
}
internal void AddOwner(uint ownerId)
{
if (_owners.Add(ownerId))
_ownerIds.Add(ownerId);
}
internal void RefreshOwner(uint ownerId)
{
AddOwner(ownerId);
if (_stateIndex.TryGetValue(ownerId, out int index))
{
_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state);
_states[index].State = state;
return;
}
if (_phase > 3)
{
_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state);
_stateIndex[ownerId] = _states.Count;
_states.Add(new PreparedShadowOwnerSlot(ownerId, state));
}
}
public void Dispose() { }
}
private bool IsRetainedRefloodOwner(uint ownerId, uint landblockId)
{
if (!_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration)
|| _suspendedEntities.Contains(ownerId)
|| (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u)))
{
return false;
}
return OwnerTouchesLandblock(ownerId, landblockId);
}
internal sealed class PreparedLandblockShadowReplacement
{
internal PreparedLandblockShadowReplacement(
uint landblockPrefix,
IReadOnlyList<uint> ownerIds,
IReadOnlyList<PreparedShadowOwnerSlot> ownerStates)
{
LandblockPrefix = landblockPrefix;
OwnerIds = ownerIds;
OwnerStates = ownerStates;
}
internal uint LandblockPrefix { get; }
internal IReadOnlyList<uint> OwnerIds { get; }
internal IReadOnlyList<PreparedShadowOwnerSlot> OwnerStates { get; }
}
internal sealed class PreparedShadowOwnerSlot
{
internal PreparedShadowOwnerSlot(
uint entityId,
PreparedShadowOwnerState? state)
{
EntityId = entityId;
State = state;
}
internal uint EntityId { get; }
internal PreparedShadowOwnerState? State { get; set; }
}
internal sealed record PreparedShadowOwnerState(
uint EntityId,
RegistrationRecord Registration,
IReadOnlyList<ShadowShape>? Shapes,
List<uint>? CellIds,
IReadOnlyList<PreparedShadowCellRows> Rows,
bool Suspended,
List<uint>? SuspendedCellIds,
HashSet<uint>? WithdrawnPrefixes);
internal sealed record PreparedShadowCellRows(
uint CellId,
ShadowEntry[] Entries);
/// <summary>
/// Retires the complete logical registry at terminal physics-engine
/// disposal, including suspended live registrations that own no cell row.
/// </summary>
public void Clear()
{
bool mutated = _cells.Count != 0
|| _entityToCells.Count != 0
|| _entityReg.Count != 0
|| _suspendedEntities.Count != 0
|| _suspendedEntityCells.Count != 0
|| _nextPreparedSetPositionCommitId
!= _lastAppliedSetPositionCommitId;
if (mutated)
AdvanceMutationRevision();
_cells.Clear();
_entityToCells.Clear();
_suspendedEntities.Clear();
_suspendedEntityCells.Clear();
_withdrawnPrefixesByOwner.Clear();
_entityShapes.Clear();
_entityReg.Clear();
_ownerVersions.Clear();
_ownerPrefixes.Clear();
_prefixOwnerSlots.Clear();
_prefixOwnerIndices.Clear();
_prefixFreeSlots.Clear();
_ownerSlots.Clear();
_ownerIndices.Clear();
_ownerFreeSlots.Clear();
_prefixScratch.Clear();
_removedPrefixScratch.Clear();
_pendingSetPositionDispatches.Clear();
_fallback = null;
}
/// <summary>
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
/// Single-shape entities return one entry per shape; multi-part entities
/// return one entry per registered part (including duplicate shapes at the
/// same position). Each entity is enumerated exactly once per logical part:
/// we use the first cell the entity occupies to read its entries, avoiding
/// re-emitting the same part for each cell it overlaps.
/// Intended for debug rendering only.
/// </summary>
public IEnumerable<ShadowEntry> AllEntriesForDebug()
{
var seenEntities = new HashSet<uint>();
foreach (var kvp in _entityToCells)
{
uint entityId = kvp.Key;
if (!seenEntities.Add(entityId)) continue;
// Use the first cell that holds entries for this entity.
foreach (uint cellId in kvp.Value)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
bool anyFound = false;
foreach (var entry in list)
{
if (entry.EntityId == entityId)
{
yield return entry;
anyFound = true;
}
}
if (anyFound) break; // Only use the first cell — avoids duplicating multi-cell shapes.
}
}
}
}
/// <summary>
/// Collision type for a shadow entry. BSP uses full polygon collision.
/// Cylinder uses a cylinder-sphere intersection test (XY distance + height clamp).
/// Sphere uses a true 3-D sphere-sphere intersection test (no height clamp).
/// </summary>
public enum ShadowCollisionType : byte { BSP, Cylinder, Sphere }
public readonly record struct ShadowEntry(
uint EntityId,
uint GfxObjId,
Vector3 Position,
Quaternion Rotation,
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f,
float Scale = 1.0f,
/// <summary>
/// Retail <c>PhysicsState</c> bits (<c>acclient.h:2815</c>). Used
/// by <c>FindObjCollisions</c> to honor <c>ETHEREAL_PS=0x4</c> +
/// <c>IGNORE_COLLISIONS_PS=0x10</c> short-circuits. Zero for static
/// landblock entities (default behavior matches pre-Commit-A).
/// </summary>
uint State = 0u,
/// <summary>
/// Decoded player / PK / PKLite / Impenetrable flags driving the
/// retail PvP exemption block in <c>FindObjCollisions</c>. Built
/// from <c>PWD._bitfield</c> at <c>CreateObject</c> time via
/// <see cref="EntityCollisionFlagsExt.FromPwdBitfield(uint)"/>.
/// </summary>
EntityCollisionFlags Flags = EntityCollisionFlags.None,
// A6.P4 door fix (2026-05-24): local-to-entity transform for multi-part
// entities. ShadowObjectRegistry.UpdatePosition uses these to rebuild
// Position/Rotation when the entity moves. Single-shape callers leave
// these at default (zero offset, identity rotation) — equivalent to
// the shape sitting at the entity's origin.
Vector3 LocalPosition = default,
Quaternion LocalRotation = default);