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);