acdream/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
Erik 8d63e5c28a test(vfx): harden live missile and effect lifetimes
Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset.

Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures.

Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 15:47:00 +02:00

627 lines
26 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-&gt;shadow_object_list</c>, Ghidra 0x0052b750).
///
/// <para>
/// BR-7 / A6.P4 (2026-06-11): this replaces the previous outdoor 24-m XY
/// grid-rectangle placement + 9-landblock radial query sweep. There is no
/// spatial radius anywhere in retail's query path; cell membership IS the
/// broad phase. The b3ce505 indoor-primary gate, the isViewer exemption,
/// and the +5 m query pad all existed to compensate the grid approximation
/// and are deleted with it.
/// </para>
/// </summary>
public sealed class ShadowObjectRegistry
{
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
private readonly HashSet<uint> _suspendedEntities = new();
/// <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 readonly Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes = new();
/// <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 readonly Dictionary<uint, RegistrationRecord> _entityReg = new();
private 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);
/// <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)
{
// 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;
Deregister(entityId);
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);
}
/// <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 with its full sphere set, not per part). Flood spheres
/// follow retail's rule (Ghidra 0x0052b9f0): when the object has
/// CylSpheres, they alone drive the flood (base point + cyl radius,
/// capped at 10); otherwise the BSP parts' bounding spheres stand in
/// for the sorting sphere. 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)
{
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;
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
if (cellSet.Count == 0) return;
Deregister(entityId);
_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);
}
/// <summary>
/// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0):
/// when the object has cylinder shapes, each contributes one sphere at
/// its world BASE point (low_pt) with the cylinder radius, capped at 10;
/// otherwise the BSP parts' bounding spheres are the footprint (the
/// sorting-sphere fallback, calc_cross_cells 0x00515230 tail).
/// </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; break; }
}
foreach (var s in shapes)
{
if (anyCyl && s.CollisionType != ShadowCollisionType.Cylinder)
continue;
if (spheres.Count >= RetailSphereCap)
break;
var world = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = world,
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)
{
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;
if (lbPrefix == 0u) return 0u;
// 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>
/// 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))
{
foreach (uint cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(entry => entry.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
_suspendedEntities.Add(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 lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new List<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;
}
}
}
}
foreach (uint entityId in toReflood)
{
var reg = _entityReg[entityId];
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);
}
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);
}
}
}
/// <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.
if (_entityReg.TryGetValue(entityId, out var retainedRegistration))
_entityReg[entityId] = retainedRegistration with { State = newState };
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
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 (_entityReg.TryGetValue(entityId, out var reg))
_entityReg[entityId] = reg with { State = newState };
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
{
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
_entityShapes.Remove(entityId);
_entityReg.Remove(entityId);
_suspendedEntities.Remove(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>();
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);
}
}
}
/// <summary>
/// All objects registered in a specific cell — retail
/// <c>CObjCell::find_obj_collisions</c> iterating only
/// <c>this-&gt;shadow_object_list</c> (Ghidra 0x0052b750). THE query
/// surface: the Transition system calls this per cell in its transit
/// cell array (primary via the insert, others via check_other_cells).
/// </summary>
public IReadOnlyList<ShadowEntry> GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return System.Array.Empty<ShadowEntry>();
}
public int TotalRegistered => _entityToCells.Count;
/// <summary>
/// Total logical shadow registrations, including dynamic live objects
/// suspended with zero cell rows while their landblock is unavailable.
/// Lifecycle stress gates must inspect this value as well as
/// <see cref="TotalRegistered"/> so a retained collision payload cannot
/// survive its owning live-object incarnation unnoticed.
/// </summary>
public int RetainedRegistrationCount => _entityReg.Count;
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
public int SuspendedRegistrationCount => _suspendedEntities.Count;
/// <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);