using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
///
/// Per-cell shadow-object index — the collision-query side of retail's
/// CObjCell.shadow_object_list (acclient.h:30916-30936). Each entity
/// registers into the EXACT cells its collision footprint overlaps, computed
/// at registration time by the sphere-overlap portal flood
/// ( = retail
/// CObjCell::find_cell_list, Ghidra 0x0052b4e0, as invoked by
/// calc_cross_cells(_static) 0x00515230/0x00515160). The Transition
/// system queries strictly per cell ( = retail
/// CObjCell::find_obj_collisions iterating only
/// this->shadow_object_list, Ghidra 0x0052b750).
///
///
/// BR-7 / A6.P4 (2026-06-11): this replaces the previous outdoor 24-m XY
/// grid-rectangle placement + 9-landblock radial query sweep. There is no
/// spatial radius anywhere in retail's query path; cell membership IS the
/// broad phase. The b3ce505 indoor-primary gate, the isViewer exemption,
/// and the +5 m query pad all existed to compensate the grid approximation
/// and are deleted with it.
///
///
public sealed class ShadowObjectRegistry
{
private readonly Dictionary> _cells = new();
private readonly Dictionary> _entityToCells = new(); // for deregistration
///
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
/// to recompose part world-transforms when
/// the entity moves. Cleared by .
///
private readonly Dictionary> _entityShapes = new();
///
/// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is
/// CObjCell::init_objects → recalc_cross_cells on cell load
/// (Ghidra 0x0052b420 / 0x00515a30): the flood can only traverse loaded
/// cells, so an object registered before its neighbourhood streams in
/// gets its cell set recomputed afterwards.
/// is the streaming-side trigger.
///
private readonly Dictionary _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);
///
/// The flood's data source (cells, buildings, terrain origins). Wired by
/// when its own DataCache is set.
/// A bare registry (unit tests) floods against an empty cache: outdoor
/// seeds still produce the overlapped landcells (pure LandDefs math);
/// indoor seeds resolve to just the seed cell.
///
public PhysicsDataCache? DataCache { get; set; }
private PhysicsDataCache _fallbackCache => _fallback ??= new PhysicsDataCache();
private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
///
/// Register a single-shape entity. is the
/// entity's m_position.objcell_id — the flood seed. Pass 0 to
/// derive the outdoor landcell under
/// (landblock-baked statics whose position is implicitly outdoor).
///
///
/// For shapes the flood
/// sphere is the cylinder BASE point with the cylinder radius — retail
/// globalizes CylSphere low_pt (overload Ghidra 0x0052b9f0).
/// For BSP shapes it is the part bounding sphere (retail's
/// sorting-sphere fallback).
///
///
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f, float scale = 1.0f,
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None,
uint seedCellId = 0u,
bool isStatic = true)
{
// 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(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);
}
///
/// Register one logical entity composed of multiple collision shapes
/// (A6.P4 door fix, 2026-05-24). All emitted
/// rows share ; the shape list is cached so
/// can recompose part transforms.
///
///
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
/// per OBJECT 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.
///
///
public void RegisterMultiPart(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u,
bool isStatic = false)
{
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(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);
}
///
/// 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).
///
private static List BuildFloodSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList shapes)
{
const int RetailSphereCap = 10;
var spheres = new List();
bool anyCyl = false;
foreach (var s in shapes)
{
if (s.CollisionType == ShadowCollisionType.Cylinder) { anyCyl = true; 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;
}
///
/// Derive the outdoor landcell id under a world position — the implicit
/// seed for landblock-baked statics registered without a cell id
/// (retail: their m_position resolves outdoor via adjust_to_outside).
///
private static uint DeriveOutdoorSeed(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId)
{
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);
}
/// Helper: append a to a cell's
/// list, creating the list if needed.
private void AddEntryToCell(ShadowEntry entry, uint cellId)
{
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List();
_cells[cellId] = list;
}
list.Add(entry);
}
///
/// Update an already-registered entity's world position + rotation,
/// preserving its ,
/// , and shape parameters.
///
///
/// Retail re-registers every moved object per successful transition step
/// (SetPositionInternal tail, Ghidra 0x00515330: remove_shadows_from_cells
/// + add_shadows_to_cells with the transition's cell array) and on
/// server-driven SetPosition via calc_cross_cells. Remote entities have
/// no local transition, so this runs the same flood from their reported
/// cell ( = the wire position's full cell
/// id). Retail keeps the previous shadows when the new array would be
/// EMPTY (the num_cells != 0 gate at pc:283540) — mirrored here
/// by skipping the re-registration when no seed resolves.
///
///
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u)
{
if (!_entityReg.TryGetValue(entityId, out var reg))
return; // not registered — no-op (callers don't have to gate)
// Keep-when-empty (retail pc:283540): no resolvable seed → leave the
// previous registration in place.
if (seedCellId == 0u
&& DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u)
return;
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(entityId, worldPos, rotation, shapes,
reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId,
seedCellId, reg.IsStatic);
return;
}
Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius,
worldOffsetX, worldOffsetY, landblockId,
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, seedCellId, reg.IsStatic);
}
///
/// BR-7 streaming hook — re-run the flood for every entity whose seed
/// cell or current cell set touches 's
/// prefix. Retail's equivalent runs per loaded cell
/// (CObjCell::init_objects → recalc_cross_cells, Ghidra
/// 0x0052b420/0x00515a30); per-landblock granularity matches our
/// streaming unit. Covers both race directions: entity registered
/// before its neighbourhood hydrated (flood couldn't traverse), and
/// cells hydrated after a server spawn landed.
///
public void RefloodLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new List();
foreach (var kvp in _entityReg)
{
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);
}
}
}
///
/// Update the cached bits for an
/// already-registered entity. Called by the inbound
/// SetState (0xF74B) dispatcher when the server broadcasts a
/// post-spawn PhysicsState change — chiefly doors flipping
/// ETHEREAL_PS = 0x4 on Use, so the
/// short-circuit can honor
/// the new state on the next resolve.
///
///
/// Retail equivalent: CPhysicsObj::set_state at
/// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044
/// — direct write `this->state = arg2`. Retail also fires side-effect
/// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden)
/// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1
/// scopes to the bare state-write.
///
///
///
/// Implementation: is a value-type record
/// copied into per-cell lists, so we rewrite the copy in each cell the
/// entity occupies. Unregistered entities are a no-op (callers don't
/// have to gate).
///
///
public void UpdatePhysicsState(uint entityId, uint newState)
{
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 };
}
/// Remove an entity from all cells it was registered in.
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);
}
///
/// Remove all entities belonging to a landblock. With flood-driven
/// registration an entity's cells can span landblock prefixes; entries
/// under OTHER prefixes survive, and the entity is fully dropped only
/// when no cells remain (its owner despawns it via
/// in the normal path — retail's per-object
/// remove_shadows_from_cells, Ghidra 0x00511230).
///
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List();
foreach (var kvp in _cells)
{
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
toRemove.Add(kvp.Key);
}
foreach (var cellId in toRemove)
_cells.Remove(cellId);
// Clean up entity-to-cell map
var entitiesToRemove = new List();
foreach (var kvp in _entityToCells)
{
kvp.Value.RemoveAll(c => (c & 0xFFFF0000u) == lbPrefix);
if (kvp.Value.Count == 0)
entitiesToRemove.Add(kvp.Key);
}
foreach (var eid in entitiesToRemove)
{
_entityToCells.Remove(eid);
_entityShapes.Remove(eid);
_entityReg.Remove(eid);
}
}
///
/// All objects registered in a specific cell — retail
/// CObjCell::find_obj_collisions iterating only
/// this->shadow_object_list (Ghidra 0x0052b750). THE query
/// surface: the Transition system calls this per cell in its transit
/// cell array (primary via the insert, others via check_other_cells).
///
public IReadOnlyList GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return System.Array.Empty();
}
public int TotalRegistered => _entityToCells.Count;
///
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
/// Single-shape entities return one entry per shape; multi-part entities
/// return one entry per registered part (including duplicate shapes at the
/// same position). Each entity is enumerated exactly once per logical part:
/// we use the first cell the entity occupies to read its entries, avoiding
/// re-emitting the same part for each cell it overlaps.
/// Intended for debug rendering only.
///
public IEnumerable AllEntriesForDebug()
{
var seenEntities = new HashSet();
foreach (var kvp in _entityToCells)
{
uint entityId = kvp.Key;
if (!seenEntities.Add(entityId)) continue;
// Use the first cell that holds entries for this entity.
foreach (uint cellId in kvp.Value)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
bool anyFound = false;
foreach (var entry in list)
{
if (entry.EntityId == entityId)
{
yield return entry;
anyFound = true;
}
}
if (anyFound) break; // Only use the first cell — avoids duplicating multi-cell shapes.
}
}
}
}
///
/// Collision type for a shadow entry. BSP uses full polygon collision.
/// Cylinder uses a simple cylinder-sphere intersection test.
///
public enum ShadowCollisionType : byte { BSP, Cylinder }
public readonly record struct ShadowEntry(
uint EntityId,
uint GfxObjId,
Vector3 Position,
Quaternion Rotation,
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f,
float Scale = 1.0f,
///
/// Retail PhysicsState bits (acclient.h:2815). Used
/// by FindObjCollisions to honor ETHEREAL_PS=0x4 +
/// IGNORE_COLLISIONS_PS=0x10 short-circuits. Zero for static
/// landblock entities (default behavior matches pre-Commit-A).
///
uint State = 0u,
///
/// Decoded player / PK / PKLite / Impenetrable flags driving the
/// retail PvP exemption block in FindObjCollisions. Built
/// from PWD._bitfield at CreateObject time via
/// .
///
EntityCollisionFlags Flags = EntityCollisionFlags.None,
// A6.P4 door fix (2026-05-24): local-to-entity transform for multi-part
// entities. ShadowObjectRegistry.UpdatePosition uses these to rebuild
// Position/Rotation when the entity moves. Single-shape callers leave
// these at default (zero offset, identity rotation) — equivalent to
// the shape sitting at the entity's origin.
Vector3 LocalPosition = default,
Quaternion LocalRotation = default);