using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
///
/// Per-cell shadow-object index — the collision-query side of retail's
/// CObjCell.shadow_object_list (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
/// ( = retail
/// CObjCell::find_cell_list, Ghidra 0x0052b4e0, as invoked by
/// calc_cross_cells(_static) 0x00515230/0x00515160). The Transition
/// system queries strictly per cell ( = retail
/// CObjCell::find_obj_collisions iterating only
/// this->shadow_object_list, Ghidra 0x0052b750).
///
///
/// 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.
///
///
public sealed class ShadowObjectRegistry
{
private CollisionWorldStateSlot _collisionWorld;
private Dictionary> _cells =>
_collisionWorld.Current.ShadowCells;
private Dictionary> _entityToCells =>
_collisionWorld.Current.ShadowEntityCells; // for deregistration
private HashSet _suspendedEntities =>
_collisionWorld.Current.SuspendedShadowEntities;
private Dictionary> _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> _withdrawnPrefixesByOwner =>
_collisionWorld.Current.WithdrawnPrefixesByOwner;
///
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
/// to recompose part world-transforms when
/// the entity moves. Cleared by .
///
private Dictionary> _entityShapes =>
_collisionWorld.Current.ShadowEntityShapes;
///
/// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is
/// CObjCell::init_objects → recalc_cross_cells 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.
/// is the streaming-side trigger.
///
private Dictionary _entityReg =>
_collisionWorld.Current.ShadowEntityRegistrations;
private Dictionary _ownerVersions =>
_collisionWorld.Current.ShadowOwnerVersions;
private Dictionary> _ownerPrefixes =>
_collisionWorld.Current.ShadowOwnerPrefixes;
private Dictionary> _prefixOwnerSlots =>
_collisionWorld.Current.ShadowPrefixOwnerSlots;
private Dictionary> _prefixOwnerIndices =>
_collisionWorld.Current.ShadowPrefixOwnerIndices;
private Dictionary> _prefixFreeSlots =>
_collisionWorld.Current.ShadowPrefixFreeSlots;
private List _ownerSlots =>
_collisionWorld.Current.ShadowOwnerSlots;
private Dictionary _ownerIndices =>
_collisionWorld.Current.ShadowOwnerIndices;
private Stack _ownerFreeSlots =>
_collisionWorld.Current.ShadowOwnerFreeSlots;
private readonly HashSet _prefixScratch = new();
private readonly List _removedPrefixScratch = new();
private ulong _mutationRevision;
private ulong _nextPreparedSetPositionCommitId;
private ulong _lastAppliedSetPositionCommitId;
private readonly HashSet _pendingSetPositionDispatches = [];
private long _setPositionDispatchFailureCount;
internal event Action? OwnerMutated;
internal event Action? 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;
///
/// 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.
///
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? slots);
return new RetainedRefloodOwnerScan(this, prefix, slots);
}
internal sealed class RetainedRefloodOwnerScan : IDisposable
{
private readonly ShadowObjectRegistry _owner;
private readonly uint _prefix;
private readonly List? _slots;
private readonly int _limit;
private int _index;
private bool _completed;
internal RetainedRefloodOwnerScan(
ShadowObjectRegistry owner,
uint prefix,
List? 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? cells))
{
for (int index = 0; index < cells.Count; index++)
_prefixScratch.Add(cells[index] & 0xFFFF0000u);
}
if (_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet? withdrawn))
{
foreach (uint prefix in withdrawn)
_prefixScratch.Add(prefix & 0xFFFF0000u);
}
if (!_ownerPrefixes.TryGetValue(entityId, out HashSet? current))
{
current = new HashSet();
_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? 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? slots))
{
slots = new List();
_prefixOwnerSlots[prefix] = slots;
_prefixOwnerIndices[prefix] = new Dictionary();
_prefixFreeSlots[prefix] = new Stack();
}
Dictionary indices = _prefixOwnerIndices[prefix];
if (indices.ContainsKey(entityId))
continue;
Stack 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? prefixes))
{
foreach (uint prefix in prefixes)
{
if (!_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary? 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 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);
///
/// The flood's data source (cells, buildings, terrain origins). Wired by
/// when its own DataCache 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.
///
public PhysicsDataCache? DataCache { get; set; }
private PhysicsDataCache _fallbackCache => _fallback ??= new PhysicsDataCache();
private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
///
/// Register a single-shape entity. is the
/// entity's m_position.objcell_id — the flood seed. Pass 0 to
/// derive the outdoor landcell under
/// (landblock-baked statics whose position is implicitly outdoor).
///
///
/// For shapes the flood
/// sphere is the cylinder BASE point with the cylinder radius — retail
/// globalizes CylSphere low_pt (overload Ghidra 0x0052b9f0).
/// For BSP shapes it is the part bounding sphere (retail's
/// sorting-sphere fallback).
///
///
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(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);
}
///
/// Register one logical entity composed of multiple collision shapes
/// (A6.P4 door fix, 2026-05-24). All emitted
/// rows share ; the shape list is cached so
/// can recompose part transforms.
///
///
/// 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 HAS_PHYSICS_BSP_PS
/// (CPhysicsObj::calc_cross_cells @0x00515230,
/// 0x00515285 test dword [esi+0xa8],0x10000 /
/// 0x0051528f jne 0x515305):
///
///
/// - BSP-bearing → find_bbox_cell_list @0x00510fc0, ported as
/// . Each part
/// contributes its authored BOUNDING BOX
/// (/Max), 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.
/// - otherwise → +
/// , retail's
/// cylsphere and sorting-sphere branches, byte-identical to before
/// #334 for every object that legitimately is spherical.
///
///
/// Every shape row is then written into every flooded cell, mirroring
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
///
///
public void RegisterMultiPart(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList 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 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(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);
}
///
/// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail CPartArray::SetPart 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.
///
public void ReplaceMultiPartPayload(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList 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? cells))
{
BumpOwnerVersion(entityId);
return;
}
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List? 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);
}
///
/// Flood spheres for an object with NO physics BSP — retail's cylsphere
/// and sorting-sphere branches of CPhysicsObj::calc_cross_cells
/// @0x00515230, both of which sit BELOW the HAS_PHYSICS_BSP_PS jump
/// at 0x0051528f jne 0x515305 and are unreachable from it:
///
///
/// - cylspheres (0x00515298 GetNumCylsphere non-zero) →
/// CObjCell::find_cell_list @0x0052b9f0 over the cylsphere array;
/// each contributes one sphere at its world BASE point with the cylinder
/// radius, capped at 10.
/// - else the sorting sphere (0x005152dc →
/// CPartArray::GetSortingSphere @0x00518b00 →
/// CObjCell::find_cell_list @0x0052b990).
///
///
///
/// #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
/// (), and
/// 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).
///
///
private static List BuildFloodSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList shapes)
{
const int RetailSphereCap = 10;
var spheres = new List();
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;
}
///
/// #334: the per-part world-placed authored boxes retail's
/// CLandCell::add_all_outside_cells @0x00533360 divides by
/// square_length. Composed exactly as the emitted
/// rows are, so the flood rectangle and the
/// collision geometry describe the same placement.
///
private static List BuildFloodPartBoxes(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList shapes)
{
var boxes = new List(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot));
}
return boxes;
}
///
/// The per-part BSP ROOT bounding spheres retail's part-array
/// CEnvCell::find_transit_cells @0x0052cae0 loads at
/// 0x0052cb36 mov esi,[ecx+0x74], transforms through the part's own
/// Position (0x0052cb4c / Position::localtolocal) and reads
/// the radius from at 0x0052cb65 fadd [esi+0xc].
///
///
/// These drive ONLY the indoor half of the BSP flood and the outdoor
/// building bridge (CEnvCell::check_building_transit @0x0052c5d0),
/// which still use the sphere traversal — the AP-159 residual. The
/// outdoor expansion uses and never
/// these. No cap: find_bbox_cell_list walks every part, bounded
/// only by num_parts (7 installed Setups carry more than 10
/// physics-BSP parts, max 49 on Setup 0x02001A91).
///
///
private static List BuildBspPartSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList shapes)
{
var spheres = new List(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;
}
///
/// 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).
///
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);
}
/// Helper: append a to a cell's
/// list, creating the list if needed.
private void AddEntryToCell(ShadowEntry entry, uint cellId)
{
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List();
_cells[cellId] = list;
}
list.Add(entry);
}
///
/// Update an already-registered entity's world position + rotation,
/// preserving its ,
/// , and shape parameters.
///
///
/// 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 ( = the wire position's full cell
/// id). Retail keeps the previous shadows when the new array would be
/// EMPTY (the num_cells != 0 gate at pc:283540) — mirrored here
/// by skipping the re-registration when no seed resolves.
///
///
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);
}
///
/// Installs the shadow-list suffix of one canonical retail
/// CPhysicsObj::SetPositionInternal 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.
///
internal void CommitSetPosition(
uint entityId,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
float worldOffsetX,
float worldOffsetY,
PhysicsShadowCommitAction action,
System.Collections.Immutable.ImmutableArray 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));
}
}
///
/// 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.
///
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? OwnerPrefixes,
uint[] ChangedPrefixes);
internal sealed record PreparedShadowCellReplacement(
uint CellId,
List Entries);
internal sealed record PreparedShadowPrefixReplacement(
uint Prefix,
bool Remove,
List? Slots,
Dictionary? Indices,
Stack? FreeSlots);
internal readonly record struct SetPositionShadowCommitReceipt(
ulong CommitId,
uint EntityId,
ulong OwnerVersion,
uint[] ChangedPrefixes,
bool Mutated)
{
internal bool IsValid => CommitId != 0UL && EntityId != 0u;
}
///
/// 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.
///
internal bool TryPrepareSetPosition(
uint entityId,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
float worldOffsetX,
float worldOffsetY,
PhysicsShadowCommitAction action,
System.Collections.Immutable.ImmutableArray 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());
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 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);
}
///
/// 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.
///
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(),
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? observers = OwnerPrefixMembershipChanged;
if (observers is null)
return;
foreach (Action observer in observers.GetInvocationList())
{
try
{
observer(owner, prefix);
}
catch
{
_setPositionDispatchFailureCount++;
}
}
}
private void DispatchSetPositionOwnerObservers(uint owner, ulong version)
{
Action? observers = OwnerMutated;
if (observers is null)
return;
foreach (Action observer in observers.GetInvocationList())
{
try
{
observer(owner, version);
}
catch
{
_setPositionDispatchFailureCount++;
}
}
}
private static uint[] CaptureChangedPrefixes(
PreparedShadowOwnerState before,
PreparedShadowOwnerState after)
{
HashSet oldPrefixes = CapturePrefixes(before);
HashSet newPrefixes = CapturePrefixes(after);
var changed = new List();
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 CapturePrefixes(
PreparedShadowOwnerState state)
{
var prefixes = new HashSet
{
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();
AddCells(touched, before.CellIds);
AddCells(touched, after.CellIds);
var afterRows = new Dictionary();
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? 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(
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 before,
HashSet 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? oldSlots);
_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary? oldIndices);
_prefixFreeSlots.TryGetValue(prefix, out Stack? oldFree);
var slots = oldSlots is null ? [] : new List(oldSlots);
var indices = oldIndices is null
? new Dictionary()
: new Dictionary(oldIndices);
Stack 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 CloneStack(Stack? source) =>
source is null
? new Stack()
: new Stack(source.Reverse());
private static void AddCells(HashSet destination, List? cells)
{
if (cells is null)
return;
for (int index = 0; index < cells.Count; index++)
destination.Add(cells[index]);
}
private static void ReplaceOwnerValue(
Dictionary 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? 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 cellIds)
{
if (_entityToCells.TryGetValue(
entityId,
out List? previousCells))
{
for (int index = 0; index < previousCells.Count; index++)
{
if (_cells.TryGetValue(
previousCells[index],
out List? entries))
{
RemoveOwnerRows(entries, entityId);
}
}
}
_suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId);
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
var exactCells = new List(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? 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? withdrawn))
{
for (int index = 0; index < exactCells.Count; index++)
withdrawn.Remove(exactCells[index] & 0xFFFF0000u);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
BumpOwnerVersion(entityId);
}
///
/// 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
/// CPhysicsObj::remove_shadows_from_cells during temporary
/// leave-world/pending-cell residence; it is deliberately not logical
/// teardown.
///
public bool Suspend(uint entityId)
{
if (!_entityReg.ContainsKey(entityId))
return false;
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
_suspendedEntityCells[entityId] = new List(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;
}
///
/// BR-7 streaming hook — re-run the flood for every entity whose seed
/// cell or current cell set touches 's
/// prefix. Retail's equivalent runs per loaded cell
/// (CObjCell::init_objects → recalc_cross_cells, 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.
///
public void RefloodLandblock(uint landblockId)
{
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
for (int i = 0; i < owners.Length; i++)
RefloodOwnerForLandblock(owners[i], landblockId);
}
///
/// 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.
///
public uint[] CaptureRefloodOwnersForLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new HashSet();
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;
}
///
/// Re-runs one owner from a retained landblock-reflood receipt. A removed,
/// suspended, or otherwise superseded owner is an idempotent no-op.
///
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);
}
///
/// Update the cached bits for an
/// already-registered entity. Called by the inbound
/// SetState (0xF74B) dispatcher when the server broadcasts a
/// post-spawn PhysicsState change — chiefly doors flipping
/// ETHEREAL_PS = 0x4 on Use, so the
/// short-circuit can honor
/// the new state on the next resolve.
///
///
/// Retail equivalent: CPhysicsObj::set_state at
/// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044
/// — 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.
///
///
///
/// Implementation: 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).
///
///
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);
}
///
/// #297 (target-side refresh): rewrites only the PWD-bitfield-derived
/// subset of a registered entity's
/// () from a
/// fresh PublicWeenieDesc._bitfield value, leaving
/// and
/// untouched. Without this,
/// CollisionExemption.ShouldSkip's target-side read would stay
/// frozen at whatever CreateObject captured, even after the
/// mover-side value refreshes live via
/// . Mirrors
/// 's per-cell rewrite shape; a no-op for
/// an entity with no live registration.
///
///
/// F3 (review round 2): callers are expected to fire this on every
/// ObjectUpdated, 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 /
/// , and that revision is a commit
/// gate a prepared SetPosition checks before applying — an
/// unrelated bump landing between prepare and apply would invalidate an
/// otherwise-valid commit.
///
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);
}
/// Remove an entity from all cells it was registered in.
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 entries,
uint entityId)
{
for (int index = entries.Count - 1; index >= 0; index--)
{
if (entries[index].EntityId == entityId)
entries.RemoveAt(index);
}
}
///
/// 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.
///
public void DeregisterStaticOwnersForLandblock(uint landblockId)
{
uint[] owners = CaptureStaticOwnersForLandblock(landblockId);
for (int i = 0; i < owners.Length; i++)
DeregisterStaticOwnerForLandblock(owners[i], landblockId);
}
///
/// Captures a stable ordered receipt for static collision owners rooted in
/// one landblock.
///
public uint[] CaptureStaticOwnersForLandblock(uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
var owners = new List();
foreach (var (entityId, registration) in _entityReg)
{
if (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
{
owners.Add(entityId);
}
}
owners.Sort();
return owners.ToArray();
}
///
/// Removes one static owner from a retained landblock receipt. If the
/// owner was already removed or rebound elsewhere, the operation no-ops.
///
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);
}
}
///
/// 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 (retail's
/// per-object remove_shadows_from_cells, Ghidra 0x00511230).
///
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List();
var touchedOwners = new HashSet();
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();
_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();
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);
}
///
/// 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
/// without scanning the complete registry.
///
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? 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? entries))
{
RemoveOwnerRows(entries, entityId);
if (entries.Count == 0)
_cells.Remove(cellId);
}
}
if (!touched)
return;
if (!_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet? withdrawn))
{
withdrawn = new HashSet();
_withdrawnPrefixesByOwner[entityId] = withdrawn;
}
withdrawn.Add(prefix);
if (cells.Count == 0)
_entityToCells.Remove(entityId);
BumpOwnerVersion(entityId);
}
///
/// All objects registered in a specific cell — retail
/// CObjCell::find_obj_collisions iterating only
/// this->shadow_object_list (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).
///
public IReadOnlyList GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return System.Array.Empty();
}
public int TotalRegistered => _entityToCells.Count;
///
/// 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
/// so a retained collision payload cannot
/// survive its owning live-object incarnation unnoticed.
///
public int RetainedRegistrationCount => _entityReg.Count;
/// Number of owner/prefix repair markers awaiting a future reflood.
public int WithdrawnPrefixMarkerCount
{
get
{
int count = 0;
foreach (var prefixes in _withdrawnPrefixesByOwner.Values)
count += prefixes.Count;
return count;
}
}
/// Suspended logical registrations awaiting spatial re-entry.
public int SuspendedRegistrationCount => _suspendedEntities.Count;
public bool HasOwnerRowsInLandblock(uint ownerId, uint landblockId) =>
_entityToCells.TryGetValue(ownerId, out List? cells)
&& cells.Exists(cell =>
(cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u));
///
/// 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.
///
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];
///
/// 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 —
/// CObjCell::init_objects (0x0052B420) →
/// CPhysicsObj::recalc_cross_cells (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.
///
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);
}
///
/// O3 (2026-08-02): retail CObjCell::init_objects 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.
///
internal void RefloodPrefixOwnersAfterReplacement(
uint landblockId,
IReadOnlyList sealedOwnerIds)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_prefixOwnerSlots.TryGetValue(prefix, out List? slots))
return;
var applied = new HashSet(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);
}
}
///
/// 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.
///
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? 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? sourceWithdrawn))
{
var retainedWithdrawn = new HashSet(sourceWithdrawn);
uint prefix = landblockId & 0xFFFF0000u;
if (_entityToCells.TryGetValue(entityId, out List? 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 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? cells)
&& cells.Exists(cell => (cell & 0xFFFF0000u) == prefix))
{
return true;
}
return _withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet? 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;
}
///
/// True while 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
/// validates against this exact
/// registry state.
///
public bool HasLogicalOwner(uint entityId) =>
_entityReg.ContainsKey(entityId);
///
/// Returns the exact collision identity consumed by retail
/// CPhysicsObj::track_object_collision. 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.
///
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? 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? cells);
_suspendedEntityCells.TryGetValue(
entityId,
out List? suspendedCells);
_entityShapes.TryGetValue(
entityId,
out IReadOnlyList? shapes);
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet? withdrawn);
var rows = new List();
if (cells is not null)
{
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List? 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(cells),
rows,
_suspendedEntities.Contains(entityId),
suspendedCells is null ? null : new List(suspendedCells),
withdrawn is null ? null : new HashSet(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 _expected;
private readonly List? _activeSlots;
private readonly List? _stagingSlots;
private readonly int _activeSlotLimit;
private readonly int _stagingSlotLimit;
private readonly HashSet _owners = new();
private readonly List _ownerIds = new();
private readonly List _states = new();
private readonly Dictionary _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 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 ownerIds,
IReadOnlyList ownerStates)
{
LandblockPrefix = landblockPrefix;
OwnerIds = ownerIds;
OwnerStates = ownerStates;
}
internal uint LandblockPrefix { get; }
internal IReadOnlyList OwnerIds { get; }
internal IReadOnlyList 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? Shapes,
List? CellIds,
IReadOnlyList Rows,
bool Suspended,
List? SuspendedCellIds,
HashSet? WithdrawnPrefixes);
internal sealed record PreparedShadowCellRows(
uint CellId,
ShadowEntry[] Entries);
///
/// Retires the complete logical registry at terminal physics-engine
/// disposal, including suspended live registrations that own no cell row.
///
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;
}
///
/// 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.
///
public IEnumerable AllEntriesForDebug()
{
var seenEntities = new HashSet();
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.
}
}
}
}
///
/// 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).
///
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,
///
/// Retail PhysicsState bits (acclient.h:2815). Used
/// by FindObjCollisions to honor ETHEREAL_PS=0x4 +
/// IGNORE_COLLISIONS_PS=0x10 short-circuits. Zero for static
/// landblock entities (default behavior matches pre-Commit-A).
///
uint State = 0u,
///
/// Decoded player / PK / PKLite / Impenetrable flags driving the
/// retail PvP exemption block in FindObjCollisions. Built
/// from PWD._bitfield at CreateObject time via
/// .
///
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);