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